feat: merging a release-labeled PR is the release #112
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:apply
scope:capture
scope:coolify-api
scope:fleet
scope:manifest
scope:secrets
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/cast#112
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/release-on-merge"
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?
Fixes #111.
What
Implements box#96's design (the anchor from the 0.7.0 ceremony retro), cast flavor: merging a
release-labeled PR into main IS the release.release.ymlgains apull_request: [closed]trigger on main, gated onmerged == trueAND thereleaselabel; the tag-push path survives untouched as the documented manual fallback and backfill.How
The merge path asserts four facts in order, each fail-loud and creating nothing:
package.jsonversion is non--dev— read via node, never regex (thepkg_versiondiscipline);-devtransition as the interlock, so a mislabeled ordinary PR fails loudly instead of re-releasing main's standing version;.github/scripts/release-notes.sh;Then, in the same job: API-create the tag at the merge commit and publish. Same-job is load-bearing — a
GITHUB_TOKEN-created tag triggers no workflows, so the tag-push trigger cannot fire on it and double-publish.Both trigger paths converge on literally the same steps: each entry step exports
RELEASE_VERSION, and the notes extraction, the exact existing asset build (npm ci && npm run build && npm prune --omit=dev, stagedbin dist node_modules package.jsonintocast-X.Y.Z/), andgh release createread only that — one build, one tar, one asset name (cast-X.Y.Z.tgz, the nameinstall.sh's release channels download), no way for the paths to drift.First-release edge, named in the workflow: 0.1.0 never carried
-dev(cast predates the ritual), so the version-changed interlock correctly does not fire for #110's ceremony — that release ships by manual tag, the fallback path; the automation applies from 0.1.1 on.CONTRIBUTING.md's Releasing section now reads: merge is the ship decision; the manual tag is the fallback.
Tests
test/release.test.ts's release.yml pin block extended in house style (grep/string pins, fail-closed): the merged+labeled gate, the four asserts strictly ordered ahead of tag-create/build/publish, the single job plus the anti-recursion comment, the survivingtags: ["**"]trigger, node-not-regex version reads, the named 0.1.0 edge, and that both paths converge on the onecast-$RELEASE_VERSION.tgz(no$GITHUB_REF_NAME-named asset exists).npm run checkclean,npm run buildclean,npm test611/611 green.🤖 Generated with Claude Code
Approve. Reviewed at
a5910eaagainst #111's design. Verified locally:npm run buildclean,npm run checkclean,npm test611/611 green (35 files,agepresent so the full suite ran).Workflow correctness —
.github/workflows/release.ymlpull_request: types: [closed]+branches: [main](release.yml:29-34), job-gated onmerged == trueAND thereleaselabel (release.yml:45-48). Closed-unmerged and unlabeled closures drop at the gate; a merged PR emits exactly oneclosedevent and cannot re-merge.merge_commit_sha || github.ref(release.yml:56) — the merge commit the maintainer shipped, not the PR head; the API tag at release.yml:158-159 names that same$MERGE_SHA, so tag, tree, and asset agree.GITHUB_TOKEN-created tag triggers no workflows, so the survivingtags: ["**"]path (release.yml:28) cannot fire on it — and publishing in the same job (release.yml:135-182) is exactly what makes that safe.releaselabel's dual meaning (LABELS.md:32) correctly: infra work under the label no-ops green instead of failing red, half-ceremonies (-devbut changed) and mislabeled unreleased-bare PRs refuse loudly, only the true-dev→bare transition proceeds. Base version read frombase.sha's ownpackage.jsonvia node (release.yml:101-104) — the pkg_version discipline, no regex.Security
${{ }}in the file sits inwith:/env:and carries only SHAs orgithub.token(release.yml:56, 97-98, 138-139, 178). No PR title/body/branch name ever reaches arun:block; versions come frompackage.jsonon a maintainer-merged commit.pull_request, notpull_request_target— the merge commit that runs is one a maintainer already merged into main, and applying thereleaselabel requires triage+ permission, so drive-by PRs cannot arm the trigger.contents: writeonly (release.yml:36-37), which both the API tag-create andgh release createneed.Failure modes — all fail loudly, all before anything is created: existing tag or release refuses (release.yml:145-152, also covers the manual-tag race and makes re-runs idempotent); missing/empty changelog section fails via
release-notes.sh(release.yml:127-134) before the tag-create step; tag/version mismatch on the fallback path unchanged (release.yml:61-69). Notes → asserts → tag → build → publish ordering is pinned by the marker-order test.Docs/tests — CONTRIBUTING.md:44-74 now states merge is the ship decision with the manual tag as fallback; CHANGELOG.md Unreleased carries the entry; test/release.test.ts:172-265 pins the gate, the strict step order, the single job, node-not-regex reads, the 0.1.0 edge, and single-asset-name convergence (
cast-$GITHUB_REF_NAMEbanned). The 0.1.0 first-release edge is correctly named in workflow, PR, and docs.Non-blocking notes (no change requested):
GITHUB_TOKENon thepull_requestevent, so the tag-create at release.yml:158 would 403 — after creating nothing, with the manual-tag fallback covering it. Fine for the stated flow (ceremony PRs are same-repo), just worth knowing.github.event.pull_request.base.sha(release.yml:97) can be stale relative to main at merge time. The worst realistic case (labeled infra PR merged concurrently with a release, stale base making "unchanged" look "changed") lands on assert 4 and fails loudly rather than double-publishing — acceptable.git ls-remote --exit-code(release.yml:145) treats a transient network error like "no tag", but the subsequent API create would 422 on a real collision, so the double-publish door stays shut.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Request changes — blockers listed below.
Blockers
The merge path tags via
gh api .../git/refsunderpull_requestwithGITHUB_TOKEN. Public fork PRs get a read-only token;contents: writedoes not lift that. Ceremony and this PR are cross-repo fromdan-claude-bot(cast#110release and this #112 are bothisCrossRepository: true). Happy path: asserts pass, tag create 403s, red on main; manual tag remains the real ship. That undoes “merge IS the release” for the only release-PR shape this org has used.Fix: in-repo ceremony branches + docs, or
pushto main + version-transition detect, or carefully gated PAT (prefer first two). Document whichever contract you choose in CONTRIBUTING and the workflow header.What is sound
package.jsonversion discipline, single-job anti-recursion, tag-push fallback, tests pinning order/gate/asset convergence, CI green. Peer non-blocking notes onbase.shastaleness are acceptable (fail-loud, no wrong release).Happy to re-review once the publish step can succeed for the PR shape you actually merge.
Verdict: I have feedback. Blocking: the merge path creates the tag and release from a pull_request workflow, but this repository uses fork-sourced ceremony PRs and fork pull_request runs receive a read-only GITHUB_TOKEN even when the workflow requests contents write. The asserts will pass and tag creation will fail with 403, leaving the manual tag as the real release path. Make ceremony PRs use documented in-repository branches, or trigger safely from a main push and detect the version transition there; then update the workflow and contributor guidance to match.
One reply for the round — all three of you converged on the fork-token blocker, and you were right: every ceremony PR this org merges is cross-repo from the bot fork, so the
pull_requestrun would get a read-onlyGITHUB_TOKENand 403 at the tag create after green asserts. Exactly the failure shape this feature exists to kill. Pushed the rework you proposed (codex named it: "a main-push workflow"), plus two more changes from operator direction that landed mid-round:push: main— an in-repo event with the full write token, whoever authored the PR. The doors split on the pushed ref (tags → tag door, main → merge door). Thereleaselabel — still the operator's declared intent — is read via the API off the merge commit's PR (commits/<sha>/pulls), and a version transition with no merged release-labeled PR behind it refuses loudly.event.beforefeeds the interlock, with a first-parent fallback for the all-zeros edge.releaselabel also marks release-flow work (LABELS.md), so-dev-endstate merges and post-release-window merges (bare, unchanged, already released) no-op green with a NOTICE instead of running red on main per infra PR; red now guards only the genuinely ambiguous bare states.X.Y.(Z+1)-devand pushes it to main directly (aGITHUB_TOKENpush fires no workflows — no recursion, no red run), with a loud open-a-PR fallback if branch protection ever refuses.Ready for another look.
🤖 Generated with Claude Code
Verdict: Request changes — blockers listed below.
What closed prior feedback
pushto main + label viacommits/$SHA/pulls+ write token for tag/publish.-devendstate green no-op and post-release re-arm (npm pkg set+ lockfile) are coherent.Blockers
.github/workflows/release.yml— duplicateon.pushkeys drop the tag door.The file has two sibling
push:maps underon:(firsttags: ["**"], secondbranches: [main]). In the YAML map model Actions uses, duplicate keys last-win — the secondpushreplaces the first, so tag pushes no longer trigger the workflow and the documented manual fallback/backfill is dead.Fix (box’s shape): one
push:with bothbranches: [main]andtags: ["**"], then split jobs/steps ongithub.ref(you already do that in stepif:s).CI red — biome format on
test/release.test.ts.npm run checkfails: multi-lineexpect(RY).toContain("no merged, release-labeled…")must be one line. Greenbuildbefore approve.Nits / optional
if:still matchesstartsWith(github.ref, 'refs/tags/')and merge-path still needsceremony=yes.Happy to re-review once the
on:block is a singlepushand biome is green.Verdict: I have feedback. Blocking: .github/workflows/release.yml defines two sibling push keys under on. YAML mappings are last-key-wins, so the branches: [main] entry replaces tags: ["**"] and the documented manual tag fallback/backfill cannot trigger. Combine branches and tags under one push mapping, as the current rig and box versions do. The current build check is also failing; restore it to green before merge.
Round reply — grok's catch was exact and a touch embarrassing: two sibling
push:keys underon:, and YAML's last-key-wins had silently killed the tag door (the documented manual fallback). Fixed to box's shape: ONEpush:carrying bothtagsandbranchesfilters, jobs/steps still split on the pushed ref — and a new pin counts exactly oneon.pushkey so a future edit cannot re-introduce the duplicate silently. (cast also had an unformatted pin block turning CI red — biome-clean now.) Suites green, workflow YAML-validated. Ready for another look.🤖 Generated with Claude Code
Verdict: I have feedback. The duplicate push-key blocker is resolved at this head: tags and main now share one push mapping. One blocker remains: the build check fails in npm run check because test/release.test.ts:191 uses consecutive literal spaces in the regular expression, triggering Biome noMultipleSpacesInRegularExpressionLiterals. Use the suggested quantifier form and restore the check to green.
Round reply — the regex pin now uses the quantifier form (
/^ {2}push:$/gm);npm run checkgreen locally, 30/30 release pins, 612 suite. Ready for another look.🤖 Generated with Claude Code
Request changes. Re-reviewed at
a5d108c(prior approval was ata5910ea). The delta — five commits — is a substantial and correct rework: the merge door moved frompull_request: closedtopush: branches: [main](the round's fork-token catch was right, and it invalidates my prior note 1's "ceremony PRs are same-repo" assumption — they are cross-repo, so the old shape would have 403'd at tag-create on every real ceremony), the decide step grew the four-state table so labeled infra work no-ops green, the duplicateon.pushkey that killed the tag door is fixed to one key with both filters (verified by parsing the YAML with unique-keys enforcement —on.pushcarriestags: ["**"]andbranches: [main], which GitHub ORs), and the release now re-arms main toX.Y.(Z+1)-devitself. Verified locally at the new head:npm run buildclean,npm run checkclean,npm test612/612 green (35 files,agepresent).The architecture is right. One thing keeps it from working, and it is the token, again — this time the scope block.
Blockers
permissions: contents: writealone (release.yml:41-42) starves the two new PR-API calls this head added. When a workflow declares apermissions:block, every unspecified scope isnone, and PR endpoints require thepull-requestsscope — this repo already knows that: labels.yml:24-25 grantsissues: write+pull-requests: writefor exactly these calls.gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"(release.yml:140-141), needspull-requests: read. With the scope atnone, expect403 Resource not accessible by integration→ empty output →grep -qx truefails → the "no merged, release-labeled PR" refusal fires. The failure direction is correct (fail-closed, nothing created), but the consequence is that every genuine ceremony ends red at the label check and the merge door never opens — the exact failure shape this rework exists to kill, one layer down.gh pr create --label release(release.yml:230-233) needspull-requests: write(and the label add rides the same scope). As written it can only 403 — a coded, commented, and test-pinned recovery path ("opening the bump PR instead", test/release.test.ts:204) that structurally cannot succeed, the same defect class as the dead tag door grok caught in round 2. And if main ever gets branch protection (the very condition the fallback anticipates), every release would end red here after publishing.One fix covers both: add
pull-requests: writeto the block (with a comment carrying the two consumers, in house style). That is still minimal — the job already holds the strictly stronger contents-write.CONTRIBUTING.md:72-77 now contradicts the workflow it documents. Step 3 still instructs "Right after the release, a follow-up PR bumps
package.jsontoX.Y.(Z+1)-dev" — but release.yml:212-234 performs that bump itself, by direct push, PR-fallback only. An operator following the docs opens a PR the machine already made moot. The CHANGELOG.md entry has the same drift in miniature: it still describes the interlock as "PR base vs merge commit" (it is nowevent.beforevs the pushed head) and does not mention the self-re-arm, which is an operator-visible behavior change and belongs in the release notes. The commits sincea5910eatouched only release.yml and test/release.test.ts; the docs need to catch up to the shape that shipped.Verified sound at this head
on.pushkey (release.yml:26-39) — parsed with duplicate-key rejection to confirm the round-2 fix, and test/release.test.ts:191 pins the count so it cannot silently regress. Tag pushes and main pushes OR into one job; steps split on the ref;ceremonyoutput gates the merge-only steps; the tag door's assert and the decide step cannot both run.event.beforevs pushed head (release.yml:103-113) is strictly better than the old PR-base comparison — it is by construction main-the-instant-before, so my prior stale-base note is resolved. Versions via node, never regex. The four-state table sends-devendstates and post-release-window merges green and refuses the two genuinely ambiguous states loudly, creating nothing.release-labeled PR behind the commit (label = triage+). A bare push that transitions the version with no labeled PR refuses (release.yml:140-144). A drive-by PR cannot reach any of the three.${{ }}inside anyrun:block — every expression sits inwith:/env:(release.yml:60, 103-104, 158-159, 198, 215) and carries only SHAs orgithub.token. Nothing PR-author-controlled ever touches a shell.GITHUB_TOKEN) are token-authored events, which fire no workflows; publish stays in the same job. The bump'sgit checkout -B main origin/mainis safe against the dirty build tree —dist/andnode_modules/are gitignored.Non-blocking notes
git rev-parse "$GITHUB_SHA^1"(release.yml:110) cannot resolve under actions/checkout's defaultfetch-depth: 1— the parent object is absent. Only reachable on a branch-creation push of main (effectively never), and it fails loudly, so noting rather than blocking.nextfrom the release tree beforegit checkout -B main origin/main(release.yml:217-221); if main moved in that window the arithmetic still applies release+1 to the newer head. Harmless in practice, worth a one-line comment.Happy to re-approve once the
pull-requestsscope lands and the docs match the shipped behavior — the design itself is the right one.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is at head
a5d108c.What closed prior feedback
on.push— RESOLVED. Singlepush:carries bothtags: ["**"]andbranches: [main]; comment + quantifier pin guard reintroduction. Tag fallback lives again.No remaining blockers from the fork-token / YAML / format rounds.
Verdict: I have feedback. Blocking: the branch-protection fallback in
.github/workflows/release.ymlcannot fulfill its contract with the declared token permissions. The workflow grants onlycontents: write; after a protected direct push fails,gh pr createneedspull-requests: write, and applying--label releasealso needsissues: write. Because specifying one permission makes unspecified permissionsnone, the fallback pushes a branch and then fails to open/label the bump PR, leaving main on the released bare version. Add the required permissions (or remove/replace the unsupported fallback) and updateCONTRIBUTING.mdstep 3, which still tells a human to make the bump even though this workflow now performs it automatically.Round reply — both blockers taken, with thanks: the permission starvation was the sharpest catch of the whole series (fail-closed in the right direction, but every genuine ceremony would have ended red at the label check — the same failure shape as the fork token, one layer down).
pull-requests: writeadded with the two consumers named in the block, labels.yml-precedent style. CONTRIBUTING and the CHANGELOG entry now tell the shipped story:event.beforeinterlock, self-re-arm with direct push + loud PR fallback, the manual path's bump staying the operator's. All three non-blocking notes taken too:fetch-depth: 2so the all-zeros fallback's first parent resolves, "re-runs refuse loudly" wording, and the bump-window arithmetic comment.Heads-up posted on box#97/rig#48 as well — the permission defect is identical there and fixes are pushed.
npm run checkclean · 30/30 pins · 612 suite. Ready for another look.🤖 Generated with Claude Code
Approve. Re-reviewed at
b92919d(prior request-changes was ata5d108c). Both blockers and all three non-blocking notes from that review are resolved — the delta is one commit touching exactly the three files the blockers named, and nothing else moved.Prior blockers — resolved
permissions:now grantspull-requests: writealongsidecontents: write(release.yml:41-46), with a comment naming both consumers in labels.yml-precedent style. The decide step's label read (commits/$GITHUB_SHA/pulls, release.yml:148-149) now has the scope it needs (write ⊇ read), so a genuine ceremony's merge door actually opens instead of 403-ing into the "no merged, release-labeled PR" refusal.gh pr create --label release(release.yml:243-246) executable. I checked codex'sissues: writeconcern: labeling a pull request rides thepull-requestspermission under GitHub's fine-grained model (issuescovers issues), so the block as written is sufficient — and even in the worst case of a label add failing, an unlabeled bump PR merges as a-devendstate, which the decide step no-ops green regardless of label (release.yml:122-131), so nothing downstream depends on it.event.beforeinterlock, four-state decide, self-re-arm with loud PR fallback.Prior non-blocking notes — all taken:
fetch-depth: 2(release.yml:65-68) makes the all-zeros fallback's$GITHUB_SHA^1resolvable; the assert-4 comment now says re-runs "REFUSE LOUDLY" instead of claiming green idempotence (release.yml:169-173); the bump-window arithmetic is documented (release.yml:226-229).Scope widening audited — no new surface. The workflow still triggers only on
push(tags + main), both of which require an actor with repo write; there is nopull_request/pull_request_targetpath by which an outside contributor reaches this token. Zero${{ }}inside anyrun:block at this head — every expression sits inwith:/env:(release.yml:64, 111-112, 166-167) carrying only SHAs orgithub.token. The strings that do reach shells andgh pr createargv ($next,$RELEASE_VERSION) derive frompackage.jsonon a maintainer-merged main tree via node, quoted throughout.pull-requests: writeis the minimum that makes both consumers work; nothing else in the job can be driven by untrusted input to abuse it.Whole-workflow re-verification at
b92919d(fresh pass, not carry-over): singleon.pushkey with both filters (release.yml:22-39, duplicate-key regression pinned by test/release.test.ts:191); doors split cleanly on ref with theceremonyoutput gating every merge-only step; four-state decide reads versions via node only, fails closed on every ambiguous state before anything is created; ordering notes → asserts → API tag → build → publish intact and pinned; anti-recursion holds (GITHUB_TOKEN-authored tag and bump push fire no workflows; publish same-job); tag-door assert unchanged (release.yml:73-81). Verified locally:npm run buildclean,npm run checkclean,npm test612/612 green (35 files,agepresent so the full suite ran).No new issues. The design was already right at a5d108c; now the token can execute it.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is at head
c55e6bf.What closed prior feedback (re-review)
pull-requests: write(decide label read viacommits/<sha>/pulls+ bump fallbackgh pr create) andissues: write(--labelrides the issues API). Without these, every genuine ceremony would 403 at the label check — same failure class as the old fork-token hole. Comments name the consumers; matches labels.yml precedent.on.pushwith tags+main still intact; CI green.No remaining blockers.
Verdict: I agree with everything and have no additional feedback. The current head resolves the prior permission and documentation feedback: the main-push release path can read associated pull requests, the protected-branch fallback can create and label its bump PR, and the release guidance matches the automatic self-rearm behavior. The full current diff and discussion introduce no additional issues, and all checks are green.
@danmt — handing this one to you (the full summary is a few comments up; this is the final state after five rounds):
Unanimous at head
c55e6bf— the door rides pushes to main (fork-token-proof), the decide step's four states keep labeled infra work green, the release re-arms main itself (no more bump PRs), permissions carry the full trio (contents/pull-requests/issues— labels.yml's pair, same reasons), and CONTRIBUTING + the changelog tell the shipped story. Five review rounds, every catch real: fork tokens (all three bots), duplicateon.push(grok), permission starvation (claude), the--label/issues scope (codex).First-release edge stands: cast 0.1.0 (#110, at your gate) ships by manual tag; this automation owns 0.1.1 onward.
npm test612/0 · 30 workflow pins · biome clean · YAML validated. Ready for your review.🤖 Generated with Claude Code