diff --git a/.github/labeler.yml b/.github/labeler.yml index 0daec5f..e76585d 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,5 +1,5 @@ -# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope -# story (issues are hand-scoped at triage; paths only exist on PRs). Additive +# path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's +# scope story (issues are hand-scoped at triage; paths only exist on PRs). Additive # only: sync-labels stays off in labels.yml, so a hand-applied scope survives. "scope:capture": - changed-files: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6e028a..71a1c85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,59 +1,32 @@ # Contributing -How change lands in this repo. The short version: PRs are born as drafts, -three reviewer bots take the first rounds, a human takes the last word — and -labels tell you where everything is without opening anything. +This repo is governed by +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). **Agents: +read [`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first** — it routes you to +your role file (builder, reviewer, triage), vendored beside it, +byte-identical to ceremony at the pin named in +[`.github/workflows/release.yml`](.github/workflows/release.yml) and +guarded by the `docs-sync` step in CI. The review-round doctrine — drafts, +whole-round replies, verdicts, the handoff — lives there and in +[`.ceremony/LABELS.md`](.ceremony/LABELS.md); this file keeps only what is +genuinely cast's. -## The PR loop +## The PR loop, cast specifics 1. **Fork and branch.** Contributors work from forks; upstream branches are for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`). -2. **Open as a draft** while you build. Drafts are invisible to the reviewer - bots on purpose. -3. **When it's ready**: mark ready-for-review and request all three bots — - `claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`. - They poll roughly every 15 minutes. -4. **Rounds are answered whole.** Wait until all three have reviewed, then - answer the entire round in a **single reply**, push the fixes, and - re-request the bots that didn't approve. Prefer verification over - argument: a test settles what a comment thread can't. -5. **Reviews end in a verdict.** A reviewer — bot or human — either - **approves** or **requests changes**, never a bare comment. A - comment-only review is a non-verdict: it doesn't say whether the round - passed, and the state machine (and anyone scanning the board) has to - guess. The verdict carries *blockingness only*, the body carries the - feedback: non-blocking nits ride an **approval** and the author addresses - them at their discretion; anything blocking — including a question that - gates the verdict — is **request changes**, saying what unblocks it. The - reconciler treats a comment-only review as not-approved, so commenting - without a verdict only stalls the PR. The machine never reads review - bodies: when a comment-only reviewer's line is really an agreement, that - judgment belongs to the **author** — escalate by requesting the - maintainer's review (step 6), and the reconciler flips the label on that - request, because an explicit request is a fact it can trust. -6. **When the round passes, the author hands the PR to the maintainer** in - three acts, in this order: post the tagged round summary, request the - maintainer's review, then set `state:needs-human` yourself — removing the - state label it replaces. The review request is what *earns* the label, - provided the PR carries **no `blocker:*` label**. A blocker means the work - is still yours whatever the round said, so on a conflicted or red PR - neither the request nor your own label write will stick — the sweep takes - it straight back off. With three formal head-current approvals the labels - workflow requests the maintainer automatically; when part of the panel is - comment-only, reading their agreement is the author's judgment, so the - author makes the request. - - Writing the label by hand is an **optimistic write, not a transfer of - ownership**. The machine stays the authority — but because the workflow - wakes on `labeled`, the author's own write fires the sweep that validates - it, and a handoff that had not earned the label is corrected seconds later. - Forgetting the write is not a failure either; it only means the label waits - for the cron, which is the lag this replaced. -7. **Checks must be green**: `npm run check`, `npm run build`, and - `npm test` locally mirror what CI runs. -8. **Feature PRs land their changelog entry as part of the PR** (box's - convention): add it under `CHANGELOG.md`'s `## Unreleased` heading — - that section becomes the release notes verbatim when a release is cut. +2. **The review panel** (`.github/labels.conf`'s `panel=` line): + `claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl` — + the required verdicts for a PR are the panel minus its author. The + maintainer (`danmt`) takes the last word and merges. +3. **Checks must be green**: `npm run check`, `npm run build`, and + `npm test` locally mirror what CI runs, plus the shellcheck sweep + (`npm run check:shell`). The release guards (`changelog-armed`, + `changelog-monotonic`, `drill-recorded`, `docs-sync`) run as ceremony's + pinned actions. +4. **Feature PRs land their changelog entry as part of the PR**: add it + under `CHANGELOG.md`'s `## Unreleased` heading — that section becomes + the release notes verbatim when a release is cut. ## Changelog entries @@ -89,144 +62,46 @@ Not an entry — that is a PR body: ## Releasing -A release is a PR, and merging it IS the release -([#111](https://github.com/heavy-duty/cast/issues/111); box#96's design, -on box#83's shape): +A release is a PR, and merging it is the release. The ceremony — the two +doors, the decide table, the stamps, the post-release re-arm — is +heavy-duty/ceremony's machinery, consumed by reference: +[its README](https://github.com/heavy-duty/ceremony/blob/main/README.md) +is the doctrine, `.github/workflows/release.yml` here is the ≤20-line +caller pinning it (`version-source: package-json` — the version lives in +`package.json`, and the post-release bump keeps `package-lock.json` in +step), and the guards run in `ci.yml` from the same pin. Bare `X.Y.Z` +tags, no `v`. -1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s - `version` (and `package-lock.json`; `npm install --package-lock-only` - keeps them in step) and stamps `CHANGELOG.md`'s Unreleased section as - `## X.Y.Z — YYYY-MM-DD`. **Then re-arm: add a fresh, empty - `## Unreleased` immediately above the section you just stamped.** The - same PR, the same diff — stamping without re-arming leaves main with no - `## Unreleased`, and the next PR that was authored before the release - and merged after has its entry land *inside the shipped section*, which - git does cleanly, with no conflict to warn anyone - (heavy-duty/rig#66 — it happened there). `test/release.test.ts` keys - this to the version, and checks both halves of the stamp: - - while `package.json` is bare, the top section may be the stamp or the - re-armed `## Unreleased`, but a `## X.Y.Z` section for the version you - are shipping **must exist and extract non-empty** — a bump without a - stamp is red here rather than after the merge, in release.yml; - - the moment step 4's `-dev` bump lands, the top section must be - `## Unreleased` or CI is red. +What stays cast's beyond that pin: - The empty `## Unreleased` this step adds is deliberately tolerated: what - must extract non-empty is the section that SHIPS, not the top one. CI - green on it, same loop as any PR. -2. **Drill, and record it.** Before the PR can be handed over, run the full - real-hardware drill — two live Coolify instances, the whole A→B promotion: - team, apply, an idempotent diff, smoke, inventory, emit-draft, fleet, - destroy, and the read-only guard — and record it in a file named for the - version, one record per version: - - drills/X.Y.Z.md - - The name matches `package.json`'s `version` exactly, and the file must hold - at least one non-whitespace character. See - [drills/README.md](drills/README.md) for what a record contains. - - [.github/scripts/drill-recorded.sh](.github/scripts/drill-recorded.sh) - enforces this on every release PR (a `-dev` tree has no ship claim and - passes trivially). It is **not a thing a reviewer has to remember** — that - is precisely how every release in this family shipped without one until a - bot blocked on it. - - So the release flow is: **draft → ready → bot round → drill → - `state:needs-human` → maintainer merge (which IS the release).** - - **The three repos' drills are independent.** Run them in any order, on any - schedule, in separate sittings. They are not three phases of one script. - - What makes that safe is that every drill **pins the same fixed set of - candidate refs**, so each one exercises exactly the combination that will - ship rather than whatever `main` happens to be that afternoon. The run - drills **candidate refs, not released artifacts**: `RIG_REPO` and `RIG_REF` - are mint-time environment variables (default `heavy-duty/rig@main`), so a - run pins the exact commits under test. - - That pinning — **not sequencing** — is what dissolves the box↔rig - recursion. box and rig *are* mutually recursive: rig builds the host that - runs box, and box's seed calls rig back to converge the guest. But - candidate refs are static identifiers that exist as soon as the release - branches do, long before any drill runs, so a cycle at runtime becomes - independent tests against one fixed pair. No repo has to be released - before another can be drilled, and there is **no fixed order in which the - three releases must be published.** - - Each repo also drills a **different thing**: box asserts the isolation - contract (the VM trust boundary), rig asserts convergence (a machine - reaches its role, idempotently), cast asserts promotion (A→B reproduces, - and the diff is idempotent). Three different exercises sharing a - substrate — which is exactly why the records are per-repo. - - cast's legs are the **least coupled** of the three: two Coolify instances - can be stood up by hand, as the July drill did for instance B via a - parameterised compose file. Within a single drill you of course bring the - substrate up before probing it — a host before a guest before Coolify — - but that is how you run *a* drill, not an ordering rule *between repos*. - - Drilling the candidate **is** drilling the release. A release PR's diff is - the version file and `CHANGELOG.md` — nothing executable differs between - the tree that was drilled and the tree that ships, so the evidence carries - across the ceremony commit. - - Each repo records ITS OWN legs in its own `drills/X.Y.Z.md`, citing the - shared **run ID** that names the pinned set and the other two repos' commit - SHAs — which is what lets separate records be reassembled into one picture. - The guard still reads only this repo's files: cast never queries box's or - rig's drill records to decide whether cast may ship, because a cross-repo - lookup degrades to "pass" the moment it fails to resolve — the - unreadable-rollup bug wearing a different hat. - - If a defect shows up only in the combination: patch, re-drill, re-record. - The three releases converge on a set that holds together; they are not - required to be right in one pass. - - A maintainer **waiver** is possible — but it must be RECORDED in - `drills/X.Y.Z.md` for that version, saying who waived it and what is - untested. The guard requires a *record*, not a passing result, so skipping - the drill stays possible and stays visible and deliberate. -3. **Merge. That's the ship decision — nothing else to do.** - [release.yml](.github/workflows/release.yml) fires on the merged, - `release`-labeled PR and asserts, in order, each fail-loud and creating - nothing: the merged version is non-`-dev`; the version *changed in this - PR* (the `-dev` transition is the interlock — a mislabeled ordinary PR - fails here); that version's changelog section extracts non-empty - ([.github/scripts/release-notes.sh](.github/scripts/release-notes.sh)); - and no tag or release exists for it yet. Then, in the same job, it tags - the merge commit bare `X.Y.Z` (no `v` prefix — box's tag scheme), builds - the package once (`npm ci && npm run build && npm prune --omit=dev`), and - publishes the release with the runnable tree — `bin/`, `dist/`, - production `node_modules/`, `package.json` — attached as - `cast-X.Y.Z.tgz`. That asset is what the installer's release channels - download: the build happens once, in CI, never on an operator's machine. - *Manual fallback and backfill:* push a bare `X.Y.Z` tag on the merge - commit yourself — the same workflow runs the same asserts, build, and - publish from the tag. -4. **The release re-arms main itself**: the same workflow run bumps - `package.json` (and `package-lock.json`) to `X.Y.(Z+1)-dev` and pushes - the commit straight to main — no follow-up PR (it opens one only if - branch protection refuses the direct push, and says so loudly). - Installs are versioned by the tree's `package.json` version, so a - `CAST_REF=main` install between releases must land as - `versions/X.Y.(Z+1)-dev`, never as `versions/X.Y.Z` — main's tree must - not impersonate the release it merely descends from. On the *manual* - tag path the bump stays yours: open the one-line PR after publishing. - This step re-arms the **version** only — the `## Unreleased` heading is - step 1's, in the ceremony PR's own diff, because no workflow ever writes - `CHANGELOG.md`. The two halves meet in `test/release.test.ts`: once this - bump makes the version `-dev`, a missing `## Unreleased` is CI-red. +- **The prebuilt asset** — + [`.github/actions/release-artifact/`](.github/actions/release-artifact/action.yml), + the artifact hook both doors invoke: the build happens ONCE, in CI, and + `cast-X.Y.Z.tgz` is the runnable tree (`bin/`, `dist/`, production + `node_modules/`, `package.json`). That asset is what the installer's + release channels download — never a source tarball, never an + operator-machine build. +- **The drill** — the real-hardware gate before the handoff of a release + PR. Cast's drill asserts **promotion**: two live Coolify instances, the + full A→B run (team, apply, an idempotent diff, smoke, inventory, + emit-draft, fleet, destroy, the read-only guard) — A→B reproduces, and + the diff is idempotent. The full meaning — the fixed candidate-ref + pinning that dissolves the box↔rig recursion, the per-version record + files, the waiver rule — is [`drills/README.md`](drills/README.md); the + `drill-recorded` guard enforces the record on every release tree. ## Labels — who sets what -The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is -who sets each kind — most of it is machinery, and hand-moving a -machine-owned label just gets corrected on the next pass: +The taxonomy and state machine are +[`.ceremony/LABELS.md`](.ceremony/LABELS.md); cast's `scope:*` rows live in +`.github/labels.conf` (reconciled by the labels caller) and their path map +in `.github/labeler.yml`. What matters day to day is who sets each kind — +most of it is machinery, and hand-moving a machine-owned label just gets +corrected on the next pass: | Labels | Set by | |---|---| -| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff (step 6) and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* | +| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* | | `blocker:*` | the same workflow, from the same facts — *what is in the way.* Any number per PR, or none. Never by hand: applying one does not stop a merge, and removing one does not unblock anything. Fix the thing and the next sweep drops the label. | | `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. | | `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. | diff --git a/drills/README.md b/drills/README.md index 7345e8e..aa15237 100644 --- a/drills/README.md +++ b/drills/README.md @@ -5,8 +5,8 @@ Per-release evidence: **one file per version**, named `.md`, where `0.2.0.md`, `0.2.0-rc1` in `0.2.0-rc1.md`. A release PR's version must have its file here, holding at least one -non-whitespace character, before CI will let it merge -(`.github/scripts/drill-recorded.sh`, wired into ci.yml). +non-whitespace character, before CI will let it merge (the +`heavy-duty/ceremony/actions/drill-recorded` guard, pinned in ci.yml). ## One file per version, and why the parser went away