feat: dogfood bootstrap — ceremony adopts its own ceremony (#11)

VERSION at 0.1.0-dev, the armed CHANGELOG.md, drills/README.md (what a
drill means here), the self-callers (self-release.yml, self-labels.yml —
local uses:, the one form consumers must never copy), .github/labeler.yml
for the scope job, and the three self-guards in CI. labels.yml gains the
self-consumption bypass (release.yml's twin) so the label bootstrap can run
before any release tag exists; action-exercise's armed/drill-recorded
scratch steps move to self-guards per their own eviction note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-bot-andresmgsl 2026-07-22 22:19:50 +00:00
parent 91863259f9
commit 9782687bd7
8 changed files with 217 additions and 26 deletions

55
.github/labeler.yml vendored Normal file
View file

@ -0,0 +1,55 @@
# Path → scope:* mapping for the labels workflow's scope job
# (actions/labeler@v5; additive only — the reusable workflow keeps
# sync-labels off, so a hand-applied scope survives the machine). The scope
# taxonomy itself lives in .github/labels.conf; LABELS.md carries the table
# these globs implement. Scopes locate, they do not alert — a path that maps
# to nothing is fine (labeler is advisory), so these rows chase the big
# surfaces, not every file.
scope:release-flow:
- changed-files:
- any-glob-to-any-file:
- .github/workflows/release.yml
- .github/workflows/self-release.yml
- .github/workflows/release-exercise.yml
- .github/scripts/self-ref-check.sh
- lib/**
- bin/**
- VERSION
- CHANGELOG.md
- drills/**
- test/decide.test.sh
- test/facts.test.sh
- test/release-chain.test.sh
- test/version.test.sh
- test/changelog.test.sh
- test/self-ref.test.sh
scope:guards:
- changed-files:
- any-glob-to-any-file:
- actions/changelog-armed/**
- actions/changelog-monotonic/**
- actions/drill-recorded/**
- test/changelog-armed.test.sh
- test/changelog-monotonic.test.sh
- test/drill-recorded.test.sh
scope:labels:
- changed-files:
- any-glob-to-any-file:
- .github/workflows/labels.yml
- .github/workflows/self-labels.yml
- .github/labeler.yml
- .github/labels.conf
- actions/labels-reconcile/**
- LABELS.md
- test/labels.test.sh
- test/labels-reconcile.test.sh
scope:docs:
- changed-files:
- any-glob-to-any-file:
- README
- docs/**
- AGENTS.md
- BUILDER.md
- REVIEWER.md
- TRIAGE.md
- CONTRIBUTING.md

View file

@ -51,33 +51,42 @@ jobs:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
uses: ./.github/workflows/release-exercise.yml uses: ./.github/workflows/release-exercise.yml
# Exercises the composite actions the way a consumer does — action.yml # The self-guards (issue #11): this repo eats exactly what it serves. The
# resolving, $GITHUB_ACTION_PATH, the relative lib sourcing — which the # three guard actions run against the REAL tree — VERSION, CHANGELOG.md,
# test suite, driving the scripts directly, cannot prove (issue #5's # drills/ — through the same `uses:` steps every consumer's CI carries.
# acceptance criterion). # These steps are also the composite-action wiring proof (issue #5's
# acceptance criterion: action.yml resolving, $GITHUB_ACTION_PATH, the
# relative lib sourcing) that action-exercise carried with scratch files
# while this repo had no tree of its own to guard; the armed and
# drill-recorded scratch steps moved here per the armed step's own
# eviction note — the file backend hardcodes the VERSION name, so a
# scratch write would SHADOW the real file, not sit beside it.
self-guards:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The monotonic guard compares HEAD against the merge base; a
# shallow checkout cannot resolve it, and in CI that is a hard
# failure, not a skip (the action's description).
fetch-depth: 0
- uses: ./actions/changelog-armed
- uses: ./actions/changelog-monotonic
- uses: ./actions/drill-recorded
# Exercises changelog-monotonic the way a consumer does, against a
# CONSTRUCTED history. The self-guards job above runs the same action on
# the real tree, but there its containment half is only as interesting as
# the PR's own diff; this job commits a known base and an insert-above
# edit on top, so a real, non-vacuous containment run is standing
# evidence on every PR. (Armed and drill-recorded moved to self-guards —
# the real tree now exercises them; monotonic stays because it reads no
# version source, so it is immune to the VERSION-shadowing problem that
# evicted the other two.)
action-exercise: action-exercise:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Construct an armed scratch tree in the workspace
# This repo carries no VERSION or CHANGELOG.md of its own (until
# #11 dogfoods); the guard reads the workspace, so give it a
# transient armed tree there. The changelog is scratch-named so a
# future real CHANGELOG.md is never shadowed; VERSION cannot be —
# the file backend hardcodes it — so if #11 ever adds a real one,
# this write must go.
run: |
printf '0.0.1-dev\n' > VERSION
printf '# Changelog\n\n## Unreleased\n\n- Scratch entry.\n' > CHANGELOG.scratch.md
- uses: ./actions/changelog-armed
with:
changelog: CHANGELOG.scratch.md
# The same scratch tree exercises drill-recorded: 0.0.1-dev is a
# development tree, so the guard proves its wiring (action.yml,
# $GITHUB_ACTION_PATH, the lib sourcing) through the nothing-to-assert
# path — the state every consumer PR is in. The bare path is the test
# suite's job.
- uses: ./actions/drill-recorded
- name: Construct a scratch history for the monotonic guard - name: Construct a scratch history for the monotonic guard
# The monotonic guard's input is a DIFF, so its exercise needs # The monotonic guard's input is a DIFF, so its exercise needs
# history, not just a file: commit a scratch changelog, mark that # history, not just a file: commit a scratch changelog, mark that
@ -86,9 +95,9 @@ jobs:
# base ref is the in-job branch, passed explicitly, because this # base ref is the in-job branch, passed explicitly, because this
# job's shallow PR checkout carries no origin/main for the input's # job's shallow PR checkout carries no origin/main for the input's
# default to resolve (consumers get that via fetch-depth: 0, per # default to resolve (consumers get that via fetch-depth: 0, per
# the action's description). Scratch-named file for the same # the action's description). Scratch-named file so the real
# shadowing reason as above; the commits live only in this job's # CHANGELOG.md is never shadowed; the commits live only in this
# checkout and are never pushed. # job's checkout and are never pushed.
run: | run: |
git config user.name ceremony-ci git config user.name ceremony-ci
git config user.email ceremony-ci@users.noreply.github.com git config user.email ceremony-ci@users.noreply.github.com

View file

@ -63,14 +63,35 @@ jobs:
repository: ${{ github.repository }} repository: ${{ github.repository }}
ref: ${{ github.event.repository.default_branch }} ref: ${{ github.event.repository.default_branch }}
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# The self-consumption bypass — release.yml's twin, and load-bearing
# for the same reason (#11): ceremony's own labels bootstrap must
# run BEFORE any release tag exists for this checkout to fetch — the
# release label the merge door reads is created by that dispatch, so
# without the bypass the first release deadlocks on its own pin. The
# base-branch checkout above already IS ceremony on the dogfood
# path.
if: github.repository != 'heavy-duty/ceremony'
with: with:
repository: heavy-duty/ceremony repository: heavy-duty/ceremony
ref: ${{ env.CEREMONY_SELF_REF }} ref: ${{ env.CEREMONY_SELF_REF }}
path: .ceremony-src path: .ceremony-src
# Two steps, mutually exclusive `if:`s, because a `uses:` path must be
# a literal — the same fork release.yml's CEREMONY_DIR env line
# papers over for `run:` steps, which composite `uses:` has no
# equivalent of.
- name: reconcile state + stale - name: reconcile state + stale
if: github.repository != 'heavy-duty/ceremony'
uses: ./.ceremony-src/actions/labels-reconcile uses: ./.ceremony-src/actions/labels-reconcile
with: with:
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }} bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
- name: reconcile state + stale (dogfood — the workspace IS ceremony)
if: github.repository == 'heavy-duty/ceremony'
uses: ./actions/labels-reconcile
with:
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}

18
.github/workflows/self-labels.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: labels
# Ceremony's own caller for the labels automation — the dogfood of issue
# #11, wearing the same local-`uses:` deviation as self-release.yml and the
# same warning: consumers must NEVER copy the local form (it rides main,
# unpinned — correct only for the repo that IS the source). Consumers write:
# uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
on:
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo
pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
labels:
uses: ./.github/workflows/labels.yml

27
.github/workflows/self-release.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: release
# Ceremony's own caller — the dogfood of issue #11. This is the consumer
# stub from docs/CONSUMERS.md with ONE deviation, and consumers must NEVER
# copy it: `uses:` below is a LOCAL path, so every run executes THIS tree's
# release.yml. No pin is the point here — ceremony's own release cannot
# check out a tag that the very run creates (#9's self-consumption bypass
# is the same fact one layer down) — and would be a bug anywhere else: a
# consumer without a pin rides main and eats every unreleased change.
# Consumers write:
# uses: heavy-duty/ceremony/.github/workflows/release.yml@<pinned-tag>
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: ./.github/workflows/release.yml
with:
version-source: file

17
CHANGELOG.md Normal file
View file

@ -0,0 +1,17 @@
# Changelog
The curated history of the ceremony itself. Each release's section is
published verbatim as that release's body (lib/changelog.sh extracts it),
so entries say what changed, cite the issue, and stop.
## Unreleased
- `lib/version.sh` — one version abstraction, `file` and `package-json` backends (#3).
- `lib/changelog.sh` + `bin/changelog-section` — the one canonical changelog-section extractor (#4).
- `actions/changelog-armed` — the version-keyed arming guard (#5).
- `actions/changelog-monotonic` — shipped release headings are append-only: no deletion, no duplication (#6).
- `actions/drill-recorded` — a release tree must carry its drill record (#7).
- `lib/decide.sh` — the merge door's five-state decision, pure and exhaustively tested (#8).
- `.github/workflows/release.yml` + `lib/facts.sh` — the reusable two-door release workflow (#9).
- `.github/workflows/labels.yml` + `actions/labels-reconcile` — label taxonomy bootstrap and PR-state reconciliation (#10).
- Ceremony adopts its own ceremony: `VERSION`, this changelog, the drill doctrine, the self-callers, and the self-guards in CI (#11).

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0-dev

43
drills/README.md Normal file
View file

@ -0,0 +1,43 @@
# Drills
What a drill means in this repo: an **end-to-end rehearsal of both doors of
the release workflow on a disposable repo**. The contract suite proves every
decision offline — facts → decide → notes against fixtures, the merge door's
step sequence replayed in release-exercise.yml — but the doors themselves
only ever run live: gating on a real push event, the tag create, the
publish, the `-dev` re-arm (release.yml's "what is honestly untested"). The
drill is where they run live *before* a version rests on them.
## The rehearsal
1. Create a scratch **private** repo. It is disposable by design — it gets
deleted at the end.
2. Install the docs/CONSUMERS.md caller stubs, pinned to the release
candidate ref. A branch ref works: refs are static identifiers — the
family's own drill doctrine.
3. Give it a fixture `VERSION` / `CHANGELOG.md` / `drills/` in the armed
state (`X.Y.Z-dev`, `## Unreleased` on top).
4. Exercise both doors, one probe at a time:
1. a merge-door ceremony publishes exactly one release and re-arms main
to `-dev`;
2. a mislabeled ordinary PR is a green NOTICE no-op;
3. a bare-version PR without the `release` label refuses;
4. a re-run of the completed ceremony refuses;
5. a tag-door release from a manual tag;
6. a mismatched tag refuses.
Every refusal must refuse **creating nothing** — a probe that leaves a
tag or a release behind on a refusal path is a failed probe.
## The record
One file per version, `drills/X.Y.Z.md` — the shape the siblings use: what
was run, where, the result of each probe, failures written down plainly. The
record is the evidence; the scratch repo is the evidence's scaffolding and
is deleted afterwards.
`actions/drill-recorded` refuses any bare-version tree whose record is
missing or blank. A waived drill is still a record: the file says WAIVED and
why — a maintainer's call, visible and reviewable in the release PR's diff,
never a silent skip.