feat(ci): gate release PRs on the signed hash list, and cut the tag after merge - #1788
Conversation
…fter merge Two workflows around the existing release ritual (a reviewed development -> master PR carrying the version bump): release-gate runs on PRs targeting master and judges the merge preview: the version moves forward numerically, the ZelBack tree hash is in the validly signed document central serves (verified against the published keys, so a stale deploy answering with the unsigned array is a red run), and helpers/hashes.json carries the hash for old fluxbench's fallback. Read-only, no secrets. Enforcement comes from branch protection listing the check as required. release-tag runs on master pushes that change the version: re-verifies the merged tree against the signed document (polling up to 15 minutes for the divergent-merge case), samples ten live nodes' /flux/hashlist -- advisory until the fleet serves it -- then pushes the annotated tag, publishes the GitHub Release, and records the tag on the provenance row in fluxhashes. The annotation happens here because the tag is pushed with GITHUB_TOKEN, whose pushes do not trigger workflows, so the publish workflow's tag path cannot fire for CI-cut tags. Idempotent on re-run once the tag exists. Every run block was executed verbatim against local origins under Linux: 27 cases covering version comparisons (including 8.17.9 -> 8.17.10 numerically), signature rejection of the unsigned catch-all body and of a wrong-key document, the poll loop converging on a later attempt, the advisory sample staying advisory, tag idempotency, and provenance annotation exactly once with attribution preserved. That harness caught one real bug: NEW_HASH was written to GITHUB_ENV but not exported, so the inline node scripts in the same step would have read undefined and failed every release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…h to fluxhashes dies The provenance-annotation step held the other PAT write path into fluxhashes, with its own clone, retry loop, and copy of the row discipline. Under the single-writer design the signer owns the record: this now dispatches "commit X, tag vN" and the signer resolves the tag against its own view of the flux repository and annotates the row itself. The merged tree hash rides along as the claimed_hash tripwire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFrS7Q3JwuPj6Yr4vALnwp
Matches fluxhashes feature/sign-hashlist: key 1 regenerated 2026-08-24 into the environment-scoped secret; key 2 unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFrS7Q3JwuPj6Yr4vALnwp
Same credential as nodejs.yml's dispatch: the flux-hashlist-dispatch app, Actions on fluxhashes only, short-lived token per run, nothing to renew. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFrS7Q3JwuPj6Yr4vALnwp
…ument Both workflows derive the tree hash with the same unguarded pipeline the publish path used: a missing ZelBack makes find error while the pipeline's status stays awk's, and the result is d41d8cd98f00b204e9800998ecf8427e, the md5 of an empty stream. Neither is unsafe today. Both use the value only to ask "is this hash in the signed document", the signer now refuses to list the empty hash, and it is not among the 224 entries currently published -- so the answer is no and the gate goes red or the tag is not cut. The outcome is already correct. What is wrong is the diagnosis. The failure reads "not in the signed document", which sends the reader to the signing chain, the published list and hashes.runonflux.io when the actual cause is that ZelBack is not there. On the day someone renames that directory all three copies of this pipeline fire at once; two now say what happened and this was the one that would not. Guarded the same way as the other two, so a future reader does not have to work out whether the omission here was deliberate. Verified by extracting each step's script verbatim from the workflow and running it against three fixtures: absent ZelBack exits 1 on find's own error, empty ZelBack exits 1 on the guard, a real tree passes through unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQKpZbuxYqrWeErU7vKgoA
actions/checkout v3 -> v7, actions/create-github-app-token v1 -> v3. checkout v3 was three majors behind and runs on a deprecated Node. v7 also refuses to fetch fork pull request code under pull_request_target and workflow_run -- neither workflow uses those triggers, so nothing changes today, but the dangerous shape now fails closed if one is ever added. The app-token majors do not reach us: v2 removed the underscore input spellings and release-tag uses the hyphenated ones; v3 removed custom proxy handling and raises the self-hosted runner floor, and this runs GitHub-hosted with no proxy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HQKpZbuxYqrWeErU7vKgoA
Cabecinha84
left a comment
There was a problem hiding this comment.
One finding I'd ask to fix before ACK
Tag already cut? will silently swallow the very next release.
Repo state right now:
- master = 8.17.0, development = 8.17.1
- Tag v8.17.1 exists and the GitHub Release v8.17.1 is marked Latest
- v8.17.1 → 60cb6fc (merge of #1773), which is on neither master nor development — master was rolled back behind it
So when the next release PR (development 8.17.1 → master 8.17.0) merges:
- Did the version change? → 8.17.0 ≠ 8.17.1 → release=true
- Tag already cut? → git ls-remote --tags origin refs/tags/v8.17.1 succeeds → done=true
- Every remaining step is skipped: no merged-tree verification, no tag, no Release, no provenance dispatch — and the
job is green
The result is the exact defect the PR exists to fix — a "Latest" release pointing at an orphan commit that isn't on
master — reported as success, with the authoritative post-merge check (the one the file header calls "the only
divergence branch protection cannot cover") skipped.
The fix is small and doesn't weaken idempotency, because the legitimate re-run case is a re-run on the same commit:
if EXISTING=$(git ls-remote --tags origin "refs/tags/v${V}^{}" | cut -f1) && [ -n "$EXISTING" ]; then
if [ "$EXISTING" = "$GITHUB_SHA" ]; then
echo "done=true" >> "$GITHUB_OUTPUT" # genuine re-run
else
echo "tag v${V} already exists at ${EXISTING}, not this commit"; exit 1
fi
fi
Either that, or clean up the stale v8.17.1 tag/Release before activation — but I'd prefer the code change, since the
same hand-tagging that produced this state can produce it again.
Non-blocking notes (worth a comment, not a block)
- "Safe to merge at any point" is slightly optimistic. A release merged to master before the signing chain is live
doesn't no-op — it polls the unsigned array for 15 minutes (Buffer.from(undefined,'base64') → TypeError, caught,
retried ×30), then fails with no tag. Non-releases are genuinely inert; releases in that window are not. The manual
tag fallback still exists, so it's a red run plus a manual step, but the PR body should say so. - The gate needs the merge-preview tree to be a branch tip. The signer reconciles ls-remote --heads --tags only, so
it signs branch-tip trees. Today master is an ancestor of development, so the merge preview tree == development's
tip tree and the gate can pass. Any commit landing directly on master (the pre-PR habit — 845aa9b "fluxos
v8.17.0" was one) creates a merge tree no branch tip holds, which no signing run will ever cover → gate red forever
until master is merged back into development. This PR actually removes the main source of that divergence
(hashes.json rides the PR), but the trap deserves a line in the workflow header. - Prerelease versions compare inconsistently. 8.17.1 → 8.17.2-rc1 throws (NaN), but 8.17.1 → 8.18.0-rc1 passes (minor
delta short-circuits before the NaN). Cosmetic — Flux doesn't ship prereleases — but the failure mode is
arbitrary. - ${{ steps.version.outputs.version }} interpolated into run:. Value comes from package.json on master, so it's
post-review content — real risk is negligible, but reading it via env: instead of ${{ }} costs nothing. - The gate is defined in the tree it gates. A release PR can edit release-gate.yml and its own required check reports
green. Inherent to in-repo required checks, mitigated by human review — worth stating explicitly rather than
leaving implicit. - No github.repository guard on release-tag. Forks pushing to their own master get red runs. Cosmetic.
- cat poll-error.txt dumps a full Node stack trace per attempt — up to 30 stack traces in the log. head -1 would read
better.
The idempotency check matched the tag by name alone, so a tag holding the release version but pointing at another commit -- one cut by hand, or one master was later rolled back behind -- was read as "this workflow already ran". Every remaining step was skipped, including the merged-tree verification that is the only check covering what master actually holds, and the job reported success with no tag cut and nothing verified. The tag is now resolved to a commit and compared to the one being released. A match is a genuine re-run and still skips; anything else stops the job and names both commits. Both tag forms are read, because an annotated tag advertises the commit through a peeled ref and a lightweight tag does not -- and every tag in this repository today is lightweight, so reading one form finds neither reliably. ls-remote answers empty when nothing matches, so pipefail is what separates "no such tag" from a transport failure. tests/ci/release-tag.sh runs the step as it ships, extracted from the workflow rather than retyped, across both tag forms and a dead remote; it also asserts the rest of the job still reads the output this step writes, since an output that is never written compares equal to nothing and silently disables the release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes to release-gate, and three small ones to release-tag.
A version the comparison cannot read is refused by name rather than judged by
whichever part happens to differ. Splitting on dots and comparing the results let
"8.18" pass with no third part at all, and "8.18.0-rc1" pass on its minor while
"8.17.2-rc1" was refused on a NaN -- opposite answers for the same kind of input.
A release whose merged tree exists on no branch is refused at the gate. The merge
preview is a ref nowhere, so its hash is in the signed document only because some
branch tip already holds the same ZelBack subtree. When master carries ZelBack
changes development lacks, nothing will ever sign the merged tree, and the
membership check below would tell the reader to wait for a signing run that never
comes. This refuses first and names the remedy. It compares the ZelBack subtree,
not the whole tree, because master diverging under helpers/ is routine and cannot
change the hash.
release-tag now runs only on this repository, reads its last two values through
env rather than interpolating them into the shell, and logs the poll failure's
message rather than its whole stack trace -- fifteen lines by thirty attempts.
tests/ci/release-workflows.sh covers all three steps, extracted from the workflow
files verbatim: 20 cases, and 11 mutations each reintroducing one defect, all
caught by a case asserting the cause. It also asserts the steps carry no ${{ }},
so a step that becomes uninspectable breaks the tests rather than silently
skipping them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What a releaser does, what each check means, and what every red message is asking for. The one change to the ritual is that the helpers/hashes.json entry now rides the release PR instead of being committed to master after the merge -- which works because the fingerprint covers ZelBack only, so adding the entry cannot change it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes to release-gate, three small ones to release-tag, and the tests for all of them. a9ed2f0 carried the test file's rename without this content; the two belong together. A version the comparison cannot read is refused by name rather than judged by whichever part happens to differ. Splitting on dots and comparing the results let "8.18" pass with no third part at all, and "8.18.0-rc1" pass on its minor while "8.17.2-rc1" was refused on a NaN -- opposite answers for the same kind of input. A release whose merged tree exists on no branch is refused at the gate. The merge preview is a ref nowhere, so its hash is in the signed document only because some branch tip already holds the same ZelBack subtree. When master carries ZelBack changes development lacks, nothing will ever sign the merged tree, and the membership check below would tell the reader to wait for a signing run that never comes. This refuses first and names the remedy. It compares the ZelBack subtree, not the whole tree, because master diverging under helpers/ is routine and cannot change the hash. release-tag now runs only on this repository, reads its last two values through env rather than interpolating them into the shell, and logs the poll failure's message rather than its whole stack trace -- fifteen lines by thirty attempts. tests/ci/release-workflows.sh covers all three steps, extracted from the workflow files verbatim: 20 cases, and 11 mutations each reintroducing one defect, all caught by a case asserting the cause. It also asserts the steps carry no ${{ }}, so a step that becomes uninspectable breaks the tests rather than silently skipping them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All eight items are serviced, at The blocking finding — fixed, by commit rather than by name
It reads both
The repository state has also moved since you looked: master is 8.17.1 and Non-blocking notes1 — overtaken by events. fluxhashes#2 and #1789 merged about ten minutes after the review. Central serves the signed document, and development's tip 2 — taken, as a check rather than a header line. The gate now refuses before it asks central, naming the remedy: merge master into development. The mechanism is right; the rule is narrower than stated. Master is not an ancestor of development — it is three commits ahead — but the gate hashes 3 — taken and widened. A version that is not three numeric parts is refused by name. 4 — taken. No 5 — acknowledged, no change. Inherent to any in-repo required status check. What stands against it is master's required approvals and code-owner review; worth having stated rather than left implicit. 6 — taken. 7 — taken, with a different command. Tests
One of the mutations is the peeled-only form, so the repository cannot drift back into it unnoticed. The suite also asserts that the steps it extracts carry no |
Background
fluxbench validates a FluxOS installation by checking its
ZelBacktree hash against a list of known-good hashes. Until recently that list was served unsigned and written directly by this repository's CI, and releases were cut by hand: the tag was pushed manually, master kept moving afterwards, and nothing ordered "the hash is published" against "nodes can update to it".Two merged PRs fixed the first half. fluxhashes#2 made the fluxhashes signer the single writer of the list — deriving every hash itself from commits it fetches from this repository, signing with Ed25519, and keeping a provenance record. #1789 replaced this repository's write access with a dispatch. Both merged on 2026-08-30, and the signer has been publishing continuously since.
This PR is the third piece: it makes the release process respect that ordering — a release PR cannot merge until the tree it ships is already in the signed list, and the tag + GitHub Release become CI outputs cut after verification instead of by hand.
This PR also adds
RELEASING.md, which is the process doc from here on. It covers what a releaser does, what each check means, and what every red message is telling you to fix. Nothing else needs to be known to cut a release.The release process, before and after
development → masterrelease PRgatecheckhelpers/hashes.jsonentryrelease-gate
Runs on PRs targeting
masterand judges the merge preview — the tree master will actually hold, not the PR branch alone:8.18has no third part, and8.18.0-rc1would otherwise pass on its minor while8.17.2-rc1was refused on aNaN.ZelBackis a tree the signer can have seen. The merge preview is a ref nowhere, so its hash is in the signed document only because some branch tip already holds the sameZelBacksubtree — normally this PR's head. If master carriesZelBackchanges development lacks, the merged tree exists on no branch and no signing run can ever cover it; this refuses immediately and names the remedy (merge master into development) instead of letting the next check tell the reader to wait for something that will never arrive. It compares theZelBacksubtree deliberately: master diverging underhelpers/is routine and cannot change the hash.helpers/hashes.jsoncarries the hash (old fluxbench's fallback; this check retires with it).Read-only, no secrets. Enforcement comes from branch protection listing the check as required — the workflow itself has no power to block a merge.
release-tag
Runs on
masterpushes that change the version, and only on this repository:/flux/hashlist— advisory (logged, never fatal) until the fleet serves the endpoint; enforcement is later turning the final failure on.The dispatch happens here rather than relying on a tag-push trigger, because the tag is pushed with
GITHUB_TOKEN— andGITHUB_TOKENpushes do not trigger workflows. Nothing in this repository writes to fluxhashes; the dispatch token carries Actions permission only.Idempotency is by commit, not by name. A re-run whose tag already points at this commit has nothing left to do and skips. A tag carrying the release version but pointing somewhere else — cut by hand, or left behind by a master that was rolled back — is not a re-run, and the job stops rather than skipping: skipping would mean the merged tree was never verified and the release reported green with no tag. Both annotated and lightweight tags are resolved, because every tag in this repository today is lightweight while everything this workflow cuts is annotated.
A push that does not change the version is not a release and takes a green no-op path.
Tested
tests/ci/release-workflows.shruns offline with no secrets, extracts each step from the workflow files verbatim rather than copying it, and mutation-tests its own assertions:The eleven mutations each reintroduce one specific defect — a lookup blind to lightweight tags, a whole-tree comparison that over-fires on routine
helpers/divergence, a loosened version pattern, a missing failure guard, a downstream step that stops reading the output the gate writes — and every one is caught by a case asserting the cause, not just the failure. It also asserts that the steps it extracts have no${{ }}left in them, so a step that becomes uninspectable breaks the tests rather than silently skipping them.Before that, the whole flow ran on real GitHub in throwaway sandbox repositories with a test key:
not in the signed document (seq 1)signed document seq 2 carries the tree hashv8.17.2and GitHub Release published; provenance row annotated with the tag, original attribution preserved0 serving— correctly non-fatalversion unchanged, not a release, greenActivation
The prerequisites are complete. fluxhashes#2 and #1789 are merged, the signer has been publishing continuously since 2026-08-30, and
hashes.runonflux.ioserves the signed document.Verified end to end on 2026-09-01: development's tip
ZelBackhash06779cc6717187066ab83ee7b1249838is present in signed document seq 12, so a release cut from development today passes the gate on its first poll.Order from here:
release-gateonly runs on PRs targeting master, andrelease-tagonly reaches master at the next release.release-tagdoes its work.release-gateruns on that PR but cannot block it yet. FollowRELEASING.md— the one change to the ritual is that thehelpers/hashes.jsonentry now rides the release PR instead of being committed to master afterwards.gateto master's required status checks. That is the activation, and it is what makes the check enforcing.🤖 Generated with Claude Code