Skip to content

feat(ci): gate release PRs on the signed hash list, and cut the tag after merge - #1788

Merged
Cabecinha84 merged 11 commits into
developmentfrom
feat/release-gate
Sep 1, 2026
Merged

feat(ci): gate release PRs on the signed hash list, and cut the tag after merge#1788
Cabecinha84 merged 11 commits into
developmentfrom
feat/release-gate

Conversation

@MorningLightMountain713

@MorningLightMountain713 MorningLightMountain713 commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Background

fluxbench validates a FluxOS installation by checking its ZelBack tree 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

today with this PR (activated)
the release act merge the reviewed development → master release PR unchanged — same PR, same review, same merge button
what is allowed to merge anything approved only a candidate whose tree hash is already in the signed list, with the version bumped and the fallback entry present — the gate check
the tag pushed by hand, sometimes at a different commit than nodes end up running cut by CI after re-verifying the tree that actually landed on master
GitHub Release manual, when done at all published by CI together with the tag
helpers/hashes.json entry committed straight to master after the tag, outside any pipeline rides the release PR, enforced by the gate
release provenance the fluxhashes commit message only the fluxhashes signer records the tag on the hash's provenance row
a late or failed hash publication invisible — nodes can update to a tree the list does not yet cover blocks the merge (before it) or the tag (after it), loudly
master between releases moves freely; nodes follow HEAD, which can be past the release moves only through gated release PRs once branch protection is on

release-gate

Runs on PRs targeting master and judges the merge preview — the tree master will actually hold, not the PR branch alone:

  • the version moves forward, compared numerically per part: string comparison would call 8.17.10 older than 8.17.9. A version that is not three numeric parts is refused by name rather than judged by whichever part happens to differ — 8.18 has no third part, and 8.18.0-rc1 would otherwise pass on its minor while 8.17.2-rc1 was refused on a NaN.
  • the merged ZelBack is 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 same ZelBack subtree — normally this PR's head. If master carries ZelBack changes 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 the ZelBack subtree deliberately: master diverging under helpers/ is routine and cannot change the hash.
  • the tree hash appears in the signed document central serves, with the signature verified against the published keys — a stale deploy answering every path with the unsigned array is a red run, not a pass.
  • helpers/hashes.json carries 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 master pushes that change the version, and only on this repository:

  • re-verifies the merged tree against the signed document, polling up to 15 minutes. The merge tree normally equals the already-verified PR head tree, so this passes on the first poll. Each retry logs the failure's message rather than its whole stack trace.
  • samples ten live nodes' /flux/hashlist — advisory (logged, never fatal) until the fleet serves the endpoint; enforcement is later turning the final failure on.
  • pushes the annotated tag, publishes the GitHub Release, and dispatches the tag annotation to the fluxhashes signer, which records it on the hash's provenance row itself.

The dispatch happens here rather than relying on a tag-push trigger, because the tag is pushed with GITHUB_TOKEN — and GITHUB_TOKEN pushes 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.sh runs offline with no secrets, extracts each step from the workflow files verbatim rather than copying it, and mutation-tests its own assertions:

as they ship : 20 passed, 0 failed
mutations    : 11 caught, 0 missed

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:

release PR opened before publication gate red: not in the signed document (seq 1)
merge attempted while red refused by branch protection, against the repo admin
candidate published and signed gate green: signed document seq 2 carries the tree hash
merge, then release-tag merged tree hash identical to the PR head hash; tag v8.17.2 and GitHub Release published; provenance row annotated with the tag, original attribution preserved
advisory sample ten real mainnet nodes, 0 serving — correctly non-fatal
non-release pushes to master version unchanged, not a release, green

Activation

The prerequisites are complete. fluxhashes#2 and #1789 are merged, the signer has been publishing continuously since 2026-08-30, and hashes.runonflux.io serves the signed document.

Verified end to end on 2026-09-01: development's tip ZelBack hash 06779cc6717187066ab83ee7b1249838 is present in signed document seq 12, so a release cut from development today passes the gate on its first poll.

