Skip to content

ci: add a least-privilege release workflow and tag validation - #32

Merged
joey-huckabee merged 2 commits into
mainfrom
ci/least-privilege-release-workflow
Sep 18, 2026
Merged

joey-huckabee merged 2 commits into
mainfrom
ci/least-privilege-release-workflow

Conversation

@joey-huckabee

Copy link
Copy Markdown
Contributor

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:

Job Adds
validate tag nothing — stays read-only
build packages: write, id-token: write
publish manifest contents: write, attestations: write

Before 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:

  • the date is a real calendar date — 20260230 matches the pattern and is
    not a date
  • the date is not in the future relative to UTC
  • NGINX version and UBI major agree with the artifact lock, and the two
    architecture locks must agree with each other
  • the tag is not already published
  • the daily sequence continues from the highest used, so a gap left by a
    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 verify against its own signature
before 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

zizmor flagged ${{ github.actor }} expanding directly into a shell command
in 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

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.
@joey-huckabee
joey-huckabee merged commit 4d6a756 into main Sep 18, 2026
7 checks passed
@joey-huckabee
joey-huckabee deleted the ci/least-privilege-release-workflow branch September 18, 2026 01:02
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