From 4aad48a1c42b8e11a3f1eb661116452cdd664381 Mon Sep 17 00:00:00 2001 From: MrChengLen Date: Tue, 29 Sep 2026 09:15:37 +0200 Subject: [PATCH 1/5] =?UTF-8?q?ci(docker):=20attest=20the=20SBOM=20to=20ea?= =?UTF-8?q?ch=20image=20by=20its=20pushed=20digest=20=E2=80=94=20main=20an?= =?UTF-8?q?d=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker.yml now generates the CycloneDX SBOM in a read-only job (sbom.yml's three SBOM steps, verbatim) and attests it to the slim and the office image with actions/attest v4.2.2: signed through Sigstore keyless OIDC, bound to the digest each build leg reported, stored with the repository's attestations and pushed to GHCR. It runs on every build (tags, main and manual rebuilds), so :latest and :office carry one too. release.yml finds the image digest only by tag, best effort, after a 60-second sleep, so it was the wrong place to attest. Built on #170: no image it attests comes from a restored build cache. The attesting job holds attestations, id-token and packages write, checks nothing out, installs nothing, runs one command (the registry login, with the docker CLI) and otherwise only download-artifact and attest. actions/attest-sbom has been deprecated since v4.0.0 (a wrapper that prints a warning), so actions/attest is pinned directly. create-storage-record is off: storage records exist for organisation-owned repositories only. build-and-push exposes one digest output per variant; the runner drops empty outputs, so each leg sets only its own. The SBOM covers the Python packages from requirements.lock, plus the pip of the venv it is generated from, and not the interpreter, the Debian packages, FFmpeg, Ghostscript or LibreOffice; docs, the workflow comment and the CHANGELOG say so. docs/release-signing.md documents `gh attestation verify` with --predicate-type https://cyclonedx.org/bom (without it gh accepts only SLSA provenance, and the check fails), --signer-workflow and --source-ref, and --source-digest to tie the attestation to the commit a verified tag signature covers. Rejected: attesting in release.yml (digest only by tag, best effort); docker/login-action in the attesting job (third-party code next to the signing token); one job output for both legs (the last leg would win). docker.yml cannot be tried on a branch (a dispatch pushes :latest and :office), so the guards pin what its first run on main needs: SBOM steps equal to sbom.yml's, every variant attested by its own digest output, push-to-registry, no if: or continue-on-error on either job or its steps, matching artifact and file names, the attesting job's actions, permissions and single command, a read-only SBOM job without secrets, cache or kept credentials, and the documented command's flags. Each of 42 simulated regressions fails at least one guard; two harmless edits pass. Security and code review: no blockers; their findings on the docs, the CHANGELOG and the guards are addressed. A pre-existing tag bug they found (the office build also pushes :latest on a release) is left for its own PR. Full suite 1478 green (72 skipped, Linux-only); ruff clean; gitleaks and hook patterns clean; pip-audit not needed (no dependency change). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker.yml | 125 +++++++++++++- .github/workflows/sbom.yml | 6 +- CHANGELOG.md | 49 ++++++ docs/release-signing.md | 56 ++++++- requirements-sbom.txt | 11 +- tests/test_supply_chain_hygiene.py | 251 ++++++++++++++++++++++++++--- 6 files changed, 467 insertions(+), 31 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 52e49bb..5005a79 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -5,8 +5,8 @@ on: branches: ["main"] tags: ["v*"] # Manual rebuild: dispatched on main, runs the whole chain (build, push, - # sign, `notify-ops`, deploy) without a code change, e.g. to redeploy - # during an incident. + # sign, attest the SBOM, `notify-ops`, deploy) without a code change, e.g. + # to redeploy during an incident. workflow_dispatch: env: @@ -29,6 +29,14 @@ jobs: - target: office suffix: "-office" description: "Office image (adds LibreOffice for high-fidelity docx→pdf)" + # Each variant's exact digest, for `attest-sbom`. The legs of a matrix + # share one set of job outputs, so every variant writes a name of its + # own (a name both legs set would keep whichever leg finished last) and + # leaves the other empty: the runner does not write an empty output, so + # it cannot overwrite the other leg's digest. + outputs: + digest-base: ${{ matrix.target == 'base' && steps.build.outputs.digest || '' }} + digest-office: ${{ matrix.target == 'office' && steps.build.outputs.digest || '' }} permissions: contents: read packages: write @@ -118,3 +126,116 @@ jobs: for tag in $TAGS; do cosign sign --yes "${tag}@${DIGEST}" done + + # The SBOM that `attest-sbom` binds to the images, generated in a job that + # can only read: installing the image's dependency set and the generator + # runs over a hundred third-party packages, and none of that code runs next + # to the tokens that push, sign and attest. The three SBOM steps are + # sbom.yml's, verbatim — tests/test_supply_chain_hygiene.py fails if they + # differ — so dispatching sbom.yml on a branch tries them; this workflow + # pushes images and cannot be tried that way. + sbom: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + # Must match the Dockerfile's base image — the app is only ever + # tested on the version it ships on. scripts/check_python_version.py + # gates this; see its docstring for the drift it exists to prevent. + python-version: "3.14" + # No `cache: "pip"`: code running on main can write it, and + # --require-hashes does not cover every cached file (see sbom.yml). + + - name: Install the image's dependency set + run: | + python -m venv "$RUNNER_TEMP/image-env" + "$RUNNER_TEMP/image-env/bin/pip" install --require-hashes -r requirements.lock + + - name: Install CycloneDX generator + run: | + pip install --require-hashes --only-binary :all: -r requirements-sbom.lock + + - name: Generate CycloneDX SBOM (JSON) + env: + VERSION: ${{ github.sha }} + run: | + cyclonedx-py environment "$RUNNER_TEMP/image-env/bin/python" \ + --PEP-639 \ + --output-file "filemorph-${VERSION}.cdx.json" \ + --output-format JSON + + - name: Hand the SBOM to the attestation job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom + path: filemorph-${{ github.sha }}.cdx.json + if-no-files-found: error + # A week, so that a failed attestation can be re-run ("Re-run failed + # jobs") after a weekend without building the images again. + retention-days: 7 + + # Binds the SBOM to each image's digest as a signed attestation: signed + # keyless through Sigstore like the cosign signature, stored with the + # repository's attestations and pushed to GHCR next to the image. Every + # build gets one — tags, main and manual rebuilds — so `:latest` and + # `:office` carry one too. docs/release-signing.md shows how to verify it. + # + # What the SBOM covers: the Python packages the image installs from + # requirements.lock, at the locked versions, plus the pip of the venv it + # is generated from, whose version can differ from the image's. It is + # generated from the lockfile, not by scanning the image, so the Python + # interpreter and the Debian packages of the base image and of the apt + # layers (ffmpeg, Ghostscript, the Cairo/Pango stack, LibreOffice in the + # office image) are not in it. + # + # This job can sign and push, so it checks nothing out and installs + # nothing: it downloads the SBOM, logs in with the docker CLI and runs + # GitHub's attest action. + attest-sbom: + needs: [build-and-push, sbom] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [base, office] + permissions: + attestations: write # store the attestation with the repository + id-token: write # OIDC token for the Sigstore signing certificate + packages: write # push the attestation to GHCR + steps: + # Into a directory of its own: the artifact comes from a job that ran + # third-party code. + - name: Download the SBOM + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sbom + path: sbom + + # The docker CLI rather than docker/login-action, so that only GitHub's + # own actions run next to this job's tokens. The attest action reads + # the login from the docker config file this writes. + - name: Log in to GitHub Container Registry + env: + ACTOR: ${{ github.actor }} + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: echo "$TOKEN" | docker login "$REGISTRY" --username "$ACTOR" --password-stdin + + - name: Attest the SBOM to the ${{ matrix.target }} image + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + # No tag: the digest identifies the image. The action lowercases + # the name, as registry paths must be. + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ needs.build-and-push.outputs[format('digest-{0}', matrix.target)] }} + sbom-path: sbom/filemorph-${{ github.sha }}.cdx.json + push-to-registry: true + # Storage records exist for organisation-owned repositories only, + # and would need `artifact-metadata: write`. + create-storage-record: false diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 2b96d7e..b7b3878 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -22,9 +22,9 @@ name: SBOM # # The generator itself installs from requirements-sbom.lock with # --require-hashes, like the image's dependencies: an unpinned install would -# run whatever release PyPI served that day. release.yml runs the same three -# SBOM steps verbatim and cannot be tried on a PR, so this workflow is its -# rehearsal: dispatch it on a branch that changes them. +# run whatever release PyPI served that day. release.yml and docker.yml run +# the same three SBOM steps verbatim and cannot be tried on a PR, so this +# workflow is their rehearsal: dispatch it on a branch that changes them. # tests/test_supply_chain_hygiene.py keeps the steps identical. # # Why "environment" and not "requirements": `cyclonedx-py environment` reads diff --git a/CHANGELOG.md b/CHANGELOG.md index a510c34..0939602 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,55 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added — the Docker images carry a signed SBOM attestation + +`docker.yml` now attests the CycloneDX SBOM to each image it pushes — slim and +office, on release tags, on every build of `main` and on manual rebuilds — so +`:latest` and `:office` carry one too. Until now the SBOM was a file attached +to the release, tied to the image by nothing but its name: `release.yml` finds +the image digest only by tag, best effort, a minute after the tag push. + +- **What it covers.** The Python packages the image installs from + `requirements.lock`, at the locked versions, plus the `pip` of the virtualenv + the SBOM is generated from. It comes from a clean install of the lockfile, + not from scanning the image, so the Python interpreter and the Debian + packages of the base image and of the `apt` layers (FFmpeg, Ghostscript, the + Cairo/Pango stack, LibreOffice) are not in it. `docs/release-signing.md` says + so next to the verification command. Images built before this change, + v1.1.0 among them, have no attestation. +- **How.** A new `sbom` job runs `sbom.yml`'s three SBOM steps, verbatim, with + read access only. `attest-sbom` then signs the SBOM for each image variant + with `actions/attest` v4.2.2 — Sigstore keyless, like the cosign signature — + bound to the digest the build step reported, stores it with the repository's + attestations and pushes it to GHCR. That job holds `attestations: write`, + `id-token: write` and `packages: write`, checks nothing out, installs + nothing, logs in with the docker CLI and runs only GitHub's own actions. + `actions/attest-sbom` has been deprecated since v4.0.0 and only wraps + `actions/attest`, so the latter is pinned directly. +- **Verify** with `gh attestation verify` as `docs/release-signing.md` shows + it. `--predicate-type https://cyclonedx.org/bom` is required: without it, + `gh` accepts only SLSA provenance and the check fails. `--signer-workflow`, + `--source-ref` and `--source-digest` pin the workflow, the tag and the + commit whose signed tag was verified; `--owner` alone would accept an + attestation from any repository of the account. +- **What it costs.** The images are attested once both are built, so a deploy + from `main` starts a little later. `notify-ops` deploys only after the whole + Docker run succeeds, so a failure in the SBOM job (PyPI) or the attestation + (Sigstore, the attestations API, GHCR) now holds back the deploy, a manual + redeploy included, until "Re-run failed jobs" gets through; the SBOM + artifact is kept for a week so that the re-run needs no new build. If one + image fails to build, neither is attested; the other stays pushed and signed. +- **Guards.** `tests/test_supply_chain_hygiene.py` keeps `docker.yml`'s SBOM + steps identical to `sbom.yml`'s, and fails if an image variant loses its + attestation or its own digest output, if the attestation stops naming the + build's digest or stops going to GHCR, if either job gets an `if:` or + `continue-on-error` (on the job or a step), if the attesting job gains an + action, a permission or any command besides the registry login, if the SBOM + job gains write access, a secret, a cache or kept credentials, if the two + jobs stop agreeing on the SBOM's file name, or if the documented command + drops `--predicate-type` or names a workflow that does not attest. Each of + 42 simulated regressions fails at least one guard. + ### Security — the Docker image is built without a restored build cache `docker.yml` restored BuildKit's GitHub Actions cache (`cache-from: type=gha`) diff --git a/docs/release-signing.md b/docs/release-signing.md index 593f4a5..c8782da 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -13,6 +13,11 @@ Both claims are independent: a forged tag would fail (1); a forged image at the published digest would fail (2). A consumer who needs end-to-end provenance verifies both. +The images also carry a signed **SBOM attestation** listing the Python +packages they ship. [Verifying the SBOM +attestation](#verifying-the-sbom-attestation) says what it covers and +how to check it. + ## Why this matters for the Compliance edition EVB-IT contracts (March 2026 update) require the procurer to be able @@ -60,6 +65,55 @@ A successful verification prints the signing certificate (issuer `https://github.com/MrChengLen/FileMorph/.github/workflows/docker.yml@refs/tags/...`), plus a Rekor transparency-log inclusion proof. +## Verifying the SBOM attestation + +[`docker.yml`](../.github/workflows/docker.yml) attests the CycloneDX +SBOM to every image it pushes: the slim and the office image, for each +release tag and each build of `main`. The attestation binds the SBOM to +the image digest and is signed through Sigstore keyless OIDC, like the +image. It is stored with the repository's attestations and pushed to +GHCR next to the image. Images built before this was introduced, v1.1.0 +among them, have none: `gh` then finds no attestation to verify. + +**What the SBOM covers:** the Python packages the image installs from +`requirements.lock`, at the locked versions, plus the `pip` of the +virtualenv it is generated from, whose version can differ from the +image's. It is generated from a clean install of the lockfile, not by +scanning the image, so it does not list the Python interpreter, the +Debian packages of the `python:3.14-slim` base image, or those the +Dockerfile adds with `apt` (FFmpeg, Ghostscript, the Cairo/Pango stack, +LibreOffice in the office image). Scan the image itself for those. For a +release, the attested SBOM comes from the same lockfile, by the same +steps, as the `filemorph-vX.Y.Z.cdx.json` attached to the release. + +Verify with the [GitHub CLI](https://cli.github.com/), logged in to any +GitHub account (`gh auth login`): + +```bash +gh attestation verify oci://ghcr.io/mrchenglen/filemorph:1.2.3 \ + --owner MrChengLen \ + --signer-workflow MrChengLen/FileMorph/.github/workflows/docker.yml \ + --source-ref refs/tags/v1.2.3 \ + --predicate-type https://cyclonedx.org/bom +``` + +- `--predicate-type` is required: by default `gh` accepts only SLSA + build-provenance attestations, and the check fails. +- `--signer-workflow` and `--source-ref` accept only an attestation that + `docker.yml` made for a push of that tag. After [verifying the + tag](#verifying-a-release-tag), also add + `--source-digest "$(git rev-parse 'v1.2.3^{commit}')"` in that clone: + it ties the attestation to the commit the tag's signature covers, so an + image built after the tag was moved to another commit fails. +- For the office image, verify `filemorph:1.2.3-office`. `:latest` and + `:office` move with every build of `main` and every release; verify + them with the ref of the build that pushed them: `--source-ref + refs/heads/main`, or `refs/tags/vX.Y.Z` right after a release. +- `--bundle-from-oci` reads the attestation from GHCR instead of the + GitHub API. +- To print the attested SBOM, add + `--format json --jq '.[0].verificationResult.statement.predicate'`. + ## First-time setup — generating the maintainer signing key The recurring flow in the next section assumes you already have a GPG @@ -129,7 +183,7 @@ git push origin vX.Y.Z The push triggers two parallel workflows: - [`docker.yml`](../.github/workflows/docker.yml) builds and - cosign-signs the container image. + cosign-signs the container image, and attests its SBOM to it. - [`release.yml`](../.github/workflows/release.yml) verifies the tag against the keys below, builds the source tarball, and publishes the GitHub release with an `IMAGE_DIGEST.txt` diff --git a/requirements-sbom.txt b/requirements-sbom.txt index e8c4a4b..46c3a15 100644 --- a/requirements-sbom.txt +++ b/requirements-sbom.txt @@ -1,5 +1,6 @@ -# The CycloneDX SBOM generator that sbom.yml and release.yml run. CI tooling -# only: it is not part of the image, and the SBOM it writes does not list it. +# The CycloneDX SBOM generator that sbom.yml, release.yml and docker.yml run. +# CI tooling only: it is not part of the image, and the SBOM it writes does +# not list it. # # The workflows install requirements-sbom.lock, compiled from this file with # hashes, and never this file: with --require-hashes pip refuses anything @@ -13,7 +14,7 @@ # leave the major version alone (.github/dependabot.yml): a major bump can # change the SBOM's CycloneDX spec version and its licence data, so take one # deliberately. 7.x also has no --PEP-639 flag: the bump has to drop it from -# sbom.yml and release.yml (tests/test_supply_chain_hygiene.py checks), and -# should dispatch sbom.yml on its branch before merging, since release.yml -# cannot be tried on a PR. +# sbom.yml, release.yml and docker.yml (tests/test_supply_chain_hygiene.py +# checks), and should dispatch sbom.yml on its branch before merging, since +# neither release.yml nor docker.yml can be tried on a PR. cyclonedx-bom>=5,<6 diff --git a/tests/test_supply_chain_hygiene.py b/tests/test_supply_chain_hygiene.py index ddcf0d2..517377b 100644 --- a/tests/test_supply_chain_hygiene.py +++ b/tests/test_supply_chain_hygiene.py @@ -30,7 +30,12 @@ * no job holding a write token or a secret restores or saves an Actions cache, docker.yml builds the image with ``no-cache: true``, and no buildx step anywhere uses a cache (no ``cache-from``/``cache-to``, - ``cache-binary: false``). + ``cache-binary: false``); + * docker.yml attests the SBOM to every image variant it pushes, by the + digest its build step reports: the SBOM comes from sbom.yml's steps in a + job that can only read, and the job that signs the attestation checks + nothing out, installs nothing and runs only GitHub's own actions; + docs/release-signing.md verifies it the way it is made. This is a tripwire, not a substitute for the server-side Scorecard run / review: the per-job permissions check here is a heuristic (it asserts a @@ -68,7 +73,7 @@ "uv_pin=$(grep -oE '^uv==[0-9][0-9A-Za-z.!+-]*' requirements-dev.txt)", 'pip install --only-binary :all: "$uv_pin"', ) -# The steps release.yml copies from sbom.yml, which is the one that can be run. +# The steps release.yml and docker.yml copy from sbom.yml, the one that can be run. _SBOM_STEPS = ( "Install the image's dependency set", "Install CycloneDX generator", @@ -92,6 +97,11 @@ "pull_request_review_comment", "pull_request_target", } +# The only actions that run in docker.yml's job holding `attestations: write`, +# and the one command it runs: the registry login the attest action reads. +_ATTEST_ACTIONS = ("actions/download-artifact@", "actions/attest@") +_ATTEST_LOGIN = 'echo "$TOKEN" | docker login "$REGISTRY" --username "$ACTOR" --password-stdin' +_RELEASE_SIGNING_DOC = _REPO_ROOT / "docs" / "release-signing.md" _SHA40_RE = re.compile(r"^[0-9a-f]{40}$") # `uses: owner/repo@ref` or `uses: owner/repo/path@ref`, tolerating a @@ -412,7 +422,7 @@ def test_deps_latest_mirrors_lint_and_test() -> None: ) -@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml", "verapdf.yml"]) +@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml", "docker.yml", "verapdf.yml"]) def test_workflow_installs_what_the_image_ships(workflow: str) -> None: """The SBOM lists, and veraPDF validates, the image's dependency set. @@ -430,7 +440,7 @@ def test_workflow_installs_what_the_image_ships(workflow: str) -> None: ) -@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml"]) +@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml", "docker.yml"]) def test_sbom_describes_the_lockfile_venv_only(workflow: str) -> None: """``cyclonedx-py environment`` reads the lockfile venv, not its own. @@ -450,7 +460,7 @@ def test_sbom_describes_the_lockfile_venv_only(workflow: str) -> None: ) -@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml"]) +@pytest.mark.parametrize("workflow", ["sbom.yml", "release.yml", "docker.yml"]) def test_sbom_generator_installs_from_its_hashed_lockfile(workflow: str) -> None: """Every pip install in the SBOM workflows is hash-checked, and the generator comes from requirements-sbom.lock, as wheels only. @@ -487,8 +497,9 @@ def test_sbom_generator_lockfile_is_hash_pinned() -> None: the lockfile test above), because the SBOM jobs run the generator on it. """ assert _SBOM_LOCKFILE.is_file(), ( - "requirements-sbom.lock missing, but sbom.yml and release.yml install from it — " - "recompile it with the command in requirements-sbom.txt, or run the deps-lock workflow" + "requirements-sbom.lock missing, but sbom.yml, release.yml and docker.yml install from " + "it — recompile it with the command in requirements-sbom.txt, or run the deps-lock " + "workflow" ) for entry in _lock_entries(_SBOM_LOCKFILE): assert re.fullmatch(r"[A-Za-z0-9._-]+==\S+( --hash=sha256:[0-9a-f]{64})+", entry), ( @@ -798,26 +809,28 @@ def test_buildx_actions_restore_no_cache(workflow: Path) -> None: assert inputs.get("cache-binary") is False, f"{label}: set `cache-binary: false`" -def test_release_sbom_steps_match_sbom_workflow() -> None: - """release.yml runs sbom.yml's SBOM steps verbatim, with the flags the - locked generator accepts. +@pytest.mark.parametrize("workflow", ["release.yml", "docker.yml"]) +def test_sbom_steps_match_sbom_workflow(workflow: str) -> None: + """release.yml and docker.yml run sbom.yml's SBOM steps verbatim, with the + flags the locked generator accepts. - release.yml only runs on a signed tag, so a mistake there would first show - in a release. sbom.yml runs the same steps on every push to main and can - be dispatched on a branch; keeping the two identical makes that run the - test of the release path. + Neither can be tried before it counts: release.yml runs on a signed tag, + and docker.yml pushes the images it builds, so a mistake would first show + in a release or on main. sbom.yml runs the same steps on every push to + main and can be dispatched on a branch; keeping them identical makes that + run the test of both paths. """ def named_steps(name: str) -> dict[str, dict]: jobs = _workflow(_WORKFLOW_DIR / name)["jobs"].values() return {step["name"]: step for job in jobs for step in _steps(job) if "name" in step} - sbom, release = named_steps("sbom.yml"), named_steps("release.yml") + sbom, copy = named_steps("sbom.yml"), named_steps(workflow) for name in _SBOM_STEPS: assert name in sbom, f"sbom.yml has no step {name!r} — update this guard" - assert name in release, f"release.yml has no step {name!r}" - assert release[name].get("run") == sbom[name].get("run"), ( - f"release.yml and sbom.yml differ in step {name!r} — keep them identical" + assert name in copy, f"{workflow} has no step {name!r}" + assert copy[name].get("run") == sbom[name].get("run"), ( + f"{workflow} and sbom.yml differ in step {name!r} — keep them identical" ) generate = sbom["Generate CycloneDX SBOM (JSON)"]["run"] generator = Version(_lock_pins(_SBOM_LOCKFILE)[canonicalize_name("cyclonedx-bom")]) @@ -829,11 +842,209 @@ def named_steps(name: str) -> dict[str, dict]: ) else: assert "--PEP-639" not in generate, ( - f"cyclonedx-bom {generator} no longer accepts --PEP-639 — drop it from sbom.yml " - f"and release.yml, or both SBOM jobs fail" + f"cyclonedx-bom {generator} no longer accepts --PEP-639 — drop it from sbom.yml, " + f"release.yml and docker.yml, or every SBOM job fails" ) +def test_docker_attests_the_sbom_to_every_image_it_pushes() -> None: + """docker.yml binds the SBOM to each image variant by the digest it pushed. + + release.yml can only look an image up by tag, best effort, a minute after + the tag push; an attestation has to name exactly what was built, pushed + and signed, which is the digest the build step reports. The legs of a + matrix share one set of job outputs, so each variant needs an output of + its own: one name for both would hand both attestations whichever digest + was written last. The attestation also goes to GHCR, and it runs on every + build, main as well as tags (a decision of 2026-09-28), so ``:latest`` + carries one too: no ``if:`` may skip either job or a step of it, and no + ``continue-on-error`` may let the run, and with it the deploy, succeed + without an attestation. The SBOM crosses over by file name, and none of + this can be tried before it runs on main, so the names have to line up + here. + """ + jobs = _workflow(_WORKFLOW_DIR / "docker.yml")["jobs"] + build = jobs["build-and-push"] + targets = [leg["target"] for leg in build["strategy"]["matrix"]["include"]] + outputs = build.get("outputs") or {} + for target in targets: + value = str(outputs.get(f"digest-{target}", "")) + assert "steps.build.outputs.digest" in value and f"'{target}'" in value, ( + f"docker.yml: build-and-push must output `digest-{target}`, the build step's " + f"digest set by the {target} leg only" + ) + + attesting = [ + name + for name, job in jobs.items() + if any(str(step.get("uses", "")).startswith("actions/attest@") for step in _steps(job)) + ] + assert attesting == ["attest-sbom"], ( + f"docker.yml: expected one job running actions/attest, `attest-sbom`; found {attesting}" + ) + sbom_jobs = [ + name + for name, job in jobs.items() + if any("cyclonedx-py" in (step.get("run") or "") for step in _steps(job)) + ] + assert len(sbom_jobs) == 1, ( + f"docker.yml: expected one job generating the SBOM, found {sbom_jobs}" + ) + job, sbom = jobs["attest-sbom"], jobs[sbom_jobs[0]] + needs = job.get("needs") or [] + needs = [needs] if isinstance(needs, str) else needs + assert {"build-and-push", sbom_jobs[0]} <= set(needs), ( + f"docker.yml: `attest-sbom` must need build-and-push and {sbom_jobs[0]}, it needs {needs}" + ) + for name in ("attest-sbom", sbom_jobs[0]): + for part in (jobs[name], *_steps(jobs[name])): + assert "if" not in part and not part.get("continue-on-error"), ( + f"docker.yml job `{name}`: an `if:` or `continue-on-error` lets a build push " + f"images without an attestation — it runs on every build, main as well as tags" + ) + attested = (job.get("strategy") or {}).get("matrix", {}).get("target") or [] + assert sorted(attested) == sorted(targets), ( + f"docker.yml: `attest-sbom` must cover every image variant built, {targets}" + ) + + attest = next(s for s in _steps(job) if str(s.get("uses", "")).startswith("actions/attest@")) + inputs = attest.get("with") or {} + assert inputs.get("subject-digest") == ( + "${{ needs.build-and-push.outputs[format('digest-{0}', matrix.target)] }}" + ), "docker.yml: attest each variant's digest as build-and-push reported it" + assert inputs.get("subject-name") == "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}", ( + "docker.yml: the attestation's subject is the image name the build pushed, without a tag" + ) + assert inputs.get("push-to-registry") is True, ( + "docker.yml: push the attestation to GHCR (`push-to-registry: true`)" + ) + + generate = next(s for s in _steps(sbom) if s.get("name") == "Generate CycloneDX SBOM (JSON)") + output = re.search(r'--output-file "([^"]+)"', generate.get("run") or "") + assert output, 'docker.yml: the SBOM step no longer writes `--output-file "…"`' + version = str((generate.get("env") or {}).get("VERSION")) + file_name = output.group(1).replace("${VERSION}", version) + upload = next( + s for s in _steps(sbom) if str(s.get("uses", "")).startswith("actions/upload-artifact@") + ) + download = next( + s for s in _steps(job) if str(s.get("uses", "")).startswith("actions/download-artifact@") + ) + handed = upload.get("with") or {} + received = download.get("with") or {} + assert handed.get("path") == file_name and handed.get("if-no-files-found") == "error", ( + f"docker.yml: upload exactly `{file_name}`, and fail if it is missing" + ) + assert received.get("name") == handed.get("name") and received.get("path") == "sbom", ( + "docker.yml: download the SBOM artifact by its name into `sbom/`, a directory of its own" + ) + assert inputs.get("sbom-path") == f"sbom/{file_name}", ( + f"docker.yml: attest `sbom/{file_name}`, the file the SBOM job generated" + ) + + +def test_docker_attestation_runs_apart_from_third_party_code() -> None: + """docker.yml's attesting job installs nothing; the SBOM job can only read. + + A signed attestation vouches for the SBOM inside it. Generating that SBOM + installs the image's dependency set and the generator — over a hundred + packages — so that job holds read access only and no secret, restores no + cache and keeps no credentials. The job that can sign and push checks + nothing out, uses only ``_ATTEST_ACTIONS``, like release.yml's publish + job, and runs one command, the registry login: a pattern of forbidden + commands would miss ``apt-get`` or ``docker run``. Its permissions are + exactly what attesting to GHCR needs, and no other job may attest. + """ + workflow = _workflow(_WORKFLOW_DIR / "docker.yml") + jobs = workflow["jobs"] + can_attest = [ + name + for name, job in jobs.items() + if (job.get("permissions") or {}).get("attestations") == "write" + ] + assert can_attest == ["attest-sbom"], ( + f"docker.yml: only `attest-sbom` may hold `attestations: write`, found {can_attest}" + ) + job = jobs["attest-sbom"] + assert job["permissions"] == { + "attestations": "write", + "id-token": "write", + "packages": "write", + }, f"docker.yml: `attest-sbom` holds {job['permissions']}" + runs = [step["run"].strip() for step in _steps(job) if "run" in step] + assert runs == [_ATTEST_LOGIN], ( + f"docker.yml job `attest-sbom` can sign and push, so it runs only `{_ATTEST_LOGIN}`; " + f"found {runs}" + ) + for step in _steps(job): + uses = step.get("uses") + assert not uses or str(uses).startswith(_ATTEST_ACTIONS), ( + f"docker.yml job `attest-sbom` runs `{uses}` — only " + f"{', '.join(_ATTEST_ACTIONS)} run next to its tokens" + ) + + for name, sbom in jobs.items(): + if not any("cyclonedx-py" in (step.get("run") or "") for step in _steps(sbom)): + continue + assert sbom.get("permissions") == {"contents": "read"}, ( + f"docker.yml job `{name}` runs third-party code, so it may only read" + ) + assert not _privileged(sbom, workflow), ( + f"docker.yml job `{name}` runs third-party code, so it may hold no secret" + ) + for step in _steps(sbom): + uses, inputs = str(step.get("uses", "")), step.get("with") or {} + assert not uses.startswith("actions/cache") and not any("cache" in k for k in inputs), ( + f"docker.yml job `{name}`, step {step.get('name') or uses!r}: restores a cache, " + f"and the SBOM it generates gets attested" + ) + if uses.startswith("actions/checkout@"): + assert inputs.get("persist-credentials") is False, ( + f"docker.yml job `{name}`: checkout without `persist-credentials: false`" + ) + + +def test_docs_verify_the_sbom_attestation_as_docker_yml_makes_it() -> None: + """docs/release-signing.md verifies the attestation the way it is made, and + says what the SBOM leaves out. + + ``gh attestation verify`` looks for SLSA provenance unless told otherwise, + so without ``--predicate-type https://cyclonedx.org/bom`` the documented + command fails on every image. The workflow it names as signer has to be + the one that attests. And the SBOM lists the Python packages from + requirements.lock only: without saying that the image's Debian packages + are not in it, the attestation reads as a complete inventory. + """ + text = _RELEASE_SIGNING_DOC.read_text(encoding="utf-8") + section = re.search(r"^## Verifying the SBOM attestation\n(.*?)(?=^## |\Z)", text, re.M | re.S) + assert section, "docs/release-signing.md has no `## Verifying the SBOM attestation` section" + body = section.group(1) + blocks = "\n".join(re.findall(r"```bash\n(.*?)```", body, re.S)) + commands = [ + " ".join(match.replace("\\\n", " ").split()) + for match in re.findall(r"gh attestation verify(?:[^\n]*\\\n)*[^\n]*", blocks) + ] + assert commands, "docs/release-signing.md shows no `gh attestation verify` command" + for command in commands: + assert "--predicate-type https://cyclonedx.org/bom" in command, ( + f"`{command}` fails: without `--predicate-type https://cyclonedx.org/bom`, gh looks " + f"for SLSA provenance, which docker.yml does not attest" + ) + signer = re.search( + r"--signer-workflow MrChengLen/FileMorph/\.github/workflows/(\S+)", command + ) + assert signer and (_WORKFLOW_DIR / signer.group(1)).is_file(), ( + f"`{command}` names no workflow of this repository as `--signer-workflow`" + ) + assert "actions/attest@" in _workflow_code(signer.group(1)), ( + f"`{command}`: {signer.group(1)} does not attest — name the workflow that does" + ) + assert "requirements.lock" in body and "Debian" in body, ( + "docs/release-signing.md: say that the SBOM covers requirements.lock's Python packages " + "only, not the image's Debian packages" + ) + + def test_verapdf_image_is_digest_pinned() -> None: """The veraPDF gate runs a validator image pinned by digest. From 955b16eb636c4dcc761ddfe3a00a5c26823411be Mon Sep 17 00:00:00 2001 From: MrChengLen Date: Tue, 29 Sep 2026 09:48:32 +0200 Subject: [PATCH 2/5] =?UTF-8?q?chore(changelog):=20take=20this=20PR's=20en?= =?UTF-8?q?try=20out=20while=20main=20is=20merged=20in=20=E2=80=94=20back?= =?UTF-8?q?=20in=20two=20commits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the CHANGELOG conflict without a force-push: with the entry out, GitHub can merge main into the branch; the next commit puts it back on top. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 49 ------------------------------------------------- 1 file changed, 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0939602..a510c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,55 +9,6 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] -### Added — the Docker images carry a signed SBOM attestation - -`docker.yml` now attests the CycloneDX SBOM to each image it pushes — slim and -office, on release tags, on every build of `main` and on manual rebuilds — so -`:latest` and `:office` carry one too. Until now the SBOM was a file attached -to the release, tied to the image by nothing but its name: `release.yml` finds -the image digest only by tag, best effort, a minute after the tag push. - -- **What it covers.** The Python packages the image installs from - `requirements.lock`, at the locked versions, plus the `pip` of the virtualenv - the SBOM is generated from. It comes from a clean install of the lockfile, - not from scanning the image, so the Python interpreter and the Debian - packages of the base image and of the `apt` layers (FFmpeg, Ghostscript, the - Cairo/Pango stack, LibreOffice) are not in it. `docs/release-signing.md` says - so next to the verification command. Images built before this change, - v1.1.0 among them, have no attestation. -- **How.** A new `sbom` job runs `sbom.yml`'s three SBOM steps, verbatim, with - read access only. `attest-sbom` then signs the SBOM for each image variant - with `actions/attest` v4.2.2 — Sigstore keyless, like the cosign signature — - bound to the digest the build step reported, stores it with the repository's - attestations and pushes it to GHCR. That job holds `attestations: write`, - `id-token: write` and `packages: write`, checks nothing out, installs - nothing, logs in with the docker CLI and runs only GitHub's own actions. - `actions/attest-sbom` has been deprecated since v4.0.0 and only wraps - `actions/attest`, so the latter is pinned directly. -- **Verify** with `gh attestation verify` as `docs/release-signing.md` shows - it. `--predicate-type https://cyclonedx.org/bom` is required: without it, - `gh` accepts only SLSA provenance and the check fails. `--signer-workflow`, - `--source-ref` and `--source-digest` pin the workflow, the tag and the - commit whose signed tag was verified; `--owner` alone would accept an - attestation from any repository of the account. -- **What it costs.** The images are attested once both are built, so a deploy - from `main` starts a little later. `notify-ops` deploys only after the whole - Docker run succeeds, so a failure in the SBOM job (PyPI) or the attestation - (Sigstore, the attestations API, GHCR) now holds back the deploy, a manual - redeploy included, until "Re-run failed jobs" gets through; the SBOM - artifact is kept for a week so that the re-run needs no new build. If one - image fails to build, neither is attested; the other stays pushed and signed. -- **Guards.** `tests/test_supply_chain_hygiene.py` keeps `docker.yml`'s SBOM - steps identical to `sbom.yml`'s, and fails if an image variant loses its - attestation or its own digest output, if the attestation stops naming the - build's digest or stops going to GHCR, if either job gets an `if:` or - `continue-on-error` (on the job or a step), if the attesting job gains an - action, a permission or any command besides the registry login, if the SBOM - job gains write access, a secret, a cache or kept credentials, if the two - jobs stop agreeing on the SBOM's file name, or if the documented command - drops `--predicate-type` or names a workflow that does not attest. Each of - 42 simulated regressions fails at least one guard. - ### Security — the Docker image is built without a restored build cache `docker.yml` restored BuildKit's GitHub Actions cache (`cache-from: type=gha`) From f0e46312561b725f321b42a1ae06a09173851315 Mon Sep 17 00:00:00 2001 From: MrChengLen Date: Tue, 29 Sep 2026 09:48:45 +0200 Subject: [PATCH 3/5] docs(changelog): SBOM attestation entry back on top of the merged changelog Second half of the conflict resolution: the entry taken out two commits ago goes back on top of [Unreleased], above the entries main brought in. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7998f19..bb2cf02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,55 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added — the Docker images carry a signed SBOM attestation + +`docker.yml` now attests the CycloneDX SBOM to each image it pushes — slim and +office, on release tags, on every build of `main` and on manual rebuilds — so +`:latest` and `:office` carry one too. Until now the SBOM was a file attached +to the release, tied to the image by nothing but its name: `release.yml` finds +the image digest only by tag, best effort, a minute after the tag push. + +- **What it covers.** The Python packages the image installs from + `requirements.lock`, at the locked versions, plus the `pip` of the virtualenv + the SBOM is generated from. It comes from a clean install of the lockfile, + not from scanning the image, so the Python interpreter and the Debian + packages of the base image and of the `apt` layers (FFmpeg, Ghostscript, the + Cairo/Pango stack, LibreOffice) are not in it. `docs/release-signing.md` says + so next to the verification command. Images built before this change, + v1.1.0 among them, have no attestation. +- **How.** A new `sbom` job runs `sbom.yml`'s three SBOM steps, verbatim, with + read access only. `attest-sbom` then signs the SBOM for each image variant + with `actions/attest` v4.2.2 — Sigstore keyless, like the cosign signature — + bound to the digest the build step reported, stores it with the repository's + attestations and pushes it to GHCR. That job holds `attestations: write`, + `id-token: write` and `packages: write`, checks nothing out, installs + nothing, logs in with the docker CLI and runs only GitHub's own actions. + `actions/attest-sbom` has been deprecated since v4.0.0 and only wraps + `actions/attest`, so the latter is pinned directly. +- **Verify** with `gh attestation verify` as `docs/release-signing.md` shows + it. `--predicate-type https://cyclonedx.org/bom` is required: without it, + `gh` accepts only SLSA provenance and the check fails. `--signer-workflow`, + `--source-ref` and `--source-digest` pin the workflow, the tag and the + commit whose signed tag was verified; `--owner` alone would accept an + attestation from any repository of the account. +- **What it costs.** The images are attested once both are built, so a deploy + from `main` starts a little later. `notify-ops` deploys only after the whole + Docker run succeeds, so a failure in the SBOM job (PyPI) or the attestation + (Sigstore, the attestations API, GHCR) now holds back the deploy, a manual + redeploy included, until "Re-run failed jobs" gets through; the SBOM + artifact is kept for a week so that the re-run needs no new build. If one + image fails to build, neither is attested; the other stays pushed and signed. +- **Guards.** `tests/test_supply_chain_hygiene.py` keeps `docker.yml`'s SBOM + steps identical to `sbom.yml`'s, and fails if an image variant loses its + attestation or its own digest output, if the attestation stops naming the + build's digest or stops going to GHCR, if either job gets an `if:` or + `continue-on-error` (on the job or a step), if the attesting job gains an + action, a permission or any command besides the registry login, if the SBOM + job gains write access, a secret, a cache or kept credentials, if the two + jobs stop agreeing on the SBOM's file name, or if the documented command + drops `--predicate-type` or names a workflow that does not attest. Each of + 42 simulated regressions fails at least one guard. + ### Fixed — website texts match what the service does The public pages were checked claim by claim against the code: From 9e3d13f2f17382a2718b0a5b2f2ab25522385a64 Mon Sep 17 00:00:00 2001 From: MrChengLen Date: Tue, 29 Sep 2026 09:53:08 +0200 Subject: [PATCH 4/5] =?UTF-8?q?chore(changelog):=20take=20the=20entry=20ou?= =?UTF-8?q?t=20to=20merge=20main=20in=20=E2=80=94=20back=20in=20two=20comm?= =?UTF-8?q?its?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PR adds its CHANGELOG entry at the same place, so each merge to main conflicts with every open PR. Taking this PR's entry out lets GitHub merge main in cleanly; the next commit puts it back on top. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 49 ------------------------------------------------- 1 file changed, 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2cf02..7998f19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,55 +9,6 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] -### Added — the Docker images carry a signed SBOM attestation - -`docker.yml` now attests the CycloneDX SBOM to each image it pushes — slim and -office, on release tags, on every build of `main` and on manual rebuilds — so -`:latest` and `:office` carry one too. Until now the SBOM was a file attached -to the release, tied to the image by nothing but its name: `release.yml` finds -the image digest only by tag, best effort, a minute after the tag push. - -- **What it covers.** The Python packages the image installs from - `requirements.lock`, at the locked versions, plus the `pip` of the virtualenv - the SBOM is generated from. It comes from a clean install of the lockfile, - not from scanning the image, so the Python interpreter and the Debian - packages of the base image and of the `apt` layers (FFmpeg, Ghostscript, the - Cairo/Pango stack, LibreOffice) are not in it. `docs/release-signing.md` says - so next to the verification command. Images built before this change, - v1.1.0 among them, have no attestation. -- **How.** A new `sbom` job runs `sbom.yml`'s three SBOM steps, verbatim, with - read access only. `attest-sbom` then signs the SBOM for each image variant - with `actions/attest` v4.2.2 — Sigstore keyless, like the cosign signature — - bound to the digest the build step reported, stores it with the repository's - attestations and pushes it to GHCR. That job holds `attestations: write`, - `id-token: write` and `packages: write`, checks nothing out, installs - nothing, logs in with the docker CLI and runs only GitHub's own actions. - `actions/attest-sbom` has been deprecated since v4.0.0 and only wraps - `actions/attest`, so the latter is pinned directly. -- **Verify** with `gh attestation verify` as `docs/release-signing.md` shows - it. `--predicate-type https://cyclonedx.org/bom` is required: without it, - `gh` accepts only SLSA provenance and the check fails. `--signer-workflow`, - `--source-ref` and `--source-digest` pin the workflow, the tag and the - commit whose signed tag was verified; `--owner` alone would accept an - attestation from any repository of the account. -- **What it costs.** The images are attested once both are built, so a deploy - from `main` starts a little later. `notify-ops` deploys only after the whole - Docker run succeeds, so a failure in the SBOM job (PyPI) or the attestation - (Sigstore, the attestations API, GHCR) now holds back the deploy, a manual - redeploy included, until "Re-run failed jobs" gets through; the SBOM - artifact is kept for a week so that the re-run needs no new build. If one - image fails to build, neither is attested; the other stays pushed and signed. -- **Guards.** `tests/test_supply_chain_hygiene.py` keeps `docker.yml`'s SBOM - steps identical to `sbom.yml`'s, and fails if an image variant loses its - attestation or its own digest output, if the attestation stops naming the - build's digest or stops going to GHCR, if either job gets an `if:` or - `continue-on-error` (on the job or a step), if the attesting job gains an - action, a permission or any command besides the registry login, if the SBOM - job gains write access, a secret, a cache or kept credentials, if the two - jobs stop agreeing on the SBOM's file name, or if the documented command - drops `--predicate-type` or names a workflow that does not attest. Each of - 42 simulated regressions fails at least one guard. - ### Fixed — website texts match what the service does The public pages were checked claim by claim against the code: From 726d60088630fa0e2ba4e3d0776b07a7991b9634 Mon Sep 17 00:00:00 2001 From: MrChengLen Date: Tue, 29 Sep 2026 09:54:21 +0200 Subject: [PATCH 5/5] docs(changelog): entry back on top of the merged changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32dd320..d8a53b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,55 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added — the Docker images carry a signed SBOM attestation + +`docker.yml` now attests the CycloneDX SBOM to each image it pushes — slim and +office, on release tags, on every build of `main` and on manual rebuilds — so +`:latest` and `:office` carry one too. Until now the SBOM was a file attached +to the release, tied to the image by nothing but its name: `release.yml` finds +the image digest only by tag, best effort, a minute after the tag push. + +- **What it covers.** The Python packages the image installs from + `requirements.lock`, at the locked versions, plus the `pip` of the virtualenv + the SBOM is generated from. It comes from a clean install of the lockfile, + not from scanning the image, so the Python interpreter and the Debian + packages of the base image and of the `apt` layers (FFmpeg, Ghostscript, the + Cairo/Pango stack, LibreOffice) are not in it. `docs/release-signing.md` says + so next to the verification command. Images built before this change, + v1.1.0 among them, have no attestation. +- **How.** A new `sbom` job runs `sbom.yml`'s three SBOM steps, verbatim, with + read access only. `attest-sbom` then signs the SBOM for each image variant + with `actions/attest` v4.2.2 — Sigstore keyless, like the cosign signature — + bound to the digest the build step reported, stores it with the repository's + attestations and pushes it to GHCR. That job holds `attestations: write`, + `id-token: write` and `packages: write`, checks nothing out, installs + nothing, logs in with the docker CLI and runs only GitHub's own actions. + `actions/attest-sbom` has been deprecated since v4.0.0 and only wraps + `actions/attest`, so the latter is pinned directly. +- **Verify** with `gh attestation verify` as `docs/release-signing.md` shows + it. `--predicate-type https://cyclonedx.org/bom` is required: without it, + `gh` accepts only SLSA provenance and the check fails. `--signer-workflow`, + `--source-ref` and `--source-digest` pin the workflow, the tag and the + commit whose signed tag was verified; `--owner` alone would accept an + attestation from any repository of the account. +- **What it costs.** The images are attested once both are built, so a deploy + from `main` starts a little later. `notify-ops` deploys only after the whole + Docker run succeeds, so a failure in the SBOM job (PyPI) or the attestation + (Sigstore, the attestations API, GHCR) now holds back the deploy, a manual + redeploy included, until "Re-run failed jobs" gets through; the SBOM + artifact is kept for a week so that the re-run needs no new build. If one + image fails to build, neither is attested; the other stays pushed and signed. +- **Guards.** `tests/test_supply_chain_hygiene.py` keeps `docker.yml`'s SBOM + steps identical to `sbom.yml`'s, and fails if an image variant loses its + attestation or its own digest output, if the attestation stops naming the + build's digest or stops going to GHCR, if either job gets an `if:` or + `continue-on-error` (on the job or a step), if the attesting job gains an + action, a permission or any command besides the registry login, if the SBOM + job gains write access, a secret, a cache or kept credentials, if the two + jobs stop agreeing on the SBOM's file name, or if the documented command + drops `--predicate-type` or names a workflow that does not attest. Each of + 42 simulated regressions fails at least one guard. + ### Fixed — compliance templates describe what the code does The DPA template and its TOM annex, the records-of-processing template, the