Establish signed releases and a version policy - #175
Conversation
Part 1 of #147: what a version number promises, and the pipeline that makes an artifact carrying one verifiable. The upgrade and recovery rehearsals are the next PR. **One version, everywhere.** A release is a single number applied to both role images, the chart's `appVersion`, and every workspace package — no per-component versioning, because every component in a deployment comes from the same build and "what am I running?" should not have five answers. Six hand-edited files with nothing structural keeping them in step, so `make version-check` and `tests/test_release_invariants.py` fail on the pull request that half-bumps them rather than during the release. The chart's *own* version is deliberately exempt: a template change is a chart release even when the application did not change. **The CHANGELOG entry is the release notes.** The workflow extracts it, so they are never written twice and cannot disagree. A release that adds a migration must carry a Migrations section — "does this upgrade touch my database?" is the first question an operator asks and the one a changelog most often fails to answer, so it is a test rather than a convention. **Signed without holding a key.** cosign's keyless flow binds each signature to the GitHub workflow identity that produced it; the certificate lives for one job. Nothing here can leak, expire, or need rotating. Images are signed **by digest, never by tag** — a tag is a mutable pointer the registry can move, so verifying a signature on one proves something about a name rather than about the bytes that run. Each image also carries an SPDX SBOM as a signed attestation, which is how "does this release contain the library in that advisory?" is answerable from the registry without rebuilding, plus GitHub build provenance. **Verification is checked before anything is published.** The first job re-runs the version and CHANGELOG checks and the build jobs depend on it, because a signed artifact is public the moment it exists and a bad release has to be superseded rather than withdrawn. The policy the mechanics serve is in `docs/releases.md`: what counts as breaking (and the two things that deliberately do not — an added response field, an added rule), a 90-day support window on the previous minor, the rule that every migration must be additive with respect to the previous release, and why rollback is bounded by migrations rather than by images. `docs/runbooks/release.md` has both halves: how to cut one, and the exact `cosign verify` / `gh attestation verify` commands an operator runs — with the `--certificate-identity-regexp` flags, because without them `cosign verify` only confirms that *somebody* signed it. Every action is pinned to a commit and the workflow is clean under zizmor: no persisted checkout credentials, and every `github.*` expansion routed through the environment rather than interpolated into a shell. Honest limitation: a tag-triggered workflow cannot be exercised by CI. The invariant tests assert its shape — verify-before-publish, pinned actions, signing by digest — and the first real tag is the first real run. make check green: 1755 passed.
There was a problem hiding this comment.
Verdict
CHANGES_REQUESTED
Completed bounded review across 1 immutable scope(s). One high-severity release-integrity bypass in the new workflow.
Scope health
Convergence: healthy. Review mode: initial.
Recommended action: CONTINUE_INCREMENTAL.
- No escalation signals.
Prior findings
| Finding | Status |
|---|---|
| — | No prior finding state |
New findings
Root cause: The workflow never validates that inputs.tag names an existing refs/tags/vX.Y.Z ref before checkout and publication.
- BLOCKER · high: Restrict recovery dispatches to an existing tag —
.github/workflows/release.yml:39
Status: NEW. Attribution: new_in_scope.
The recovery input is treated as an arbitrary checkout ref, not as an existing tag. A manual run withtag=main(or any branch/SHA) checks out that ref, passes the version and changelog checks, and publishes its images under the declared release version andlatest; the release step also uses that arbitrary string astag_name. This bypasses the stated tags-only release gate and can overwrite a released image tag with untagged code.
Invariant: Only tagged releases are supported and signed artifacts must be built from the release tag.
Ownership: Release workflow dispatch input to trusted publishing credentials.. Behaviour: Manual recovery runs can publish arbitrary refs as releases..
Evidence:.github/workflows/release.yml:39usesref: ${{ inputs.tag || github.ref }}; the same unchecked input is used for every publishing job and astag_namein the GitHub release. Reproduce by dispatching the workflow withtag=mainwhilemaindeclares the current version.
Independent assessment:workflow_dispatchaccepts an unchecked ref for checkout and derives the required version directly from that same string. An untagged branch namedv<declared-version>can therefore pass verification and be built, signed, and published with release credentials; no existingrefs/tags/validation exists.
Fix-induced regressions
- None evidenced.
Uncertainty
- No material uncertainty recorded.
Validation
- Reviewed the supplied immutable PR patch; prior blockers were not supplied.
Residual risks
- None identified.
|
Fixed in da7f20f. Real, and a good catch — the recovery path threw away the one Two halves, because either alone is thin: Validated before anything is checked out from it. The verify job now checks out Resolved once, used everywhere. The job outputs Both halves are pinned:
zizmor still reports no findings on the workflow. |
Real, and a good catch. `workflow_dispatch` accepted `tag` as an arbitrary checkout ref, so a manual recovery run with `tag: main` would check out a branch, pass the version and CHANGELOG checks — `main` declares the current version — and publish untagged code under that release, over an image an operator had already verified. `on: push: tags:` gives that gate for free; the recovery path threw it away. Two halves, because either alone is thin: **Validated before anything is checked out from it.** The verify job now checks out without the input, at full depth, and only then asserts the ref matches an anchored `^v[0-9]+\.[0-9]+\.[0-9]+$` and that `refs/tags/<ref>` actually exists. Anchored so `v1.2.3-attacker` or `refs/heads/v1.2.3` is refused rather than trimmed into something that looks valid. Checking out the input first and then validating it would have been checking the ref against itself. **Resolved once, used everywhere.** The job outputs `refs/tags/vX.Y.Z`, and every publishing job checks that out instead of the input; the GitHub release takes its `tag_name` from the same output. Validating in one job is only worth anything if no later job can reach past it for the original string, so `inputs.tag` now appears exactly once in the file — in the env of the step that validates it — and a test asserts that count. `test_only_an_existing_release_tag_can_be_published` and `test_no_publishing_job_checks_out_the_dispatch_input` pin both halves. zizmor still reports no findings.
There was a problem hiding this comment.
Verdict
APPROVE
Completed bounded review across 1 immutable scope(s). No new actionable findings. The release workflow now validates a strict existing release tag before checkout and routes all publishing jobs through the verified ref.
Scope health
Convergence: healthy. Review mode: delta.
Recommended action: CONTINUE_INCREMENTAL.
- No escalation signals.
Prior findings
| Finding | Status |
|---|---|
| F-301864D5F3D1 | Resolved |
New findings
No new findings.
Fix-induced regressions
- None evidenced.
Uncertainty
- No material uncertainty recorded.
Validation
- Prior blocker F-301864D5F3D1 resolved:
verifyuses anchored tag validation plusgit rev-parse --verify --quiet "refs/tags/$REF_NAME"; later checkouts useneeds.verify.outputs.refandtag_nameusesneeds.verify.outputs.tag. - Reviewed the supplied immutable workflow, runbook, and invariant-test delta.
- Confirmed the prior raw dispatch input is used only in the verification job; publishing checkouts use
needs.verify.outputs.refand GitHub release creation usesneeds.verify.outputs.tag.
Residual risks
- None identified.
Part 1 of #147: what a version number promises, and the pipeline that makes an artifact
carrying one verifiable. The upgrade and recovery rehearsals are the next PR.
One version, everywhere
A release is a single number applied to both role images, the chart's
appVersion, andevery workspace package. No per-component versioning: every component in a deployment
comes from the same build, and "what am I running?" should not have five answers.
Six hand-edited files with nothing structural keeping them in step — so
make version-checkand
tests/test_release_invariants.pyfail on the pull request that half-bumps them, ratherthan during the release. The chart's own version is deliberately exempt and there is a test
saying so, since a template change is a chart release even when the application did not change.
The CHANGELOG entry is the release notes
The workflow extracts it, so they are never written twice and cannot disagree.
A release that adds a migration must carry a Migrations section. That is a test, not a
convention: "does this upgrade touch my database?" is the first question an operator asks and
the one a changelog most often fails to answer.
Signed without holding a key
cosign's keyless flow binds each signature to the GitHub workflow identity that produced it,
and the certificate lives for the length of one job. Nothing in this repository can leak,
expire, or need rotating.
verifying a signature on one proves something about a name rather than about the bytes
that run.
the library in that advisory?" gets answered from the registry without a rebuild.
and that commit.
git archiverather than GitHub's generated tarball, which has changedbyte-for-byte across platform upgrades — a checksum published against it is a promise this
project cannot keep.
Verification happens before anything is published. The first job re-runs the version and
CHANGELOG checks; both build jobs depend on it. Once a signed artifact exists it is public, and
a bad release has to be superseded rather than withdrawn.
The policy the mechanics serve
docs/releases.md— what counts as breaking, and the two things that deliberately do not (anadded response field, an added rule; treating either as breaking would make every release a
major). A 90-day support window on the previous minor. The rule that every migration must be
additive with respect to the previous release, because a rolling upgrade briefly runs the old
API against the new schema. And why rollback is bounded by migrations rather than by images:
downgrade()makes it mechanically possible, not lossless, so where the data matters the answeris a restore.
docs/runbooks/release.mdhas both halves — how to cut one, and the exactcosign verify/gh attestation verifycommands an operator runs. With the--certificate-identity-regexpflags, because without them
cosign verifyonly confirms that somebody signed it.Supply chain
Every action pinned to a commit (a test asserts it), and the workflow is clean under zizmor:
persist-credentials: falseon each checkout, and everygithub.*expansion routed through theenvironment rather than interpolated into a shell.
Honest limitation
A tag-triggered workflow cannot be exercised by CI. The invariant tests assert its shape —
verify-before-publish, pinned actions, signing by digest — and the first real tag is the first
real run. That is why the first job is a gate rather than a formality.
#147 acceptance criteria
make checkgreen: 1755 passed.