Repository navigation
ci: add a least-privilege release workflow and tag validation - #32
Merged
Merged
Conversation
The workflow runs only for a pushed tag. There is no branch trigger, no schedule, and no manual dispatch, so a release is caused by creating an immutable tag and by nothing else. Permissions are read-only at the workflow level and widened per job: validation stays read-only, the build jobs add package write and an OIDC token, and only the final job holds contents and attestation write. Before anything is built the workflow requires the tagged commit to be an ancestor of main, so a tag pointing at an unreviewed commit cannot publish it. Tag validation is a script with unit tests rather than a regex in a workflow, because a release tag is immutable: a tag discovered to be wrong afterwards cannot be corrected, only superseded. It checks the pattern, that the date is a real calendar date and not in the future, that the NGINX version and UBI major agree with the artifact lock, that the tag is not already published, and that the daily sequence continues from the highest used rather than back-filling a gap left by a withdrawn release. Fifteen tests cover those rules and run in the lint job, so the contract is exercised on every change rather than once during a release. The signature and both attestations bind to the manifest digest, never to a tag, and the workflow verifies its own signature before publishing evidence. Two high-severity template-injection findings were found and fixed before commit: `github.actor` expanded directly into a shell command in both registry logins. Both values now travel as environment variables, and the token is piped rather than placed in a here-string. The workflow now audits clean. This workflow has never been executed, which the file says in its own header. Rehearsing it end to end from an untagged candidate remains an open item.
The pinned digest for `actions/attest-sbom` did not match its `v3.0.0` comment. zizmor caught it in CI, which is the point of pinning by digest and annotating with the version: a pin nobody can check against a tag is a hash somebody typed. Resolved every pin in the workflow against the upstream tag rather than fixing only the one that was reported. The other seven were correct.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First of Package 4's three items.
The workflow cannot fire by accident
Trigger is a pushed tag only — no branch trigger, no schedule, no manual
dispatch. A release is caused by creating an immutable tag and by nothing
else.
Permissions are read-only at the workflow level and widened per job:
packages: write,id-token: writecontents: write,attestations: writeBefore anything is built, the tagged commit must be an ancestor of
main,so a tag pointing at an unreviewed commit cannot publish it.
Tag validation is a tested script, not a regex in YAML
A release tag is immutable: one discovered to be wrong afterwards cannot be
corrected, only superseded. So the checks run before anything is published
under it, and they are covered by 15 unit tests in the lint job — exercised on
every change rather than once, during a release, when it is too late.
Beyond the pattern:
20260230matches the pattern and isnot a date
architecture locks must agree with each other
withdrawn release is never back-filled and two artifacts can never share a
name
Digest-bound, and it verifies itself
Signature, provenance attestation, and SBOM attestation all bind to the
manifest digest, never to a tag — a tag can be made to point elsewhere, a
digest cannot. The workflow then runs
cosign verifyagainst its own signaturebefore publishing evidence, proving the signature is discoverable and bound to
this workflow's identity, which is what a consumer will actually check.
Two high-severity findings, fixed before commit
zizmorflagged${{ github.actor }}expanding directly into a shell commandin both registry logins — template injection. Both values now travel as
environment variables, and the token is piped rather than placed in a
here-string. The workflow audits clean.
What this PR does not claim
The workflow has never been executed. That is stated in the file's own
header, not just here. Rehearsing it end to end from an untagged candidate is a
separate Package 7 item, and until that happens its behaviour is asserted by
reading rather than by evidence.
🤖 Generated with Claude Code