The reusable release workflow — both doors, one implementation #9
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:docs
scope:guards
scope:labels
scope:release-flow
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/ceremony#9
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Part of #1. Blocked by #3, #4, #8. Blocks #11, #13–#16.
Goal
.github/workflows/release.yml— THE reusable release workflow (on: workflow_call), replacing box's (213 lines), rig's (238) and cast's (250). Read all three before writing a line — every comment in them was bought with an incident, and the constraint list in #1 ("Load-bearing constraints") is the port-review checklist for this issue.The caller contract (goes in the workflow's header comment AND docs/CONSUMERS.md)
The consumer's entire
release.yml:workflow_callinputs:version-source(string, default"file"). That's all — see #1's config-axes table; the artifact axis is a repo-local hook (below), not an input.Key mechanics of the reusable form (verify each against docs, then encode in comments):
github.ref,github.sha,github.event.before), caller'sGITHUB_TOKEN, caller's permission grant. So the two doors split ongithub.refexactly as today, and the anti-recursion property is unchanged: tags/pushes created withGITHUB_TOKENfire no workflows.pushto main, neverpull_request(fork PRs get a read-only token thatpermissions:cannot raise — box#97; every ceremony PR in this org is cross-repo from a bot fork).Getting this repo's
lib/at run time — the self-ref pin (#1 D3)A called workflow file arrives alone; it does not bring its repo. So the first step of each job checks out two things:
${{ github.sha }}(fetch-depth: 2— the head's first parent must resolve; plus cast's belt-and-bracesgit fetch --depth=1 origin "$BASE_SHA" || truebefore reading the base version);heavy-duty/ceremonyat a literal pinned ref, intopath: .ceremony-src(inside the workspace; actions/checkout cannot land outside it).The pin is one workflow-level env:
CEREMONY_SELF_REF: "X.Y.Z"(checkout'sref:accepts${{ env }};uses:strings do not — which is why the shared logic is script files, not inneruses:references). Rules:github.repository == 'heavy-duty/ceremony'(the dogfood path, #11), the.ceremony-srccheckout is skipped andCEREMONY_DIRpoints at the consumer checkout itself — the workspace IS this repo at the merge commit, libs included. Otherwise ceremony's own 0.1.0 run would try to check out tag0.1.0, which is created only after that very run succeeds. Every script invocation goes through aCEREMONY_DIRenv ($GITHUB_WORKSPACE/.ceremony-srcfor consumers,$GITHUB_WORKSPACEfor self), set in one step, so the bypass is oneif:plus one env line.test/self-ref.test.sh+ CI step: on a bare-VERSIONtree,CEREMONY_SELF_REFMUST equalVERSION; on a-devtree it MUST equal the newest## X.Y.Zheading in this repo's own CHANGELOG.md (the last release); before the first release (no stamped headings yet), it MUST equalVERSIONwith-devstripped. A stale pin fails CI here, not a consumer's release — and the self-bypass means the pin is never load-bearing for this repo's own releases, only for consumers'..ceremony-src/sits untracked in the consumer's workspace: the bump step must nevergit add -A— it adds exactly the version file(s). Write that comment where the temptation lives.Job 1 — the merge door (
if: github.ref == 'refs/heads/main')Steps, in order (each
if:-gated on the decide output where the sources gate):verviaversion_read;BASE_SHA=github.event.before, falling back torev-parse "$GITHUB_SHA^1"when all-zeros (branch-create push — use cast'scase "$BASE_SHA" in *[!0]*)test);base_ver= version at BASE_SHA (file:git show "$BASE_SHA:VERSION"; package-json:git showto$RUNNER_TEMPthenversion_readagainst it);releasedviagh release view "$ver";labeledviagh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"with the sources' exact jq (merged PRs only,releasein label names) — a push event carries no PR payload, and the PR lives on a fork, hence the API. Skip the API calls in states that don't need them (decide tolerates empty — #8).lib/decide.sh→ceremony=yes|nointo$GITHUB_OUTPUT.no→ job ends green (the notices already printed). Refusal → red, nothing created.changelog_section(#4) →$RUNNER_TEMP/notes.md; empty → refuse ("the ceremony PR must stamp it; refusing to publish an empty release").catthe notes into the log.git ls-remote --exit-code origin "refs/tags/$ver") or the release exists. This is what makes re-runs refuse loudly instead of clobbering, and what catches a manual tag racing the merge.gh api repos/…/git/refs -f ref="refs/tags/$ver" -f sha="$GITHUB_SHA"). Same job as publish, on purpose — aGITHUB_TOKENtag triggers nothing, so this job is the only chance to publish and the tag door cannot double-fire (the sources' central comment; keep it).gh release create "$ver" --verify-tag --title "$ver" --notes-file …plus every file in$RELEASE_ASSETS_DIR(glob only if the dir is non-empty;--verify-tagstays).-dev—version_next_dev+version_write(#3), commit asgithub-actions[bot], messagechore: bump main to <next> — a dev install must not impersonate <ver>,git fetch origin main && git checkout -B main origin/mainfirst (main may have moved; release+1 lands on the newer head — intended, per cast's comment). Direct push; on refusal (branch protection) push achore/bump-<next>branch and open a PR--label release, loudly. Never leave main armed to impersonate the release.Job 2 — the tag door (
if: startsWith(github.ref, 'refs/tags/'))The manual fallback and backfill: checkout (consumer at the tag +
.ceremony-src), assert$GITHUB_REF_NAMEequals the tree's own version (mismatch → the sources' teaching refusal: "a release is a PR, then a tag; the tag goes on its MERGE commit — delete this tag and re-tag"), notes, artifact hook,gh release create --verify-tag. No bump on this door (cast's precedent: the fallback does not rewrite main). No decide, no label check — the tag is the operator's explicit act.The artifact hook (#1 D4)
gh release create.if: hashFiles('.github/actions/release-artifact/action.yml') != ''→ stepuses: ./.github/actions/release-artifact(resolves in the consumer checkout; legal in a called workflow because the action is on disk)with: version: <ver>; envRELEASE_ASSETS_DIR(workflowmkdir -ps it under$RUNNER_TEMPfirst).$RELEASE_ASSETS_DIR; exit non-zero to abort the release. A failed hook leaves the tag created but no release published — document the recovery plainly: fix the cause, then push the same tag again is impossible (it exists), so the backfill IS the tag door semantics: delete and re-push the tag, or rungh release createby hand from a fixed tree. The merge door's nothing-exists assert will refuse a re-run, by design.What cannot be contract-tested, and what we do instead
The decide table, notes, version and bump logic are all tested via #3/#4/#8. The YAML itself is covered by actionlint plus one honest gap: the live doors. That gap is closed by #11 (ceremony's own 0.1.0 release runs this exact workflow via a local-path call) and by the #13 pilot's rehearsal. State the gap in the workflow header — do not pretend the YAML is tested.
Acceptance criteria
test/self-ref.test.sh+ CI step enforce the pin rules.workflow_dispatch, dry wiring only) proves the workflow parses and the checkouts + fact-gathering steps run against a fixture — the doors themselves are proven in #11.Triage:
blocked→ready. All three named blockers have landed on main — #3 via PR #28 (3d252d2,lib/version.sh), #4 via PR #29 (366d08e,lib/changelog.sh), #8 via PR #31 (0699bed,lib/decide.sh). #5 (PR #33,dde6b25) also just closed, so the composite-action pattern this workflow's guards ride on is established. Free for a builder to claim.Claiming — starting on the reusable release workflow now. Draft PR to follow shortly on
build/9-release-workflow.