diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..ea86a49 --- /dev/null +++ b/.github/labeler.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a765652..1d90c13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,33 +51,42 @@ jobs: if: github.event_name == 'pull_request' uses: ./.github/workflows/release-exercise.yml - # Exercises the composite actions the way a consumer does — action.yml - # resolving, $GITHUB_ACTION_PATH, the relative lib sourcing — which the - # test suite, driving the scripts directly, cannot prove (issue #5's - # acceptance criterion). + # The self-guards (issue #11): this repo eats exactly what it serves. The + # three guard actions run against the REAL tree — VERSION, CHANGELOG.md, + # drills/ — through the same `uses:` steps every consumer's CI carries. + # 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: runs-on: ubuntu-latest steps: - 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 # The monotonic guard's input is a DIFF, so its exercise needs # 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 # job's shallow PR checkout carries no origin/main for the input's # default to resolve (consumers get that via fetch-depth: 0, per - # the action's description). Scratch-named file for the same - # shadowing reason as above; the commits live only in this job's - # checkout and are never pushed. + # the action's description). Scratch-named file so the real + # CHANGELOG.md is never shadowed; the commits live only in this + # job's checkout and are never pushed. run: | git config user.name ceremony-ci git config user.email ceremony-ci@users.noreply.github.com diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index e682fdf..2e2363c 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -63,14 +63,35 @@ jobs: repository: ${{ github.repository }} ref: ${{ github.event.repository.default_branch }} - 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: repository: heavy-duty/ceremony ref: ${{ env.CEREMONY_SELF_REF }} 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 + if: github.repository != 'heavy-duty/ceremony' uses: ./.ceremony-src/actions/labels-reconcile with: bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }} env: GH_TOKEN: ${{ github.token }} 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 }} diff --git a/.github/workflows/self-labels.yml b/.github/workflows/self-labels.yml new file mode 100644 index 0000000..b6d17e8 --- /dev/null +++ b/.github/workflows/self-labels.yml @@ -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@ +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 diff --git a/.github/workflows/self-release.yml b/.github/workflows/self-release.yml new file mode 100644 index 0000000..b9cf91c --- /dev/null +++ b/.github/workflows/self-release.yml @@ -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@ +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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..08cdae3 --- /dev/null +++ b/CHANGELOG.md @@ -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). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0d4d124 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0-dev diff --git a/drills/README.md b/drills/README.md new file mode 100644 index 0000000..6382139 --- /dev/null +++ b/drills/README.md @@ -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.