Order from here:

  1. Merge this PR. Nothing changes on merge — release-gate only runs on PRs targeting master, and release-tag only reaches master at the next release.
  2. The next release carries both workflows onto master, where release-tag does its work. release-gate runs on that PR but cannot block it yet. Follow RELEASING.md — the one change to the ritual is that the helpers/hashes.json entry now rides the release PR instead of being committed to master afterwards.
  3. The old publication credential on this repository can be retired once master carries the post-feat(ci): hash publication becomes a dispatch; flux CI loses write access to fluxhashes #1789 workflow.
  4. Add gate to master's required status checks. That is the activation, and it is what makes the check enforcing.

🤖 Generated with Claude Code

…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 Cabecinha84 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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:

  1. Did the version change? → 8.17.0 ≠ 8.17.1 → release=true
  2. Tag already cut? → git ls-remote --tags origin refs/tags/v8.17.1 succeeds → done=true
  3. 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)

  1. "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.
  2. 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.
  3. 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.
  4. ${{ 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.
  5. 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.
  6. No github.repository guard on release-tag. Forks pushing to their own master get red runs. Cosmetic.
  7. 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>
@MorningLightMountain713

Copy link
Copy Markdown
Collaborator Author

All eight items are serviced, at 3ee1c829b. RELEASING.md and tests/ci/release-workflows.sh are new on the branch.

The blocking finding — fixed, by commit rather than by name

Tag already cut? now resolves the tag to a commit and compares it to the one being released. The same commit is a genuine re-run and still skips. Anything else stops the job and names both commits, because skipping would mean the merged tree was never verified and the release reported green with no tag.

It reads both refs/tags/vX and refs/tags/vX^{} and prefers the peeled value. Two things made that necessary rather than taking the snippet as written:

  • The peeled ref exists only for annotated tags. All 312 tags in this repository are lightweight — including the v8.17.1 that prompted the finding — so a peeled-only lookup matches none of them.
  • There is no done=false branch. On the ordinary path nothing is written to $GITHUB_OUTPUT, so every later step's if compares an empty string against 'false' and the release silently no-ops green.

The repository state has also moved since you looked: master is 8.17.1 and 60cb6fca9 is on it, so that particular collision is gone. The class is not, which is why the check is in.

Non-blocking notes

1 — overtaken by events. fluxhashes#2 and #1789 merged about ten minutes after the review. Central serves the signed document, and development's tip ZelBack hash 06779cc6717187066ab83ee7b1249838 is in seq 12 — so a release today verifies on the first poll. The body no longer describes the prerequisites as pending.

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 ./ZelBack only, and all three touch helpers/. 845aa9b6a is one line of helpers/hashes.json. Of the last 25 commits on master, ten are direct and none touches ZelBack. So the invariant is that the merge preview's ZelBack subtree must match a branch tip's, not that the whole tree must — a whole-tree comparison would block every release following a node-data commit. There is a test case for exactly that, and a mutation proving it bites.

3 — taken and widened. A version that is not three numeric parts is refused by name. 8.18 passed before too, with no third part at all.

4 — taken. No ${{ }} remains inside any run: in either workflow, and the tests assert it.

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. if: github.repository == 'RunOnFlux/flux' on the job, matching the guard #1789 put on nodejs.yml.

7 — taken, with a different command. head -1 returns [eval]:1 — node prints the file marker first and the message about five lines down. It now greps the message line, with head -1 as a fallback so a failure that is not a node error still prints something.

Tests

tests/ci/release-workflows.sh runs offline with no secrets, extracts each step from the workflow files verbatim rather than copying it, and mutation-tests its own assertions.

as they ship : 20 passed, 0 failed
mutations    : 11 caught, 0 missed

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 ${{ }}, so a step that becomes uninspectable breaks the tests rather than silently skipping them.

@Cabecinha84 Cabecinha84 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ack

@Cabecinha84
Cabecinha84 merged commit a4fe016 into development Sep 1, 2026
3 checks passed
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.

2 participants