docs: CONTRIBUTING points at the mirror; docs swept for deleted-path pointers
CONTRIBUTING keeps only what is genuinely cast's — the panel roster, the local checks, the changelog house style, the prebuilt-asset contract and the promotion drill meaning; the review-round doctrine and label taxonomy now live in .ceremony/. drills/README.md and labeler.yml repoint at the pinned guard and the vendored LABELS.md (the sweep rule from ceremony#12, learned in rig#112). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
becf41940d
commit
f223aa699e
3 changed files with 59 additions and 184 deletions
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
235
CONTRIBUTING.md
235
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. |
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ Per-release evidence: **one file per version**, named `<version>.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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue