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:
parent
91863259f9
commit
9782687bd7
8 changed files with 217 additions and 26 deletions
55
.github/labeler.yml
vendored
Normal file
55
.github/labeler.yml
vendored
Normal 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
|
||||||
61
.github/workflows/ci.yml
vendored
61
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
21
.github/workflows/labels.yml
vendored
21
.github/workflows/labels.yml
vendored
|
|
@ -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
18
.github/workflows/self-labels.yml
vendored
Normal 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
27
.github/workflows/self-release.yml
vendored
Normal 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
17
CHANGELOG.md
Normal 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
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
0.1.0-dev
|
||||||
43
drills/README.md
Normal file
43
drills/README.md
Normal 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.
|
||||||
Loading…
Reference in a new issue