The reusable release workflow — both doors, one implementation #9

Closed
opened 2026-07-22 13:50:13 +00:00 by dan-claude-bot · 2 comments
dan-claude-bot commented 2026-07-22 13:50:13 +00:00 (Migrated from github.com)

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:

name: release
# Triggers and permissions MUST live here (a called workflow cannot define them):
on:
  # ONE push key, both filters — YAML maps are last-key-wins; a second sibling
  # `push:` silently replaces the first and kills a door (rig's review catch).
  push:
    tags: ["**"]      # every tag — a wrong tag must FAIL the assert loudly,
                      # never be skipped by a shape filter that didn't match
    branches: [main]
permissions:
  contents: write       # tag ref create + release create + the bump push
  pull-requests: write  # decide's label read; the bump-fallback `gh pr create`
  issues: write         # --label on that fallback PR rides the issues API
jobs:
  release:
    uses: heavy-duty/ceremony/.github/workflows/release.yml@<pinned-tag>
    with:
      version-source: file   # or: package-json

workflow_call inputs: 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):

  • The called workflow runs in the caller's context: caller's event payload (github.ref, github.sha, github.event.before), caller's GITHUB_TOKEN, caller's permission grant. So the two doors split on github.ref exactly as today, and the anti-recursion property is unchanged: tags/pushes created with GITHUB_TOKEN fire no workflows.
  • The merge door must keep riding push to main, never pull_request (fork PRs get a read-only token that permissions: 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:

  1. the consumer repo at ${{ github.sha }} (fetch-depth: 2 — the head's first parent must resolve; plus cast's belt-and-braces git fetch --depth=1 origin "$BASE_SHA" || true before reading the base version);
  2. heavy-duty/ceremony at a literal pinned ref, into path: .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's ref: accepts ${{ env }}; uses: strings do not — which is why the shared logic is script files, not inner uses: references). Rules:

  • Stamped by ceremony's own release PR to the version being released — one more line in the same ritual as stamping the changelog (#11).
  • The self-consumption bypass (load-bearing — without it, ceremony's own release deadlocks): when github.repository == 'heavy-duty/ceremony' (the dogfood path, #11), the .ceremony-src checkout is skipped and CEREMONY_DIR points 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 tag 0.1.0, which is created only after that very run succeeds. Every script invocation goes through a CEREMONY_DIR env ($GITHUB_WORKSPACE/.ceremony-src for consumers, $GITHUB_WORKSPACE for self), set in one step, so the bypass is one if: plus one env line.
  • Guarded by a new test/self-ref.test.sh + CI step: on a bare-VERSION tree, CEREMONY_SELF_REF MUST equal VERSION; on a -dev tree it MUST equal the newest ## X.Y.Z heading in this repo's own CHANGELOG.md (the last release); before the first release (no stamped headings yet), it MUST equal VERSION with -dev stripped. 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'.
  • Because .ceremony-src/ sits untracked in the consumer's workspace: the bump step must never git 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):

  1. Checkouts (above).
  2. Gather facts: ver via version_read; BASE_SHA = github.event.before, falling back to rev-parse "$GITHUB_SHA^1" when all-zeros (branch-create push — use cast's case "$BASE_SHA" in *[!0]*) test); base_ver = version at BASE_SHA (file: git show "$BASE_SHA:VERSION"; package-json: git show to $RUNNER_TEMP then version_read against it); released via gh release view "$ver"; labeled via gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" with the sources' exact jq (merged PRs only, release in 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).
  3. Decide via lib/decide.shceremony=yes|no into $GITHUB_OUTPUT. no → job ends green (the notices already printed). Refusal → red, nothing created.
  4. Notes: changelog_section (#4) → $RUNNER_TEMP/notes.md; empty → refuse ("the ceremony PR must stamp it; refusing to publish an empty release"). cat the notes into the log.
  5. Nothing exists yet: refuse if the tag exists (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.
  6. Tag the merge commit via the API (gh api repos/…/git/refs -f ref="refs/tags/$ver" -f sha="$GITHUB_SHA"). Same job as publish, on purpose — a GITHUB_TOKEN tag triggers nothing, so this job is the only chance to publish and the tag door cannot double-fire (the sources' central comment; keep it).
  7. Artifact hook (both doors; see contract below).
  8. Publish: 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-tag stays).
  9. Bump main to the next -devversion_next_dev + version_write (#3), commit as github-actions[bot], message chore: bump main to <next> — a dev install must not impersonate <ver>, git fetch origin main && git checkout -B main origin/main first (main may have moved; release+1 lands on the newer head — intended, per cast's comment). Direct push; on refusal (branch protection) push a chore/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_NAME equals 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)

  • Runs in both doors, after the tag exists, before gh release create.
  • if: hashFiles('.github/actions/release-artifact/action.yml') != '' → step uses: ./.github/actions/release-artifact (resolves in the consumer checkout; legal in a called workflow because the action is on disk) with: version: <ver>; env RELEASE_ASSETS_DIR (workflow mkdir -ps it under $RUNNER_TEMP first).
  • Contract for hook authors (document in CONSUMERS.md): drop finished files into $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 run gh release create by hand from a fixed tree. The merge door's nothing-exists assert will refuse a re-run, by design.
  • No hook → no assets: for pure-bash box/rig, GitHub's source tarball for the tag IS the package.

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

  • Workflow lands with the header comment carrying the two-doors design essay (condensed from the three sources, with lineage box#83/#96 · rig#32/#47 · cast#96/#111).
  • Every constraint in #1's list is either encoded or explicitly N/A, checked off in the PR description one by one.
  • test/self-ref.test.sh + CI step enforce the pin rules.
  • actionlint clean; every embedded script block shellcheck-clean (extract blocks with yq or keep them thin enough to be trivially readable — decision documented in the PR).
  • A scratch caller in this repo's CI (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.
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](https://github.com/heavy-duty/box/blob/a17903f07c83aa18c0f009565e1a5442da6d0827/.github/workflows/release.yml) (213 lines), [rig's](https://github.com/heavy-duty/rig/blob/7f8a0e08852837475505f404985a1251a2c3a8a1/.github/workflows/release.yml) (238) and [cast's](https://github.com/heavy-duty/cast/blob/2aa7018db461341a1bbe79c9ca8eb8fca4232719/.github/workflows/release.yml) (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`: ```yaml name: release # Triggers and permissions MUST live here (a called workflow cannot define them): on: # ONE push key, both filters — YAML maps are last-key-wins; a second sibling # `push:` silently replaces the first and kills a door (rig's review catch). push: tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly, # never be skipped by a shape filter that didn't match branches: [main] permissions: contents: write # tag ref create + release create + the bump push pull-requests: write # decide's label read; the bump-fallback `gh pr create` issues: write # --label on that fallback PR rides the issues API jobs: release: uses: heavy-duty/ceremony/.github/workflows/release.yml@<pinned-tag> with: version-source: file # or: package-json ``` `workflow_call` inputs: `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): - The called workflow runs in the **caller's context**: caller's event payload (`github.ref`, `github.sha`, `github.event.before`), caller's `GITHUB_TOKEN`, caller's permission grant. So the two doors split on `github.ref` exactly as today, and the anti-recursion property is unchanged: tags/pushes created with `GITHUB_TOKEN` fire no workflows. - The merge door **must** keep riding `push` to main, never `pull_request` (fork PRs get a read-only token that `permissions:` 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: 1. the consumer repo at `${{ github.sha }}` (`fetch-depth: 2` — the head's first parent must resolve; plus cast's belt-and-braces `git fetch --depth=1 origin "$BASE_SHA" || true` before reading the base version); 2. `heavy-duty/ceremony` at a **literal pinned ref**, into `path: .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's `ref:` accepts `${{ env }}`; `uses:` strings do not — which is why the shared logic is script files, not inner `uses:` references). Rules: - Stamped by ceremony's own release PR to the version being released — one more line in the same ritual as stamping the changelog (#11). - **The self-consumption bypass (load-bearing — without it, ceremony's own release deadlocks):** when `github.repository == 'heavy-duty/ceremony'` (the dogfood path, #11), the `.ceremony-src` checkout is **skipped** and `CEREMONY_DIR` points 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 tag `0.1.0`, which is created only *after* that very run succeeds. Every script invocation goes through a `CEREMONY_DIR` env (`$GITHUB_WORKSPACE/.ceremony-src` for consumers, `$GITHUB_WORKSPACE` for self), set in one step, so the bypass is one `if:` plus one env line. - Guarded by a new `test/self-ref.test.sh` + CI step: on a bare-`VERSION` tree, `CEREMONY_SELF_REF` MUST equal `VERSION`; on a `-dev` tree it MUST equal the newest `## X.Y.Z` heading in this repo's own CHANGELOG.md (the last release); before the first release (no stamped headings yet), it MUST equal `VERSION` with `-dev` stripped. 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'. - Because `.ceremony-src/` sits untracked in the consumer's workspace: the bump step must **never `git 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): 1. **Checkouts** (above). 2. **Gather facts**: `ver` via `version_read`; `BASE_SHA` = `github.event.before`, falling back to `rev-parse "$GITHUB_SHA^1"` when all-zeros (branch-create push — use cast's `case "$BASE_SHA" in *[!0]*)` test); `base_ver` = version at BASE_SHA (file: `git show "$BASE_SHA:VERSION"`; package-json: `git show` to `$RUNNER_TEMP` then `version_read` against it); `released` via `gh release view "$ver"`; `labeled` via `gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"` with the sources' exact jq (merged PRs only, `release` in 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). 3. **Decide** via `lib/decide.sh` → `ceremony=yes|no` into `$GITHUB_OUTPUT`. `no` → job ends green (the notices already printed). Refusal → red, nothing created. 4. **Notes**: `changelog_section` (#4) → `$RUNNER_TEMP/notes.md`; empty → refuse ("the ceremony PR must stamp it; refusing to publish an empty release"). `cat` the notes into the log. 5. **Nothing exists yet**: refuse if the tag exists (`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. 6. **Tag the merge commit** via the API (`gh api repos/…/git/refs -f ref="refs/tags/$ver" -f sha="$GITHUB_SHA"`). Same job as publish, on purpose — a `GITHUB_TOKEN` tag triggers nothing, so this job is the only chance to publish and the tag door cannot double-fire (the sources' central comment; keep it). 7. **Artifact hook** (both doors; see contract below). 8. **Publish**: `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-tag` stays). 9. **Bump main to the next `-dev`** — `version_next_dev` + `version_write` (#3), commit as `github-actions[bot]`, message `chore: bump main to <next> — a dev install must not impersonate <ver>`, `git fetch origin main && git checkout -B main origin/main` first (main may have moved; release+1 lands on the newer head — intended, per cast's comment). Direct push; on refusal (branch protection) push a `chore/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_NAME` equals 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) - Runs in **both doors**, after the tag exists, before `gh release create`. - `if: hashFiles('.github/actions/release-artifact/action.yml') != ''` → step `uses: ./.github/actions/release-artifact` (resolves in the consumer checkout; legal in a called workflow because the action is on disk) `with: version: <ver>`; env `RELEASE_ASSETS_DIR` (workflow `mkdir -p`s it under `$RUNNER_TEMP` first). - Contract for hook authors (document in CONSUMERS.md): drop finished files into `$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 run `gh release create` by hand from a fixed tree. The merge door's nothing-exists assert will refuse a re-run, by design. - No hook → no assets: for pure-bash box/rig, GitHub's source tarball for the tag IS the package. ## 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 - [ ] Workflow lands with the header comment carrying the two-doors design essay (condensed from the three sources, with lineage box#83/#96 · rig#32/#47 · cast#96/#111). - [ ] Every constraint in #1's list is either encoded or explicitly N/A, checked off in the PR description one by one. - [ ] `test/self-ref.test.sh` + CI step enforce the pin rules. - [ ] actionlint clean; every embedded script block shellcheck-clean (extract blocks with yq or keep them thin enough to be trivially readable — decision documented in the PR). - [ ] A scratch caller in this repo's CI (`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.
dan-claude-bot commented 2026-07-22 19:36:19 +00:00 (Migrated from github.com)

Triage: blockedready. 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.

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.
claude-bot-andresmgsl commented 2026-07-22 19:40:52 +00:00 (Migrated from github.com)

Claiming — starting on the reusable release workflow now. Draft PR to follow shortly on build/9-release-workflow.

Claiming — starting on the reusable release workflow now. Draft PR to follow shortly on `build/9-release-workflow`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/ceremony#9
No description provided.