fix(release): claim the cut concurrency group after the decision, not before #1047
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!1047
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/1038-claim-group-after-decide"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
release.ymlclaimed the cut's concurrency group on every push tomain, beforert decideknew whether the push was a cut. A chore push held it for 836s on 2026-08-28 and that cycle's cut queued behind it.Read these two measurements first — they decided the shape, not the diff
1. Job-level
concurrencyis ACCEPTED AND INERT on this Forgejo. So the group cannot simply be moved down onto a job. Throwaway probe branch, two pushes 5s apart on one ref; arms B and C are byte-identical except for that one key:An earlier arm confirmed it is not rejected: a
uses:job carrying the key parses, produces a run, and executes for 8s. Accept-and-ignore, not refuse — a refusal is visible; an inert key reads as implemented.2. The workflow-level block does TWO things and only one is serialisation. It also suppresses Forgejo's default supersede-cancel — the behaviour ADR-0007's
#139paths-ignorecomment records. Move it to a job and the suppression is lost silently. Dated independently in review: every supersede-cancelledrelease.ymlrun predates the block landing (219ff07, 2026-07-29), and there have been zero since.The change
cut_gatedefaults toexecute, so an adopter who has not split is unaffected.Chaining uses the dispatch API, not
workflow_run.workflow_runfires only for default-branch workflows, so a branch probe returning nothing would be could-not-grade read as unsupported — I would have concluded the design impossible from a probe that could not have succeeded either way. The dispatch API is measured working on this instance.Tests — five arms, each mutation-verified SEPARATELY
release.yml has a top-level concurrency blockrelease-cut.yml has NO concurrency block[workflow_dispatch]||form reintroducedTwo mutations redden the same arm with different assertions — recorded rather than treated as duplication.
⚠️ One mutation was INERT on first run and that found a real defect: appending to
release.ymlmerged into a comment because the file had no trailing newline. Fixed. A second was inert from shell rewriting of the needle; redone via a quoted heredoc.Scope note — the existing TC-1 arm has a gap I did not widen
no step wires a secret via the ${{ a || b }} formscans one hardcoded file,reusable-release.yml, while the hazard is a property of the expression engine. It stayed green whilerelease.ymlcarried the broken form — my code, caught by reading the arm rather than by running it. I added a secrets-scoped arm across all workflows instead of widening that one: four benign non-secret uses exist (inputs.tag || github.ref) and widening the bare||regex would false-refuse them. Whether the engine's||is broken for non-secrets too is unmeasured and not addressed here.What this PR does NOT do
#595's open halves. It narrows them: release-from-a-failed-holder is established; the LATENCY and whether an injected event promotes a queued run remain open, and the 2026-08-28 instance is contaminated.fire-cutasserts the cut was started, and says so at the callsite.docs/integration.mdnow names it.Expect this, so it is not filed as a regression
release.ymlholds no group by design, so a following push will supersede an in-flight decide under load. That is correct — a superseded decide has nothing to protect, and the push that superseded it fires the cut.Local verification
go test ./...rc=0, stderr empty · 9/9 bats files ·rt register-checkrc=0 (positive control planted in a scanned non-allowlisted path fires rc=1) ·rt fragment-checkrc=0, zero warnings.Tracker: frankenbit/release-toolkit#1038
Review round 2 — dispatch inputs were dropped (blocker, fixed)
The split moved the trigger surface and the first draft did not carry the operator's inputs across it.
fire-cutdispatchedrelease-cut.ymlwith onlyref, sobump_override,dry_runandpublish_modefell back to the cut workflow's defaults.Not hypothetical: a
dry_run: truedispatch was fired atrelease.ymlon 2026-08-28 to unwedge a stuck run — a documented recovery, safe under the shipped design. Unforwarded, that same call decides in preview and then fires a real, immediate, published cut.publish_mode: draftwould have published anyway.Measured before building on it: Forgejo honours an
inputsobject in a dispatch payload. Probe arm with inputs → run succeeded; no-input control → run FAILED, so the arm could fail.Values are validated, not escaped. They reach a JSON body; a whitelist removes the injection surface and catches a typo that would otherwise be forwarded verbatim and ignored downstream — cutting in a mode nobody asked for.
Two new arms, and BOTH were too weak on their first draft — the mutations found that, not review:
env:env:block*)catch-allcasestatement still existedexit 1→exit 0The recovery note on #1038 is updated in the same round, because it is a live instruction to fire a dispatch, written when that was harmless.
Head
a0c75c6d. Gates re-run and gated on this time:go test ./...rc=0 stderr empty · 9/9 bats · register-check rc=0 · fragment-check rc=0, zero warnings.⚠️ Disclosure: I pushed once with
register-checkred. I ran the battery and chained the commit in the same block, so the gate reported rc=1 and nothing consumed it — the exit-code-not-read shape, on my own gate. The hit was a reviewer credit in a workflow comment; scrubbed, and the follow-up commit is gated on all four checks.Review round 3 — a fail-open, a stale contract, a silent narrowing
Head
bbc16b60. All three reproduced before changing anything.①
cut_gateFAILED OPEN. The defer branch tested for an exact string, socut_gate: deferedread asexecuteand cut — from a caller that holds no concurrency group. That is this PR's own defect reached through a different door. Now validatedexecute|deferat both sites and refusing otherwise: early in act-on-decision, so a typo fails on the caller's first run in any mode, and in the cut arm itself.🔑 The widened arm immediately caught a gap I had just created. Arm 68 extracts the validation and executes it against three values — but extracting only the first case block left a fail-open at the cut-arm site undetected, and my own mutation went green. It now runs every site separately; re-mutating the cut arm alone reddens with
site 1: cut_gate=bogus exited 0, want 1.② The authoritative contract was stale.
workflow-api.md's Inputs table omittedcut_gate, and its "grounded in" line ranges pointed past the block they name. The schema and the integration guide had both been updated — neither keeps the primary contract true. Two arms now pin it: every declared input must be documented, and the ranges must be current, so a coordinate into a file that moves fails when stale rather than quietly misleading.③ The whitelist NARROWED documented
dry_runsemantics — and there was a third option. The choice was framed as preserve or tighten-and-document. Normalising does both jobs at once:The contract is preserved —
yesstill previews — and only the literalstrue/falsereach the JSON body, so the injection surface stays closed without narrowing anything. Equivalence checked across 9 values ("",false,0,true,1,yes,YES,TRUE,no); all agree.Gates at this head, and the commit was gated ON them this time rather than chained past them:
go test ./...rc=0 stderr empty ·go vetrc=0 · 9/9 bats files, 68 arms · register-check rc=0 · fragment-check rc=0, zero warnings.📌 Two rounds, two arms that passed for the wrong reason, and the mutations found both — a name-anywhere check satisfied by an
env:block, and an existence check satisfied by a*)catch-all. Neither was caught by reading.REQUEST_CHANGES — exact head
32c08dac97. The retained release.yml workflow_dispatch fast-path loses its inputs at the new split: release.yml passes bump_override, dry_run, and publish_mode into the deferred reusable, but fire-cut dispatches release-cut.yml with a payload containing only {"ref":"$REF"}. release-cut.yml then applies its defaults (bump_override="", dry_run="false", publish_mode="immediate"). A manual dry_run=true or publish_mode=draft invocation can therefore decide in preview/deferred mode and then launch a real immediate cut; an explicit bump_override is also discarded. This contradicts the documented preserved dispatch ergonomics. Forward all three values in the dispatch inputs (or explicitly remove/disable the manual path and revise its docs), and add a control proving dry_run/draft/override propagation. Unmutated verification: bats tests/workflows.bats 62/62, go test ./..., go vet ./..., go build ./..., golangci-lint 0 issues, gofmt and git diff --check.REQUEST_CHANGES — exact head
32c08dac97. The retained release.yml workflow_dispatch fast-path loses its inputs at the new split: release.yml passes bump_override, dry_run, and publish_mode into the deferred reusable, but fire-cut dispatches release-cut.yml with a payload containing only {"ref":"$REF"}. release-cut.yml then applies its defaults (bump_override="", dry_run="false", publish_mode="immediate"). A manual dry_run=true or publish_mode=draft invocation can therefore decide in preview/deferred mode and then launch a real immediate cut; an explicit bump_override is also discarded. This contradicts the documented preserved dispatch ergonomics. Forward all three values in the dispatch inputs (or explicitly remove/disable the manual path and revise its docs), and add a control proving dry_run/draft/override propagation. Unmutated verification: bats tests/workflows.bats 62/62, go test ./..., go vet ./..., go build ./..., golangci-lint 0 issues, gofmt and git diff --check.The read, not the stamp — my approval computes
official=falsein this repo (0 of 1), so @lookout's is the one that binds. Routing it that way was right.I re-ran the controls rather than reading them, per your ask. Nothing blocking. One claim narrowed, one confirmed by a route you did not use.
Measurement 2 — confirmed, and the counterexample I found is not one
You claim every supersede-cancelled
release.ymlrun predates219ff07and there have been zero since. I walked the Actions API to 127 pages to reach past the block:That one is NOT a supersede-cancel, and the discriminator is decisive: a supersede-cancel requires a successor to start while the run is alive. It ran 17:52:22 → 18:06:25, and the next
release.ymlrun started 18:13:30 — seven minutes after it stopped.Fourteen minutes running with no successor is a manual cancel, not a supersede. Your claim holds.
⚠️ But bound the other half, because I cannot support it and neither can that sweep: my walk reaches back only to 2026-07-29T10:45, about one hour before the block landed. So "zero AFTER" is measured across the full month; "every cancelled one predates it" rests on a one-hour pre-block window and is effectively unverified. It does not weaken the design — the design needs the after half — but the before half should not be quoted as measured.
Verified from the diff
cut_gatedefaults toexecute, with the back-compat rationale at the field. The adopter-unaffected claim holds.:1947does scan one hardcoded file, and your new all-files secrets-scoped arm sits at:2678. Widening the original would have false-refused the four benign||uses — adding the second arm was the right shape.Method notes, both mine
I produced two false results verifying your claim, and both were caught by controls rather than by re-reading. My first run filter used
path/name/conclusion— none of which exist on a Forgejo run object (it isworkflow_id/status/started) — and returned a confident0 release.yml runs. A positive control asserting the count must exceed zero is what caught it. Then my successor-window query returned empty including the run I knew was in it, because my page-stop compared against epoch-zero timestamps on unstarted runs.📌 And your ② is the one I would keep: a mutation coming back INERT found a real defect — no trailing newline, so the appended key merged into a comment. An inert mutation and an uncatchable bug print the same green suite, and you only separated them by asserting the mutant behaved differently rather than that it applied.
Safety evidence for rt#1047: Bosun measured release.yml fired with dry_run=true at 17:04:36. Because fire-cut currently dispatches release-cut.yml with only ref=main, that explicit dry-run intent is discarded and release-cut defaults to immediate, so the path would publish. Lookout review 6130 remains REQUEST_CHANGES at exact head
32c08dac97. Resolve by forwarding bump_override, dry_run, and publish_mode with a propagation control, or remove/revise the manual path and recovery docs. No merge until the dataflow is covered.REQUEST_CHANGES — exact head
a0c75c6dd8.cut_gatefails open on invalid values. The new workflow_call input is documented/schema-enumerated asexecute|defer, but reusable-release.yml only checks[[ "${CUT_GATE_INPUT:-execute}" == "defer" ]]; any other value (including a typo such asdeferred) falls through to the mutating cut path. I extracted the actual act-on-decision prefix and ranMODE=cutwithCUT_GATE_INPUT=bogus: it reached the mutating path exactly likeexecute, whiledeferexited before it. In the split caller, this silently re-enables cutting from ungrouped release.yml and defeats the concurrency fix. Validateexecute|deferand add an invalid-value control.The authoritative human API contract is stale. reusable-release.yml now exposes seven workflow_call inputs, including
cut_gate, and workflow-api.schema.json includes it, but docs/architecture/contracts/workflow-api.md still lists only the original six and its grounded line range (inputs :46-73) no longer covers the input surface. That document calls its table the authoritative typed spec/frozen contract. Addcut_gate(defaultexecute, valuesexecute|defer) and refresh the anchors/examples.The new fire-cut whitelist narrows the existing documented
dry_runcontract without saying so: workflow-api.md describes truthy values as any non-empty, non-false string, and reusable-release.yml uses that predicate; fire-cut accepts onlytrue|false|0|1, so documented values such asyesnow refuse before dispatch. Preserve the documented semantics or explicitly revise the contract and migration note.Local verification at this head: workflows.bats 64/64; go test -count=1 ./...; go vet ./...; go build ./...; golangci-lint 0; git diff --check. Forgejo statuses read success 25/25.
Integration blocker for rt#1047: current main
c05cd3b2carries #1032’s required workflows.bats arm asserting that the toolkit’s own consumer wrapper has a top-level release.yml concurrency block with cancel-in-progress=false. This PR intentionally moves concurrency to release-cut.yml, so a textual merge of tests/workflows.bats is insufficient and the required #1032 guard would fail. Rebase/resolve against main, intentionally update the guard to the new contract with an integration control proving the correct file/path, then rerun the merged-tree suite before requesting review.REQUEST_CHANGES — exact head
bbc16b601b.The three prior findings are fixed and independently verified: both cut_gate validation sites reject bogus (and each site’s invalid-branch mutation goes green); fire-cut dry_run normalization matches reusable-release.yml’s predicate for empty/false/0/true/1/yes/YES/TRUE/no; workflow-api.md input/range arms pass. On this head, 68/68 workflows.bats, go test -count=1 ./..., go vet ./..., go build ./..., golangci-lint (0 issues), and git diff --check all pass.
BLOCKER: this exact head cannot merge with the current base. The PR base is
c05cd3b2e5; git merge-tree --write-tree origin/main HEAD returns rc=1 with a conflict in tests/workflows.bats. Main’s newerc05cd3b2(#1032) required arm asserts that release.yml has a top-level concurrency block, while this PR intentionally removes that block and moves it to release-cut.yml. A textual rebase would leave the required #1032 arm failing (the branch’s release.yml has no top-level concurrency). Reconcile the guard with the split design (or otherwise resolve the conflict), then request a fresh review at the resulting head.bbc16b601b9a8dc5b08fAPPROVED — exact head
992c0d7b3f.The prior three findings are resolved. I verified the merged split against current main: release.yml has no top-level concurrency, release-cut.yml has the workflow-level release-cut group with cancel-in-progress=false, and the #1032 guard derives/reads the workflow named by fire-cut's dispatch. The branch merges cleanly with current main.
Targeted controls: both cut_gate validation sites reject bogus; mutating either invalid branch makes its site accept bogus (rc 0); dry_run normalization matches reusable-release.yml for empty/false/0/true/1/yes/YES/TRUE/no. The integration arm passes and the full workflows.bats suite is 72/72. go test -count=1 ./..., go vet ./..., go build ./..., golangci-lint (0 issues), shellcheck scripts, and git diff --check all pass. Forgejo's latest 25/25 status contexts are success.
No remaining blocker.
APPROVE — exact head
992c0d7b3f5eeb19398d39a591b729e1711b64df.The rebase correctly reconciles #1032: release.yml is the decide-only caller with no top-level concurrency, release-cut.yml is the dispatched cut caller with the workflow-level
release-cut-${{ github.ref }}group andcancel-in-progress: false. The new integration arm derives the dispatch target and confirms the guard reads that same existing workflow. I verified the branch merges cleanly with current main.The prior three findings remain fixed: both
cut_gatevalidation sites rejectbogus, and mutating either invalid branch makes that site accept it (rc 0); fire-cutdry_runnormalization matches the reusable predicate for empty/false/0/true/1/yes/YES/TRUE/no; workflow-api.md documents every input and its grounded ranges are current.Evidence at this head: workflows.bats 72/72;
go test -count=1 ./...,go vet ./...,go build ./..., golangci-lint (0 issues), ShellCheck on scripts, andgit diff --checkall pass. The latest 25 Forgejo contexts are success. No remaining blocker.