ceremony/docs/CONSUMERS.md
claude-bot-andresmgsl 77f195a6ae ci: docs-sync-exercise job + CONSUMERS.md doctrine-mirror section
The fixture consumer is the workspace root (a uses: step cannot cd);
ceremony rides a subdirectory checkout serving as both the local action
ref and the --source override. CONSUMERS.md gains the wiring, bootstrap,
and pin-bump procedure (#12's section, amended in the same cycle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:42:01 +00:00

6.7 KiB

Consumer setup

Release workflow

The reusable release workflow implements both doors of the ceremony — the merge door (merging the release-labeled ceremony PR ships it) and the tag door (a bare X.Y.Z tag push as the manual fallback and backfill). The design essay lives in the workflow's own header comment; the doctrine in issue #1.

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

version-source is the only input: file (a VERSION file — box, rig, incubator) or package-json (the version field, lockfile kept in sync on the post-release bump — cast). Everything else a repo might vary is a change to the ceremony itself, made in this repo, once.

Keep the merge door on push to main — never pull_request: a pull_request run from a public fork gets a read-only GITHUB_TOKEN that permissions: cannot raise (box#97), and every ceremony PR in this org is cross-repo from a bot fork.

Bootstrap the version at X.Y.Z-dev, not bare: a first version that never carried -dev hits the decide table's refuse row and has to ship by the tag door instead (the known first-release edge, cast#111).

The artifact hook

If the repository contains .github/actions/release-artifact/action.yml, both doors invoke it — after the tag exists, before gh release create — with the release version as input and RELEASE_ASSETS_DIR exported. Contract for hook authors:

  • Drop finished files into $RELEASE_ASSETS_DIR; every file there is uploaded as a release asset.
  • Exit non-zero to abort the release.
  • The hook owns its own toolchain (checkout is done; install node, docker, whatever it needs, itself).

A failed hook leaves the tag created but no release published. Recovery is the tag door's semantics: fix the cause, then delete and re-push the same tag (the tag door publishes for it), or run gh release create by hand from a fixed tree. The merge door's nothing-exists assert will refuse a re-run of the completed merge, by design.

No hook → no assets: for a pure-bash tree, GitHub's source tarball for the tag IS the package.

Labels automation

The reusable labels workflow owns two independent jobs: additive path-based scope:* labels and reconciliation of PR state, blockers, handoff, and stale status. The consumer keeps its path mapping in .github/labeler.yml and its review panel plus scope taxonomy in .github/labels.conf.

The complete caller is:

name: labels
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: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>

pull_request_target is intentional: fork PRs need the base repository's token to write labels. The reusable workflow executes no PR code. It checks out only the consumer's base branch and the pinned ceremony implementation.

.github/labels.conf has one mandatory panel setting followed by zero or more scope rows:

panel=claude-bot example-codex-bot example-grok-bot
scope:cli|C5DEF5|The command-line surface
scope:docs|C5DEF5|Documentation

The panel is whitespace-separated. Label rows use exactly name|color|description; blank lines are ignored and extra pipes are refused. Core state, blocker, work-queue, and release labels come from ceremony. Scope rows remain consumer-owned because paths and surfaces differ by repository.

After adding the caller and configuration, run workflow_dispatch once to bootstrap labels on a fresh repository. Scheduled and PR-triggered runs only reconcile; they do not repeatedly upsert the taxonomy.

Doctrine mirror

Machinery is consumed by reference — GitHub fetches the workflows and actions above from the pin at run time — but documents have no runtime: an agent reads the working tree it stands in. So the agent-facing doc set (ceremony's docs/VENDORED.txt: AGENTS.md, TRIAGE.md, BUILDER.md, REVIEWER.md, LABELS.md) is vendored into each consumer at .ceremony/, byte-identical to ceremony at the pin, plus a generated .ceremony/README.md marking the directory machine-managed. actions/docs-sync owns the copy: --fix writes it (and deletes what the manifest dropped — mirror means mirror), --check re-diffs it in CI on every PR, so a hand edit or a stale pin goes red instead of quietly governing.

The consumer's ci.yml gains the guard alongside the others:

      - uses: actions/checkout@v4
      - uses: heavy-duty/ceremony/actions/docs-sync@<pinned-tag>

mode defaults to check. There is no ref input: the action reads the pin from the consumer's own .github/workflows/release.yml — the same single uses: …/release.yml@<ref> line that pins the machinery, so one pin governs machinery and doctrine alike, and a second pin cannot fall out of sync.

Bootstrap on adoption: add the release and labels callers first (the pin must exist — the mirror is verified against it), then run --fix once from the repo root and commit .ceremony/ together with the callers:

curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony/<pinned-tag>/actions/docs-sync/docs-sync.sh" \
  | bash -s -- --fix

If the repo has no root AGENTS.md, --fix also scaffolds the thin stub that routes agents to .ceremony/AGENTS.md — created once, never overwritten; it is per-repo content the moment you edit it, so --check asserts only that it exists.

The pin-bump procedure: bumping the pin is one PR — the pin line change plus the re-synced mirror (run --fix locally, or let the red --check on the bump PR say what is stale). The guard makes a half-done bump — pin without mirror, mirror without pin — unmergeable, which is how a process change rolls out: deliberately, per repo, reviewed.