cast/CONTRIBUTING.md
dan-claude-bot 775af63063 feat: merging a release-labeled PR is the release (#111)
box#95 taught the family that a forgotten manual tag is the worst
failure shape: silent, no red X, a release that simply doesn't happen.
The ship decision already lives in the ceremony PR — the one whose whole
diff is the version leaving -dev, carrying the reviews and the
maintainer's merge — so tagging after it is transcription, and
transcription belongs to the machine (box#96's design; this is cast's
twin).

release.yml now also triggers on pull_request closed against main,
gated on merged == true AND the hand-set release label. The merge path
asserts four facts in order, each fail-loud and creating nothing: the
merged package.json version is non--dev (read via node, never regex —
the pkg_version discipline); the version CHANGED in this PR (base vs
merge — the -dev interlock, so a mislabeled ordinary PR fails loudly);
the version's changelog section extracts non-empty via the existing
release-notes.sh; and no tag or release exists yet (idempotent re-runs,
and the loud answer to a manual-tag race). Then, in the same job, it
tags the merge commit via the API and publishes. Same-job is
load-bearing: a GITHUB_TOKEN-created tag triggers no workflows, so the
tag-push path cannot fire on it and double-publish.

Both trigger paths converge on literally the same steps — each entry
step exports RELEASE_VERSION, and the notes extraction, the exact
existing asset build (npm ci, npm run build, npm prune --omit=dev,
staged as cast-X.Y.Z/), and the gh release create read only that — so
the paths cannot drift and the installer keeps finding the one asset
name it knows, cast-X.Y.Z.tgz. The tag-push path survives as the
documented manual fallback and backfill, and it matters immediately:
0.1.0 never carried -dev (cast predates the ritual), so the interlock
correctly does not fire for #110's ceremony — that one ships by manual
tag, and the automation applies from 0.1.1 on.

test/release.test.ts pins the new wiring in the house grep style,
fail-closed: the merged+labeled gate, the four asserts strictly ordered
ahead of tag/build/publish, the single job, the anti-recursion comment,
and that no per-path asset name exists. CONTRIBUTING.md's Releasing now
says it plainly: merge is the ship decision; the tag is the fallback.

Fixes #111

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

5.9 KiB

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.

The PR loop

  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 by requesting their review — that request is what flips state:needs-human. With three formal head-current approvals the labels workflow requests it automatically; when part of the panel is comment-only, reading their agreement is the author's judgment, so the author makes the request.
  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.

Releasing

A release is a PR, and merging it IS the release (#111; box#96's design, on box#83's shape):

  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. CI green on it, same loop as any PR.
  2. Merge. That's the ship decision — nothing else to do. 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); 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.
  3. Right after the release, a follow-up PR bumps package.json to X.Y.(Z+1)-dev (and package-lock.json with it) — box#90's step of the family ritual. 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.

Labels — who sets what

The full taxonomy lives in 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:

Labels Set by
state:* the labels workflow (.github/workflows/labels.yml) — recomputed from GitHub's own facts every 15 minutes and on PR events. Never by hand.
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). Additive — you may add more, the machine won't remove them.
scope:* on issues you, when opening or triaging — issues have no paths to derive from.
blocked, release you — automation never guesses intent.
bug / enhancement / documentation you, on issues only — a PR's type already lives in its title.

Issues

Give issues the same care as PR titles: say the surface in the title, apply a scope: label and a type label (bug / enhancement / documentation) when you open one, and blocked when it waits on something — that is what keeps the board navigable as the issue count grows.