ceremony/.github/workflows/release.yml
codex-bot-andresmgsl 734676ab7d
Some checks failed
CI / test (pull_request) Failing after 41s
CI / release-exercise (pull_request) Successful in 13s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
fix: guard tag release publication
2026-08-30 09:27:08 +00:00

401 lines
20 KiB
YAML

name: release
# THE reusable release workflow — two doors into one act, implemented once
# for the whole family (issue #9; lineage box#83/#96 · rig#32/#47 ·
# cast#96/#111 — this essay is condensed from those three sources, and every
# rule in it was bought with an incident).
#
# ## The two doors
#
# * The MERGE door: a release is a PR — `release: X.Y.Z`, carrying the
# hand-set `release` label, bumping the version from X.Y.Z-dev to bare
# X.Y.Z and stamping the changelog — and MERGING it is the ship decision.
# The label is the intent, the version transition is the interlock: the
# 5-state table (lib/decide.sh, issue #8) tells a ceremony apart from
# release-flow work under the same label, turns every legitimate
# non-ceremony into a green NOTICE no-op, and refuses every half-ceremony
# loudly, creating nothing. The job then tags the merge commit via the API
# and publishes in the SAME job, on purpose: a GITHUB_TOKEN-created tag
# fires no workflows (GitHub's anti-recursion), so that tag can never
# re-enter the tag door below and double-publish — this job is the
# release's only chance to publish, and the nothing-exists assert covers a
# manual tag racing the merge. Afterwards the job re-arms main itself:
# bump to X.Y.(Z+1)-dev, pushed directly with the job's token (fires
# nothing), falling back to a labeled PR if branch protection refuses —
# loudly, never leaving main armed to impersonate the release.
#
# * The TAG door: a bare X.Y.Z tag push (no 'v' prefix — box's 0.6.0 set
# the scheme) is the documented manual fallback and backfill. The tag must
# name the tree's own version; a mismatch fails loudly and creates
# nothing. No decide and no label check — the tag is the operator's
# explicit act — and no bump: the fallback does not rewrite main (cast's
# precedent).
#
# Both doors publish the release body from the version's own CHANGELOG.md
# section (lib/changelog.sh — the one canonical extractor): the curated
# prose, never the generated PR list. Assets come only from the consumer's
# optional artifact hook (below); with no hook, GitHub's source tarball for
# the tag IS the package (box, rig).
#
# ## The caller contract
#
# This is the consumer's ENTIRE release.yml (also in docs/CONSUMERS.md).
# Triggers and permissions MUST live in the caller — a called workflow
# cannot define them:
#
# name: release
# 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: the tag fallback had stopped triggering).
# push:
# tags: ["**"] # every tag — a wrong tag must FAIL the assert
# # loudly below, 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 # the label read; the bump-fallback PR
# 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
#
# The called workflow runs in the CALLER's context: the caller's event
# payload (github.ref / github.sha / github.event.before), the caller's
# GITHUB_TOKEN, the caller's permission grant. The doors split on the pushed
# ref exactly as the sources did, and the anti-recursion property is
# unchanged: tags and pushes created with GITHUB_TOKEN fire no workflows.
# The merge door MUST keep riding `push` to main, never `pull_request`: a
# pull_request run from a public FORK gets a READ-ONLY token that
# `permissions:` cannot raise (box#97) — and every ceremony PR in this org
# is cross-repo from a bot fork — so the asserts would pass and the tag
# create would 403, red on main, every release.
#
# ## The self-ref pin (#1 D3)
#
# A called workflow file arrives alone; it does not bring its repository.
# So each door checks out heavy-duty/ceremony at the literal pinned
# CEREMONY_SELF_REF below (into .ceremony-src, inside the workspace) to get
# lib/ at run time — except on the dogfood path: when the caller IS
# heavy-duty/ceremony, the workspace already holds this repo at the merge
# commit, libs included, and fetching tag X.Y.Z from the very run that
# creates it would deadlock (#11). Every script call goes through
# CEREMONY_DIR, so the bypass is one `if:` plus one env line.
#
# ## The artifact hook (#1 D4)
#
# If the consumer carries .github/actions/release-artifact/action.yml, both
# doors invoke it — after the tag exists, before the publish — with
# `version` as input and RELEASE_ASSETS_DIR exported; every file the hook
# drops there is uploaded as a release asset. Exit non-zero to abort the
# release. No hook → no assets.
#
# ## What is honestly untested
#
# Every decision this workflow takes lives in a tested script: version state
# (lib/version.sh), the 5-state verdict (lib/decide.sh), fact gathering
# (lib/facts.sh), notes extraction (lib/changelog.sh), and the facts →
# decide → notes chain is rehearsed end-to-end against fixtures
# (test/release-chain.test.sh). The merge door's early step sequence — both
# checkout shapes, both branches of the self-consumption bypass, the
# CEREMONY_DIR wiring, and the facts → decide → notes steps with their real
# $GITHUB_OUTPUT plumbing — is executed against a fixture by
# release-exercise.yml's step-replay job, on every PR via ci.yml. What
# remains, honestly untested until it runs live: the doors themselves —
# door gating on a real push event, tag create, publish, and bump. 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.
on:
workflow_call:
inputs:
version-source:
description: >-
Where the tree's version lives: "file" (a VERSION file — box, rig,
incubator) or "package-json" (the version field, lockfile kept in
sync on bump — cast)
type: string
required: false
default: file
env:
# A called workflow arrives without its repository. This literal pin is
# stamped by ceremony's own release PR to the version being released —
# one more line in the same ritual as stamping the changelog (#11) — and
# .github/scripts/self-ref-check.sh fails ceremony's own CI when it is
# stale: a stale pin dies here, not in a consumer's release. checkout's
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
# shared logic arrives as script files via checkout, not as inner `uses:`
# references.
CEREMONY_SELF_REF: "0.6.3"
VERSION_SOURCE: ${{ inputs.version-source }}
jobs:
release-on-merge:
# The merge door. Gated on the push EVENT as well as the ref (rig's
# form): a workflow_dispatch of a caller sitting on main — this repo's
# own release-exercise.yml — must stay dry wiring, never a live door.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The pushed head is what ships; its first parent (fetch-depth: 2)
# is main the instant before the PR landed, which the version
# transition is measured against (lib/facts.sh adds the
# belt-and-braces fetch of event.before — cast's precedent).
ref: ${{ github.sha }}
fetch-depth: 2
- uses: actions/checkout@v4
# The self-consumption bypass (load-bearing — without it, ceremony's
# own release deadlocks): on the dogfood path the workspace IS this
# repo at the merge commit, libs included, so nothing is fetched —
# the 0.1.0 run would otherwise check out tag 0.1.0, which is
# created only AFTER that very run succeeds (#11).
if: github.repository != 'heavy-duty/ceremony'
with:
repository: heavy-duty/ceremony
ref: ${{ env.CEREMONY_SELF_REF }}
path: .ceremony-src
- name: wire CEREMONY_DIR and the assets dir
run: |
if [ "$GITHUB_REPOSITORY" = "heavy-duty/ceremony" ]; then
echo "CEREMONY_DIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
else
echo "CEREMONY_DIR=$GITHUB_WORKSPACE/.ceremony-src" >> "$GITHUB_ENV"
fi
mkdir -p "$RUNNER_TEMP/release-assets"
echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV"
- name: gather the facts — version, base version, released, labeled
id: facts
env:
GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ github.sha }}
EVENT_BEFORE: ${{ github.event.before }}
# Facts on stdout in $GITHUB_OUTPUT form, diagnostics on stderr;
# the API facts are gathered only in the states that consult them.
run: bash "$CEREMONY_DIR/lib/facts.sh" >> "$GITHUB_OUTPUT"
- name: 'decide: ceremony, or release-flow work under the label?'
id: decide
env:
VER: ${{ steps.facts.outputs.ver }}
BASE_VER: ${{ steps.facts.outputs.base_ver }}
RELEASED: ${{ steps.facts.outputs.released }}
LABELED: ${{ steps.facts.outputs.labeled }}
# The 5-state table lives in lib/decide.sh (issue #8) — pure, so it
# is contract-tested offline. `ceremony=no` ends this job green (the
# NOTICE already printed); a refusal is red with nothing created.
run: |
out="$(bash "$CEREMONY_DIR/lib/decide.sh")"
printf '%s\n' "$out"
printf '%s\n' "$out" | grep '^ceremony=' >> "$GITHUB_OUTPUT"
- name: release notes — the version's own changelog section
if: steps.decide.outputs.ceremony == 'yes'
env:
VER: ${{ steps.facts.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh"
if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then
echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
printf '%s\n' "$diagnosis" >&2
exit 1
fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
# What makes a re-run of a completed ceremony refuse instead of
# clobber, and what catches a manual tag racing the merge.
run: |
if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2
exit 1
fi
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! exists="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1
fi
if [ "$exists" = yes ]; then
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
exit 1
fi
- name: tag the merge commit — same job as the publish, on purpose
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
MERGE_SHA: ${{ github.sha }}
# A GITHUB_TOKEN-created tag triggers nothing (anti-recursion), so
# the tag door cannot double-fire off this tag — and this job is
# the only chance to publish (the sources' central comment).
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_tag_create "$VER" "$MERGE_SHA"
- name: artifact hook — the consumer's own release-artifact action
# Runs after the tag exists, before the publish (#1 D4). The local
# path resolves in the consumer checkout at the workspace root —
# legal in a called workflow because the action is on disk. Hook
# contract: drop finished files into $RELEASE_ASSETS_DIR; exit
# non-zero to abort the release (docs/CONSUMERS.md).
if: steps.decide.outputs.ceremony == 'yes' && hashFiles('.github/actions/release-artifact/action.yml') != ''
uses: ./.github/actions/release-artifact
with:
version: ${{ steps.facts.outputs.ver }}
- name: publish the release
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
run: |
assets=()
for f in "$RELEASE_ASSETS_DIR"/*; do
if [ -e "$f" ]; then assets+=("$f"); fi
done
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
# The post-release bump, folded into the release act (the sources'
# operator decision: a mechanical one-liner deserves no PR of its
# own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev
# refuses anything but bare X.Y.Z). A GITHUB_TOKEN push fires no
# workflows (anti-recursion), so the bump triggers neither this door
# nor a red run; should branch protection refuse the direct push, the
# step opens the bump PR itself and says so, loudly, instead of
# leaving main armed to impersonate the release.
- name: bump main to the next -dev — the release re-arms main itself
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/version.sh"
next="$(version_next_dev "$VER")"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# main may have moved since the merge; release+1 lands on the
# newer head — the intended arithmetic either way (cast's
# comment).
git fetch origin main
git checkout -B main origin/main
version_write "$VERSION_SOURCE" "$next"
# NEVER `git add -A` here: .ceremony-src sits UNTRACKED in this
# workspace on the consumer path, and -A would commit the whole
# ceremony checkout into the consumer's main. Exactly the files
# the bump wrote, nothing else.
case "$VERSION_SOURCE" in
file) git add VERSION ;;
package-json) git add package.json package-lock.json ;;
esac
git commit -m "chore: bump main to $next — a dev install must not impersonate $VER"
if ! git push origin main; then
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
git checkout -b "chore/bump-$next"
git push origin "chore/bump-$next"
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_pr_create "chore/bump-$next" main \
"chore: bump main to $next" \
"The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
release
fi
release-on-tag:
# The tag door — the manual fallback and backfill. The tag is the
# operator's explicit act: no decide, no label check — and no bump
# (cast's precedent: the fallback does not rewrite main). Event-gated
# like the merge door: dispatch runs stay dry.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
# The self-consumption bypass — see the merge door's twin step.
if: github.repository != 'heavy-duty/ceremony'
with:
repository: heavy-duty/ceremony
ref: ${{ env.CEREMONY_SELF_REF }}
path: .ceremony-src
- name: wire CEREMONY_DIR and the assets dir
run: |
if [ "$GITHUB_REPOSITORY" = "heavy-duty/ceremony" ]; then
echo "CEREMONY_DIR=$GITHUB_WORKSPACE" >> "$GITHUB_ENV"
else
echo "CEREMONY_DIR=$GITHUB_WORKSPACE/.ceremony-src" >> "$GITHUB_ENV"
fi
mkdir -p "$RUNNER_TEMP/release-assets"
echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV"
- name: the tag must name the tree's own version
id: assert
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/version.sh"
ver="$(version_read "$VERSION_SOURCE")"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
echo "tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing." >&2
echo "A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
exit 1
fi
echo "ver=$ver" >> "$GITHUB_OUTPUT"
- name: release notes — the version's own changelog section
env:
VER: ${{ steps.assert.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh"
if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then
echo "CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release" >&2
printf '%s\n' "$diagnosis" >&2
exit 1
fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: no published release may exist
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.assert.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! exists="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1
fi
if [ "$exists" = yes ]; then
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
exit 1
fi
- name: artifact hook — the consumer's own release-artifact action
# After the tag exists (it fired this door), before the publish —
# the same contract as the merge door's twin step.
if: hashFiles('.github/actions/release-artifact/action.yml') != ''
uses: ./.github/actions/release-artifact
with:
version: ${{ steps.assert.outputs.ver }}
- name: publish the release
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.assert.outputs.ver }}
run: |
assets=()
for f in "$RELEASE_ASSETS_DIR"/*; do
if [ -e "$f" ]; then assets+=("$f"); fi
done
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"