Skip to content

ci(docker): attest the SBOM to each image by its pushed digest — main and tags - #180

Merged
MrChengLen merged 7 commits into
mainfrom
pr-sbom-attestation
Sep 29, 2026
Merged

MrChengLen merged 7 commits into
mainfrom
pr-sbom-attestation

Conversation

@MrChengLen

Copy link
Copy Markdown
Owner

What

Every image docker.yml pushes — slim and office, on release tags, on every build of main and on manual rebuilds — now carries a signed SBOM attestation:

  • sbom job (contents: read): sbom.yml's three SBOM steps, verbatim, then hands the SBOM over as an artifact.
  • attest-sbom job (matrix base/office; attestations, id-token, packages: write): downloads the artifact into sbom/, logs in to GHCR with the docker CLI, and runs actions/attest@1e69f48… # v4.2.2 with subject-name + the build's exact digest, sbom-path and push-to-registry: true. No checkout, no install, no third-party action.
  • build-and-push exposes digest-base / digest-office: one output name per matrix leg. The runner does not write empty outputs, so neither leg overwrites the other's digest.

Why

release.yml resolves the image digest only by tag, best effort, after sleep 60 — the wrong place to attest. docker.yml knows each variant's digest exactly (the index digest, the same one cosign signs and a tag resolves to).

Scope label: the SBOM covers the Python packages from requirements.lock (plus the pip of the venv it is generated from) — not the interpreter, the Debian packages, FFmpeg, Ghostscript or LibreOffice. The docs, the workflow comment and the CHANGELOG say so.

Decided before implementation

  • Attest on main and tags, so :latest/:office are covered and the path runs on every merge rather than first at a release.
  • actions/attest v4.2.2 instead of actions/attest-sbom, which has been deprecated since v4.0.0 (a wrapper around actions/attest v4.1.0 that prints a warning). Same inputs.
  • Label in docs/workflow/CHANGELOG; the SBOM file is generated exactly as for the release.

Builds on #170 (merged): no image this attests comes from a restored build cache.

Verification

  • docs/release-signing.md documents gh attestation verify oci://… --owner MrChengLen --signer-workflow MrChengLen/FileMorph/.github/workflows/docker.yml --source-ref refs/tags/vX.Y.Z --predicate-type https://cyclonedx.org/bom (--predicate-type is required, gh defaults to SLSA provenance), plus --source-digest to tie it to the commit a verified tag signature covers.
  • Full suite 1478 green (72 skipped, Linux-only); ruff clean; gitleaks and hook patterns clean.
  • docker.yml cannot be tried on a branch (a dispatch pushes :latest/:office), so tests/test_supply_chain_hygiene.py pins what the first run on main needs. Each of 42 simulated regressions fails at least one guard; two harmless edits pass.
  • Security and code review: no blockers; findings on docs, CHANGELOG and guards addressed. A pre-existing tag bug they found (on a release the office build also pushes :latest, via metadata-action's latest=auto) is left for its own PR.

After merge: the first Docker run on main must show both attest-sbom legs green, and
gh attestation verify oci://ghcr.io/mrchenglen/filemorph:latest --owner MrChengLen --signer-workflow MrChengLen/FileMorph/.github/workflows/docker.yml --source-ref refs/heads/main --predicate-type https://cyclonedx.org/bom must succeed.

🤖 Generated with Claude Code

… and tags

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 <noreply@anthropic.com>
MrChengLen and others added 6 commits September 29, 2026 09:48
…back in two commits

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 <noreply@anthropic.com>
…ngelog

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 <noreply@anthropic.com>
…ommits

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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MrChengLen
MrChengLen merged commit 0c4250c into main Sep 29, 2026
7 checks passed
@MrChengLen
MrChengLen deleted the pr-sbom-attestation branch September 29, 2026 07:58
MrChengLen added a commit that referenced this pull request Sep 29, 2026
…ommits

main moved on (#178, #179, #180 and #170) and adds entries at the top of
CHANGELOG.md, so GitHub's update-branch refuses. A merge commit cannot be
signed from this machine, so the entry leaves for the merge of main and comes
back in the commit after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant