Skip to content

Establish signed releases and a version policy - #175

Merged
icebergai-review-bot[bot] merged 2 commits into
mainfrom
release-policy-v2
Aug 16, 2026
Merged

Establish signed releases and a version policy#175
icebergai-review-bot[bot] merged 2 commits into
mainfrom
release-policy-v2

Conversation

@richardmhope

Copy link
Copy Markdown
Contributor

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

  • 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.
  • SPDX SBOM per image, attached as a signed attestation — how "does this release contain
    the library in that advisory?" gets answered from the registry without a rebuild.
  • GitHub build provenance, so the image can be tied back to this repository, that workflow,
    and that commit.
  • The source archive is git archive rather than GitHub's generated tarball, which has changed
    byte-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 (an
added 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 answer
is a restore.

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.

Supply chain

Every action pinned to a commit (a test asserts it), and the workflow is clean under zizmor:
persist-credentials: false on each checkout, 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. That is why the first job is a gate rather than a formality.

#147 acceptance criteria

  • A documented release workflow produces immutable versioned artifacts
  • Artifact signatures and provenance are independently verifiable
  • Upgrade notes identify migrations and operator actions
  • Security fixes have a defined release and notification process
  • CI rehearses supported upgrade paths and documented recovery — the next PR

make check green: 1755 passed.

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.

@icebergai-review-bot icebergai-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with tag=main (or any branch/SHA) checks out that ref, passes the version and changelog checks, and publishes its images under the declared release version and latest; the release step also uses that arbitrary string as tag_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:39 uses ref: ${{ inputs.tag || github.ref }}; the same unchecked input is used for every publishing job and as tag_name in the GitHub release. Reproduce by dispatching the workflow with tag=main while main declares the current version.
    Independent assessment: workflow_dispatch accepts an unchecked ref for checkout and derives the required version directly from that same string. An untagged branch named v<declared-version> can therefore pass verification and be built, signed, and published with release credentials; no existing refs/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.

@richardmhope

Copy link
Copy Markdown
Contributor Author

Fixed in da7f20f. Real, and a good catch — the recovery path threw away the one
gate on: push: tags: gives for free.

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 and refs/heads/v1.2.3 are refused rather than trimmed into
something that looks valid. Checking the input out first and validating it afterwards
would have been checking the ref against itself.

Resolved once, used everywhere. The job outputs refs/tags/vX.Y.Z; every
publishing job checks that out, and 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.

Both halves are pinned:

  • test_only_an_existing_release_tag_can_be_published — the anchored pattern and the
    git rev-parse --verify refs/tags/… are present.
  • test_no_publishing_job_checks_out_the_dispatch_inputinputs.tag appears exactly
    once, and the publishing jobs use needs.verify.outputs.ref.

zizmor still reports no findings on the workflow. make check green.

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.

@icebergai-review-bot icebergai-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: verify uses anchored tag validation plus git rev-parse --verify --quiet "refs/tags/$REF_NAME"; later checkouts use needs.verify.outputs.ref and tag_name uses needs.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.ref and GitHub release creation uses needs.verify.outputs.tag.

Residual risks

  • None identified.

@icebergai-review-bot
icebergai-review-bot Bot merged commit d2f8b7f into main Aug 16, 2026
6 checks passed
@icebergai-review-bot
icebergai-review-bot Bot deleted the release-policy-v2 branch August 16, 2026 04:52
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