diff --git a/.ceremony/AGENTS.md b/.ceremony/AGENTS.md new file mode 100644 index 0000000..b8af0fa --- /dev/null +++ b/.ceremony/AGENTS.md @@ -0,0 +1,49 @@ +# AGENTS.md — start here + +You are an agent working in a repo governed by +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is +the router: find your role below, read its file, then act. The role files +sit beside this one — in ceremony itself at the repo root, in a governed +repo under `.ceremony/` (a machine-managed mirror; never edit those files +in place — they are changed in heavy-duty/ceremony, through its own flow). + +## Your role + +You were told your role when you were pointed at this repo ("you are a +reviewer here"). That one word is your whole onboarding: + +| you are the… | read | your job in one line | +|---|---|---| +| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through | +| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria | +| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human | + +Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels +are the shared state machine, and misusing one lies to every other agent on +the board. + +**Not told a role?** Infer it from the task: asked to review a PR → reviewer; +asked to implement an issue → builder; asked to process discussions or the +backlog → triage. Still ambiguous → ask before acting. Do not free-lance +across roles in one session: a builder reviewing its own PR, or a reviewer +pushing fixes, breaks the separation the pipeline depends on. + +## The pipeline you are part of + +``` +discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release + (anyone) (agent) (queue) (agent) (agents) (human) (ceremony) +``` + +Two rules bind every role: + +- **Only triage mints issues.** Found work? Open or extend a discussion. +- **Only humans merge.** Convergence ends at `state:needs-human`, never at + a merge button. + +## Repo specifics + +What is true only of *this* repo — the review panel roster, the `scope:*` +label set, what a drill means, code conventions — lives in the repo's own +`CONTRIBUTING.md`. Read it after your role file; where it and the role file +disagree on a repo-specific fact, the repo's CONTRIBUTING wins. diff --git a/.ceremony/BUILDER.md b/.ceremony/BUILDER.md new file mode 100644 index 0000000..a1680f1 --- /dev/null +++ b/.ceremony/BUILDER.md @@ -0,0 +1,76 @@ +# BUILDER.md — the builder role + +You turn one issue into one PR. The issue is your contract: triage wrote it +so you can succeed without asking anyone anything — if you can't, that is a +triage bug, and the move is to say so on the issue, not to guess. + +## Picking + +- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`, + never an `epic` (epics organize; their children are the work). +- Respect dependency order: inside an epic, take the earliest unblocked + unclaimed child. Between epics and strays, prefer the issue that unblocks + the most other work. +- **One issue at a time.** Finish or release your claim before taking + another. + +## Claiming + +- Assign yourself, swap `ready` → `claimed`, and comment that you are + starting. The claim is a promise of a draft PR soon — a claim with no PR + and no activity is what the staleness sweep reclaims. +- **Abandoning is fine; ghosting is not.** If you stop, say where you got to, + push the branch if it holds anything useful, unassign, and restore + `ready`. + +## Building + +- Branch per issue; open the PR **as a draft early**, `Closes #N` in the + body. Drafts are invisible to the reviewer panel on purpose — the draft + phase is yours. +- **The issue's acceptance criteria are your definition of done.** Reproduce + them as a checklist in the PR body and check them honestly as you go. If + one turns out to be wrong or unreachable, say so on the issue and get it + amended by triage — do not silently ship less than the issue says. +- Every behavior change adds one line to `CHANGELOG.md` under + `## Unreleased` — insert **above** the heading below it, never over it + (the monotonic guard's whole reason to exist). +- Follow the repo's conventions file and match the code you touch. Tests are + not optional: the issue's test plan is the floor, not the ceiling. +- **Scope discipline: the PR does the issue — whole, and nothing else.** + Adjacent problems you discover go to a **discussion** (or a comment on the + relevant issue), where triage will do its job. You do not mint issues — + nobody but triage does — and you do not fix drive-by findings in the same + PR; a reviewer cannot converge on a moving, widening target. + +## The review round + +(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the +panel roster and any repo-specific flow notes live in that repo's own +CONTRIBUTING; everything below is the shared flow.) + +1. Mark ready-for-review; request **the whole panel** (the roster is in the + repo's CONTRIBUTING). +2. **Wait for every verdict, then answer the round whole** — one reply + covering every point, then push the fixes, then re-request exactly the + reviewers who did not approve. Prefer verification over argument: when a + reviewer doubts behavior, add the test that settles it. +3. Never dismiss a review, never merge, never mark your own work as passed. + A blocking point you disagree with is answered with evidence or escalated + in the PR — a maintainer can be asked for a ruling; silence and + force-forward are not options. + +## Handoff + +When the round passes — every panel verdict approves the **current head**, +and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if +this is a release PR) — hand it to the human, in order: + +1. post the round summary (what changed per round, what was verified); +2. request the human's review; +3. set `state:needs-human` yourself. + +The label write is optimistic — the reconciler validates it, and takes it +back if the PR is not actually mergeable-right-now. Then stop: the PR is the +human's. Address what comes back (`state:addressing`) and re-hand-off the +same way. diff --git a/.ceremony/LABELS.md b/.ceremony/LABELS.md new file mode 100644 index 0000000..4be1fb3 --- /dev/null +++ b/.ceremony/LABELS.md @@ -0,0 +1,98 @@ +# Labels + +The taxonomy shared across the heavy-duty repos. Only the `scope:` set +differs per repo (each repo's `.github/labels.conf` names its actual +surfaces); everything else below is core and identical everywhere, created by +the labels workflow's bootstrap dispatch (issue #10). + +Two state machines share the taxonomy: the **PR machine** (proven in +box/rig/cast, reconciled by machinery) and the **issue flow** (the +triage → build queue, doctrine-enforced today, machinery to follow — +issue #18). One rule joins everything: **states are machine-owned, intent +labels are hand-set** — a hand-moved state label is a lie waiting to happen, +and the reconciler recomputes it from GitHub's own facts. + +## PR state — who is the ball with? (exactly one per open PR) + +| Label | Color | Waiting on | +|---|---|---| +| `state:building` | `#FBCA04` | the builder — PR is a draft | +| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) | +| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up | +| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head | + +`bots-reviewing` vs `addressing` is deliberate: staleness in the first means +*poke the reviewers*, in the second *the builder dropped the ball*. And +`state:needs-human` means exactly one thing — a human could merge this now — +so it requires zero blockers and head-current approvals; anything less and +the reconciler takes it back. The author sets it at handoff (the one +hand-set state); the `labeled` event fires the sweep that validates the +write within seconds. + +## PR blockers — what is in the way? (facts, as many as apply) + +| Label | Color | Means | +|---|---|---| +| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** | +| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide | +| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked | +| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) | + +States answer *whose ball*; blockers answer *what's in the way*. They are +separate axes because the single-label version kept lying — independent facts +projected onto one totally-ordered label meant one always won and the losers +vanished off the board (box's `state:needs-rebase`, retired: the reconciler +strips it on sight). + +## Issue flow — the work queue (exactly one per open, triaged, non-epic issue) + +| Label | Color | Means | Set by | +|---|---|---|---| +| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage | +| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage | +| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder | +| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it | +| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage | + +The invariant a board scan relies on: every open issue is either +`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` / +`blocked`. A `claimed` issue with no open PR and no activity is what the +staleness sweep will reclaim (issue #18); until that machinery exists, +[TRIAGE.md](TRIAGE.md) owns the hygiene by hand. + +## Cross-cutting (PRs and issues) + +| Label | Color | Meaning | +|---|---|---| +| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied | +| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) | +| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself | +| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it | + +## Scope — which surface? (PRs and issues, any number) + +All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The +set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via +actions/labeler, issues get theirs from triage. This repo's set: + +| Label | Covers | +|---|---| +| `scope:release-flow` | the reusable release workflow, decide, the doors | +| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded | +| `scope:labels` | the labels workflow, reconciler, this taxonomy | +| `scope:docs` | README doctrine, CONSUMERS.md, the role files | + +## Issue types + +`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry +their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label +on a PR would say the same thing twice and drift. + +## Maintenance + +The labels workflow (issue #10) recomputes PR state statelessly on PR events +plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on +manual dispatch. Issue-flow labels are doctrine-owned until #18 lands +machinery for them. Default GitHub labels (`duplicate`, `invalid`, +`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at +bootstrap — a `question` is a discussion, not an issue. diff --git a/.ceremony/README.md b/.ceremony/README.md new file mode 100644 index 0000000..34bc9c4 --- /dev/null +++ b/.ceremony/README.md @@ -0,0 +1,14 @@ +# .ceremony/ — the vendored doctrine mirror + +Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit +these files here: they are byte-identical copies of +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this +repository's pinned ref, and CI re-diffs them on every PR — a hand edit +goes red. They are changed in heavy-duty/ceremony, through its own flow, +and arrive here when the pin moves. + +The pin lives in `.github/workflows/release.yml` — the single +`uses: heavy-duty/ceremony/.github/workflows/release.yml@` line. One +pin governs machinery and doctrine alike: bump it and re-sync this mirror +in the same PR (`docs-sync --fix`, or let the red check on the bump PR say +what is stale). diff --git a/.ceremony/REVIEWER.md b/.ceremony/REVIEWER.md new file mode 100644 index 0000000..17ac893 --- /dev/null +++ b/.ceremony/REVIEWER.md @@ -0,0 +1,70 @@ +# REVIEWER.md — the reviewer role + +You are one voice on a panel. The panel's job is to converge — on an +approval the human can trust, or on a precise statement of what is wrong. +The machine reads only your **verdict**; humans read your reasons. + +## The verdict doctrine + +- **Every review ends in a verdict**: approve, or request changes. A + comment-only review is a non-verdict — it does not say whether the round + passed, the state machine treats it as not-approved, and the PR simply + stalls. If you have an opinion, you have a verdict; commenting without one + only wedges the flow. +- **The verdict carries blockingness only; the body carries the feedback.** + Non-blocking nits ride an **approval**, and the builder addresses them at + their discretion. Anything blocking — including a question whose answer + gates your approval — is **request changes**, saying exactly what + unblocks it. +- An approval you would not defend to the human is a defect. You are not + being asked to be agreeable; you are being asked to be right. + +## What you review against + +In order of authority: + +1. **The issue's acceptance criteria** — the PR's `Closes #N` names your + spec. Check every criterion; a PR that ships less than the issue says is + a request-changes even if the code is beautiful. +2. **The repo's load-bearing constraints** — the rules bought with + incidents (in ceremony itself: issue #1's constraint list; in a governed + repo: its own CONTRIBUTING plus ceremony's README). A change that + "simplifies away" a constraint gets request-changes with a link to the + incident that made the rule. +3. **The code itself** — correctness first, then tests (does the test plan's + floor exist? do the failure cases actually fail?), then conventions. + Changelog line present for behavior changes; comments carry why, not + what. + +**Verify over opine.** Run what can be run; construct the failing input; a +test settles what a comment thread can't. A review that says "I ran X and +saw Y" outranks one that says "this looks like it might". + +## What you do not do + +- **Re-litigate the spec.** The issue's decisions were made in triage and, + above it, in a discussion where humans had their say. If you think the + spec itself is wrong, say so with reasons — as a comment pointing at the + discussion, while still reviewing the implementation against the spec as + written. Spec changes go through triage, not through a review round. +- **Merge, or tell the builder to merge.** Convergence hands the PR to a + human; only humans merge. +- **Approve a moving target.** Your approval is of a specific head. If the + builder pushes after your approval, GitHub stales it — that is correct, + and the builder owes a re-request, not an assumption. + +## The round rhythm + +- Review the **whole PR at the current head** each round, not just the diff + since your last comments — the fix for someone else's point can break + yours. +- The builder answers rounds whole and re-requests you; until re-requested, + the ball is not yours (`state:addressing` is the builder working — pile-on + reviews mid-address just churn the target). +- Convergence = every panel verdict approves the current head, no + `blocker:*` standing. Then the builder hands off (`state:needs-human`) and + the panel's job is done. +- If a round exposes a disagreement **within the panel**, argue it in the PR + with evidence until one side concedes or the builder escalates to the + maintainer for a ruling. Two reviewers pulling a builder in opposite + directions without resolution is a panel failure, not a builder failure. diff --git a/.ceremony/TRIAGE.md b/.ceremony/TRIAGE.md new file mode 100644 index 0000000..3dcf655 --- /dev/null +++ b/.ceremony/TRIAGE.md @@ -0,0 +1,95 @@ +# TRIAGE.md — the triage role + +You are the only door issues come through. Humans and agents open +**discussions**; you decide what becomes work. The quality of every +downstream stage — a builder succeeding without asking, a reviewer having a +spec to review against — is set here, by you, and nowhere else. + +## Why this door exists + +Discussions are allowed to be ambiguous; issues are not. An issue is a work +order a builder must be able to execute **without asking anyone anything**. +Keeping one accountable role between the two is what keeps the bar from +eroding — the moment anyone can mint an issue, the backlog fills with +"improve X" entries nobody can build, and builders start guessing. Guessing +is the failure this whole flow exists to prevent. + +## Your inputs + +- **Every open discussion** in the repo you serve. +- **Stray issues** — anything filed directly, by anyone. Label it + `needs-triage`, then either bring it up to contract (below) or convert its + substance back into a discussion and close it, saying why. Do not shame the + filer; do route the work correctly. + +## For each discussion, converge on exactly one outcome + +1. **Answer.** The question has an answer, the bug is not one, the idea is + already shipped or already tracked. Reply with the answer (link the code, + the doc, the existing issue), mark answered. +2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the + repo, its history, or its docs. Ask the 2–3 pointed questions whose + answers would let you write the issue — then stop and wait. Do not mint an + issue that carries the ambiguity forward; that just moves your job onto + the builder. +3. **Escalate.** The blocker is a *decision* only a human owns — scope, + money, product direction, breaking a public contract. Say precisely what + the decision is, list the options with your recommendation, and name the + decider. The discussion is where humans decide; wait there. +4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link + where it belongs if anywhere, close. A refusal with reasons is a good + outcome; a zombie discussion is not. +5. **Accept.** It justifies work → mint the issue(s). The contract below is + the bar. + +## The issue contract + +Every issue you mint carries, in this order: + +- **A title that names the deliverable** — "lib/version.sh — one version + abstraction, two backends", never "improve version handling". +- **Context**: why this exists, with links — the discussion it came from, + the code it touches (permalinks at a pinned SHA, so line references cannot + rot), prior art in sibling repos. +- **The spec**: decisions made, not options listed. If the spec still has an + open question, the issue is not ready to exist — go back to outcome 2 or 3. +- **Tasks**: the steps, checkboxed, in order. +- **Acceptance criteria**: checkboxed, verifiable, and honest — these become + the builder's definition of done and the reviewer's review spec, verbatim. +- **Test plan**: what proves it, including the cases that must fail. +- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an + epic organizes it. +- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and + exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)). + +The bar, stated once: **a competent builder who has read only this issue and +the repo can succeed.** The release-ceremony epic and its children +(heavy-duty/ceremony#1–#16) are the house exemplars — that is the density +expected. + +## Multi-issue work + +When an acceptance produces more than one issue, mint an **epic** (`epic` +label): the approach, the decisions, the constraint list, and a +dependency-ordered task list of child issues. Children reference the epic; +the epic's checklist is the progress view. Builders never pick the epic +itself. Keep the checklist current — a stale epic misleads every scan. + +## Backlog hygiene (yours until #18 automates it) + +- **Dedup before minting** — search issues *and* closed issues; extend or + reopen before duplicating. +- **Flip `blocked` → `ready`** when the named dependency lands. +- **Reclaim abandoned claims**: `claimed` + no open PR + no activity → + comment, unassign, restore `ready`. +- **Close obsolete issues** with the reason and a link to what obsoleted + them. Every label on every open issue stays true; the board is only worth + scanning if it does not lie. + +## What you never do + +- Write code, review code, or build the thing yourself. +- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)). +- Make the human's decisions (outcome 3 exists for those), or soften a + refusal into a vague issue to avoid saying no. +- Mint an issue to "discuss" something — that is a discussion. diff --git a/.github/labels.conf b/.github/labels.conf new file mode 100644 index 0000000..34a94a2 --- /dev/null +++ b/.github/labels.conf @@ -0,0 +1,7 @@ +panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl +scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node +scope:users|C5DEF5|users-* — class model, apply/status, close-root +scope:runner|C5DEF5|runner-* — GitHub runner lifecycle +scope:coolify|C5DEF5|coolify-* — Coolify and backup install +scope:db|C5DEF5|db.sh — dump/restore +scope:installer|C5DEF5|install.sh — how rig lands on a machine diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh deleted file mode 100755 index b684723..0000000 --- a/.github/scripts/changelog-monotonic.sh +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# changelog-monotonic.sh [] [] — assert that no SHIPPED -# release heading was DELETED by this branch: the set of '^## X.Y.Z' headings -# on HEAD must be a SUPERSET of the set at the merge base. -# -# The failure it exists to catch (#98; ported from heavy-duty/box#122, which -# was caught in review of box#118) leaves no trace. An author adding an entry -# under '## Unreleased' REPLACES the line below it instead of inserting above -# it: -# -# -## 0.2.0 — 2026-07-19 -# +## Unreleased -# + -# +### Fixed -# + -# +- **An entry** -# -# git merges that cleanly — it is a one-line edit inside a file nobody has -# touched concurrently — so there is no conflict and no signal. 0.2.0's whole -# body is now sitting under '## Unreleased', and 0.2.0 has no section at all. -# -# The arming rule is green on exactly that tree, correctly. changelog_armed() -# in test/release.sh asks only whether the TOP section agrees with VERSION, -# and deleting '## 0.2.0' leaves '## Unreleased' on top. It is not wrong, it -# is narrow — it guards ONE heading, the one a PR is about to write under. -# This guards the REST of the file, the part no single tree can be asked -# about at all, because "a heading disappeared" is not a property of a tree — -# it is a property of a DIFF. -# -# The damage surfaces at the next release, in changelog_section() -# (.github/scripts/release-lib.sh), which anchors on the heading: -# -# awk -v ver="$2" ' -# /^## / { if (found) exit; found = ($2 == ver); next } -# ... -# -# No heading, no section — and release.yml's "refusing to publish an empty -# release" assert is the first thing that notices, one whole release too late. -# -# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The -# ceremony (CONTRIBUTING, "Releases") adds one and never removes one; nothing -# else in the documented flow touches them. So SUPERSET is exact — it has no -# legitimate violation to carve an exception for. The stamp is covered for -# free: rewriting '## Unreleased' -> '## X.Y.Z — DATE' ADDS X.Y.Z and removes -# no X.Y.Z heading, because 'Unreleased' is not one. '## Unreleased' is -# deliberately NOT in the set this guards — the arming rule owns that heading, -# keyed on VERSION, and the ceremony legitimately consumes it. -# -# A file of its own, NOT a clause inside test/release.sh's arming check, for -# three reasons. Its input is different (a git history, not two files). Its -# degradation is different (no base ref is a SKIP, not a failure). And the -# arming rule is driven by test/release.sh against constructed VERSION + -# CHANGELOG.md trees that are not git repos at all — folding a git-dependent -# assert into it would make every one of those cases either skip or lie. -# Same discipline as release-lib.sh: its own file so a test can drive it. - -base_ref="${1:-${CHANGELOG_MONOTONIC_BASE:-origin/main}}" -changelog="${2:-CHANGELOG.md}" - -# Fail-closed switch: CI sets it, so a SKIP that would be a sensible local -# degradation becomes a red run there instead. A guard that can silently -# stop guarding is the failure shape this whole family of checks exists to -# refuse, so the skip path is loud and CI refuses to take it at all. -strict="${CHANGELOG_MONOTONIC_STRICT:-0}" - -skip() { - if [ "$strict" = "1" ]; then - echo "changelog-monotonic: $* — and CHANGELOG_MONOTONIC_STRICT=1, so this is a FAILURE, not a skip." >&2 - echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2 - echo " (Uniqueness on HEAD already passed; it is containment that cannot run.)" >&2 - echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2 - exit 1 - fi - echo "changelog-monotonic: containment SKIPPED — $*" - echo " (Uniqueness on HEAD already ran and passed — only the deleted-heading" - echo " half needs the history. In CI this same condition is a hard failure.)" - exit 0 -} - -[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; } - -# The set of RELEASE headings: '## ...' where looks like a -# version. Field $2, the same split changelog_section() uses, so the two -# cannot disagree about what a section header is. 'Unreleased' fails the -# shape and is excluded by construction. -headings_raw() { - awk ' - /^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 } - ' -} -headings() { headings_raw | sort -u; } - -# --- uniqueness on HEAD (the box#118 class) ---------------------------------- -# Containment catches a DELETED heading. It cannot catch a DUPLICATED one: the -# duplicate is head-side SURPLUS, and `comm -23` (base minus head) is blind to -# extras on the head side — with or without `sort -u`, base {0.2.0} minus head -# {0.2.0, 0.2.0} is empty. Multiset comparison does not close it either, for -# the same reason. The assert that does is uniqueness of version headings ON -# HEAD, kept alongside containment rather than replacing it. -# -# This is the shape box#118's bad rebase produced: two `## 0.2.0 — 2026-07-19` -# headings with an incoming entry between them. Every other guard stays green -# — conflict markers absent, the arming rule happy (the top section is still -# right), tests and shellcheck clean. -# -# rig's symptom differs from box's, and the difference matters. box's -# release-notes.sh RE-ARMS its grab on every matching '## ' line, so a -# duplicate makes it ABSORB whatever sits between the copies. rig's -# changelog_section() has `if (found) exit`, so it stops dead at the second -# copy instead: a duplicate TRUNCATES. The published body is only what sits -# BETWEEN the two headings, and everything under the second copy — the real -# body of that release — is silently dropped. Different symptom, same class: -# no conflict, no red run, discovered only by a human reading the published -# notes. -# -# Nothing legitimate repeats a version heading: the ceremony stamps a NEW -# version, and 'Unreleased' fails the version shape and never reaches here. -dupes="$(headings_raw < "$changelog" | sort | uniq -d)" -if [ -n "$dupes" ]; then - { - echo "changelog-monotonic: $changelog has DUPLICATE release heading(s):" - echo - printf '%s\n' "$dupes" | sed 's/^/ ## /' - echo - cat <&2 - exit 1 -fi - -# --- everything below needs the HISTORY -------------------------------------- -# Uniqueness is settled. What follows is containment, which compares HEAD -# against the merge base and therefore genuinely depends on the base ref, the -# merge base, and the base blob. Each of those can be unavailable for reasons -# that are not the author's fault (a shallow clone, a fork checkout without the -# upstream remote, the commit that first adds the changelog), so each degrades -# rather than failing — which is exactly why the uniqueness half must NOT live -# down here (#98; fixed upstream in heavy-duty/box#143, where rig's copy of -# this script came from). It asks nothing of the history, and gating it behind -# these conditions let a duplicate exit 0 on a message about deletion. - -git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ - || skip "not inside a git work tree, so there is no history to compare against" - -git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \ - || skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)" - -merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)" -[ -n "$merge_base" ] \ - || skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)" - -# The changelog may not exist at the merge base at all (the commit that adds -# it). Nothing to have deleted, so nothing to assert. -base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)" -[ -n "$base_file" ] || { - echo "changelog-monotonic: $changelog does not exist at the merge base ($(git rev-parse --short "$merge_base")) — nothing could have been deleted (uniqueness on HEAD already passed)." - exit 0 -} - -base_headings="$(printf '%s\n' "$base_file" | headings)" -head_headings="$(headings < "$changelog")" - -# comm -23: lines in the base set that are NOT in the head set — exactly the -# headings this branch removed. -missing="$(comm -23 <(printf '%s\n' "$base_headings") <(printf '%s\n' "$head_headings"))" - -if [ -n "$missing" ]; then - { - echo "changelog-monotonic: this branch DELETES release heading(s) from $changelog:" - echo - printf '%s\n' "$missing" | sed 's/^/ ## /' - echo - cat <&2 - exit 1 -fi - -count="$(printf '%s\n' "$base_headings" | grep -c . || true)" -head_count="$(printf '%s\n' "$head_headings" | grep -c . || true)" - -# The success line has two honest forms, because this step now runs on two -# shapes of event. On a push to main the merge base IS HEAD: containment -# compared the file against itself and asserted nothing, and deletion is -# undetectable on that event by construction. Reporting "all N still present" -# there would be the same dishonesty the skip messages were fixed for in #98 — -# a log claiming a check that did no work. Uniqueness is the half that actually -# ran, so that is the half the line names. -if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then - echo "changelog-monotonic: containment vacuous (the merge base IS HEAD, so nothing could have been deleted between them) — uniqueness on HEAD checked $head_count release heading(s)." -else - echo "changelog-monotonic: all $count release heading(s) at the merge base ($(git rev-parse --short "$merge_base")) are still present in $changelog" -fi diff --git a/.github/scripts/drill-recorded.sh b/.github/scripts/drill-recorded.sh deleted file mode 100755 index 975fdc9..0000000 --- a/.github/scripts/drill-recorded.sh +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# drill-recorded.sh [] [] — assert that the version -# this tree is about to ship has a DRILL RECORD at /.md. -# -# defaults: drills VERSION -# -# CONTRIBUTING ("Releasing") says a release carries a real-hardware drill. -# Nothing enforced it, so no release in this family has ever had one: the -# ceremony is four correct mechanical steps — bump VERSION, stamp the -# changelog, re-arm, merge — and every one of them is checked by a script, -# while the one step that costs an afternoon on real hardware was checked by -# a reviewer remembering. Reviewers remember exactly as long as the release is -# interesting, which is never at 0.4.3. A bot finally blocked on it; this is -# that block, moved into CI where it does not depend on anyone's attention. -# -# ONE FILE PER VERSION, which is what this script is now mostly about. The -# first cut of this guard kept every record as a section inside one -# drill/RUNS.md, and paid for it: it needed an awk extractor that matched a -# literal '## Release drill — ' prefix, tolerated an optional ' — DATE' tail, -# compared the version WHOLE so that '0.3.0-rc1' could not answer for '0.3.0', -# and then separately insisted the extracted body hold a non-blank line. Every -# one of those rules existed only because records shared a file. Both sibling -# repos shipped a DEFECT out of that complexity during review — a -# `sed '/./,$!d'` extractor where `.` matches a space, so a heading plus one -# tab satisfied the gate (box#149, cast#138), and heading-grammar drift on the -# other side. Splitting the records makes nearly all of it unrepresentable: -# `0.3.0.md` and `0.3.0-rc1.md` are simply different files, there is no -# heading to parse and no grammar to drift, and the whole-version comparison -# is done by the filesystem. -# -# PER-REPO, and that is the load-bearing design decision. The obvious -# alternative — have rig ask box's repo whether the drill ran — cannot fail -# safely: the lookup needs a network call, a token, and a checkout that may be -# a fork, and every one of those failure modes lands on "could not read", which -# a naive implementation spells `|| true` and reads as PASS. That is exactly -# the UNREADABLE-vs-NONE bug #90 fixed one layer up (an unreadable check rollup -# reading as "nothing is failing"), and re-introducing it in the release gate -# would be worse: it degrades to green on precisely the tree that ships. So rig -# records rig's own legs in rig's own repo, and this script reads a file that -# is either in the checkout or is not. -# -# The directory is `drills/`, NOT `.drills/`. A dot-directory is invisible to -# every glob that has not set `dotglob`, which is how #70 here and box#116 / -# box#118 all happened: a file that exists but that no sweep can see is worse -# than no file, because it reads as covered. -# -# What it asserts is a RECORD, not a RESULT — and that is deliberate, not a -# weakness. A gate that demanded "the drill passed" would have to parse -# somebody's prose for a verdict, and would leave a maintainer who consciously -# ships without a full drill (a doc-only release, a hardware outage) with no -# move except deleting the check. Requiring a record means the waiver is -# WRITTEN DOWN, in a file named for the version it applies to, in a commit a -# reviewer sees. Skipping stays possible; skipping silently does not. -# -# Vacuous on a `-dev` tree, which is why it needs no trigger scoping in -# ci.yml (unlike changelog-monotonic.sh, whose input is a diff): every ordinary -# PR carries a `-dev` VERSION and passes without a drill record existing at -# all. The check has something to say on exactly one tree — the release -# ceremony PR — and that is the tree it must be impossible to merge without. - -drills="${1:-drills}" -version_file="${2:-VERSION}" - -# An unreadable version file is an ERROR, never a silent pass. There is no -# version to be lenient about, so leniency here could only mean "ship -# unevidenced" — the exact degradation the per-repo decision above exists to -# avoid. -[ -f "$version_file" ] || { - echo "drill-recorded: no such file: $version_file" >&2 - exit 1 -} - -version="$(tr -d '[:space:]' < "$version_file")" -[ -n "$version" ] || { - echo "drill-recorded: $version_file is empty — there is no version to check a drill against." >&2 - exit 1 -} - -# The -dev half. A development tree is not shipping anything, so there is -# nothing to evidence; saying so out loud (rather than exiting 0 in silence) -# is the #98 lesson — a guard that prints nothing is indistinguishable from a -# guard that did nothing. -case "$version" in - *-dev) - echo "drill-recorded: VERSION is $version — a development tree has nothing to assert (the drill gates a RELEASE, and this is not one)." - exit 0 - ;; -esac - -record="$drills/$version.md" - -# WHITESPACE IS NOT A RECORD. This is the one surviving piece of the rule set -# the old section-parsing guard needed, and it survives because it is the one -# part that splitting the files does not make unrepresentable: an empty file, -# or a file holding only spaces, tabs and newlines, exists at the right path -# and is still no evidence. It is the same property box#149 and cast#138 both -# got wrong with `sed '/./,$!d'` (`.` matches a space), where a record of one -# tab shipped an evidence-free release. `grep -q '[^[:space:]]'` is the whole -# check now, with no extractor in front of it to get wrong. -# -# The negated form below, matching box's and cast's twins exactly, so there is -# no divergence between the three to explain. -# -# It also avoids a real `set -e` hazard, which is worth naming precisely -# because an earlier draft of this comment named it BACKWARDS. A bare -# `[ -f "$record" ] && grep -q ... "$record"` mid-script does NOT abort when -# the file is missing: the left-hand side of `&&` is exempt from errexit, so a -# miss simply continues. What DOES abort is the other case — the file exists -# and `grep` finds nothing, i.e. exactly the whitespace-only record this guard -# is here to refuse. The script would die on its most interesting input, -# before printing the message that explains it. -# -# Verified rather than reasoned about: -# bash -ec '[ -f /nonexistent ] && r=yes; echo reached' -> prints, exit 0 -# bash -ec 'f=$(mktemp); echo " " >"$f" -# [ -f "$f" ] && grep -q "[^[:space:]]" "$f" -# echo reached' -> silent, exit 1 -# -# Caught by all three reviewers on #104. The lesson is the same one #149 and -# cast#138 taught: this family's comments get read as contracts, so a comment -# that misstates the semantics is a defect even when the code is correct. -if [ ! -f "$record" ] || ! grep -q '[^[:space:]]' "$record"; then - { - echo "drill-recorded: VERSION is $version, and there is no drill record at $record." - echo - cat <&2 - exit 1 -fi - -lines="$(grep -c . "$record" || true)" -echo "drill-recorded: $record records a drill for $version ($lines non-blank line(s))." diff --git a/.github/scripts/labels-reconcile.sh b/.github/scripts/labels-reconcile.sh deleted file mode 100644 index 1cec82a..0000000 --- a/.github/scripts/labels-reconcile.sh +++ /dev/null @@ -1,486 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# labels-reconcile.sh — the automation LABELS.md promises: state labels are -# written by machinery, never by hand. Every run derives each open PR's -# state:* from GitHub's own facts (draft flag, requested reviewers, submitted -# reviews) and converges the labels to it, so a killed run or a hand-moved -# label heals on the next pass. Stale is judged from real activity — commits, -# comments, reviews — never from label churn, or the sweep would un-stale its -# own mark every tick. -# -# The verdict contract (CONTRIBUTING.md): reviews end in approve or -# request-changes. Some live bots are comment-only and post agreement as a -# COMMENTED review — a non-verdict this machine refuses to guess about (body -# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The -# judgment call belongs to the PR AUTHOR, who reads the round and escalates -# by requesting the human's review — an explicit request is a fact, and it is -# the one this machine trusts (see decide_state's top precedence). The -# machine auto-requests the human only in the no-judgment-needed case: three -# formal head-current approvals. Any approval that counts must be bound to -# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a -# stale approval must never promote unreviewed code to the human. -# -# DRY_RUN=1 narrates every mutation instead of performing it (how this script -# is rehearsed against the live repo). A workflow_dispatch run also bootstraps -# the taxonomy (label create --force) — that heal is dispatch-only; the cron -# sweep tolerates a missing label rather than recreating it. -# -# The state machine below is pure (globals in, state out) and covered by -# fixture tests in test/labels-reconcile.sh. - -HUMAN="${HUMAN_REVIEWER:-danmt}" -BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl) -STATES=(state:building state:bots-reviewing state:addressing state:needs-human) -BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested) -# Labels this machine used to own and no longer does. Cleared on sight so a -# retirement heals the board instead of stranding a label nothing recomputes. -RETIRED=(state:needs-rebase) -STALE_AFTER=$((48 * 3600)) - -log() { printf 'labels: %s\n' "$*"; } - -run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing - if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi -} - -# --------------------------------------------------------------------------- -# The state machine. Pure functions over four globals, set per PR: -# DRAFT true|false -# HEAD_SHA the PR's current head commit -# REQUESTED newline-separated logins with a review currently requested -# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews -# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict) -# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup) -# --------------------------------------------------------------------------- - -requested() { grep -qxF "$1" <<<"$REQUESTED"; } - -checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE - # UNREADABLE is the absence of the key itself, which is what a failed fetch - # leaves behind — distinct from a present-but-empty rollup, which honestly - # means this PR has no checks. Collapsing the two let an API hiccup present - # as "nothing is failing", i.e. as mergeable-by-a-human: the same - # unknown-certified-as-green shape as the bug this machine exists to stop. - # The caller skips the PR entirely rather than labelling on facts it did not - # read; blocking on it instead would flap the whole board on one bad call. - # The rollup mixes two node types with two different closed enums: CheckRun - # carries `conclusion` (CheckConclusionState), StatusContext carries `state` - # (StatusState). Rather than list the outcomes that block — the version that - # shipped in this PR's first round listed four, and ERROR, CANCELLED and - # STALE fell through its `else` into SUCCESS — this lists the outcomes that - # DON'T, and treats everything else as blocking. - # - # That direction is the point. An outcome we do not recognise is one we - # cannot certify as mergeable, and certifying the unrecognised as green is - # the exact shape of #136. The cost of being wrong is symmetric in form and - # not in consequence: a false FAILURE parks the PR on the agent, who looks; - # a false SUCCESS invites a human to merge a tree that will not merge. - jq -r ' - if (has("statusCheckRollup") | not) then "UNREADABLE" else - - # NEUTRAL and SKIPPED satisfy branch protection — a skipped required check - # is not a failed one, and path-filtered jobs skip constantly here. - ["SUCCESS", "NEUTRAL", "SKIPPED"] as $passing - # "" covers a StatusContext still reported with no state at all. - | ["", "PENDING", "IN_PROGRESS", "QUEUED", "WAITING", "REQUESTED", "EXPECTED"] as $waiting - - # A re-run does not evict the run it superseded — the rollup keeps both. - # This PR proved it: its own tip carried a CANCELLED `scope` (15:19:39) - # beside the SUCCESS `scope` (15:19:45) that replaced it, same workflow. - # Once CANCELLED blocks, judging every entry would strand this very PR in - # needs-rebase forever, so collapse each context to its newest entry first. - # Key on workflow + name because a bare job name is only unique within its - # workflow. - # - # Dating a run is the subtle part, and getting it wrong restores the bug. - # A run still in flight has no completion, but `gh` does not omit the - # field: its Go struct marshals the zero time as "0001-01-01T00:00:00Z", - # which is a string, so `//` will not fall through it. Ordering on - # completion therefore sorted the LIVE re-run to the bottom and let `last` - # pick the very run it superseded — reporting the old SUCCESS while a - # replacement was still running, which is #136 again. - # - # So: date a run by when it BEGAN, discarding both spellings of absent - # (null, and the zero sentinel) and falling back only if it never recorded - # a beginning. NOT by the newest stamp of any kind: `max` compares the - # completion of a finished run against the start of a live one, which are - # different quantities and not an ordering on runs. A run cancelled by the - # concurrency group does not stop the instant its replacement starts — the - # runner has to wind down — so predecessor.completedAt > successor.startedAt - # is the ordinary case, and `max` dated the dead predecessor newer than the - # live run that replaced it, narrowing both failures above without closing - # them. The list is already in preference order, so `first` IS that rule. - # - # An entry that carries no usable timestamp at all sorts LAST rather than - # first — something we cannot date is most likely the thing just created, - # and treating it as newest keeps an undateable in-flight run from being - # discarded in favour of a stale success. Every ambiguity resolves toward - # "not settled". - | [ (.statusCheckRollup // [])[] - | { ctx: [.workflowName // "", .name // .context // ""], - at: ([.startedAt, .createdAt, .completedAt] - | map(select(type == "string" and . != "" - and (startswith("0001-01-01") | not))) - | first // ""), - outcome: ((.conclusion // .state // "") | ascii_upcase) } ] - | group_by(.ctx) - | map(sort_by([(.at == ""), .at]) | last | .outcome) as $latest - - | if ($latest | length) == 0 then "NONE" - elif (($latest - $passing - $waiting) | length) > 0 then "FAILURE" - elif (($latest - $passing) | length) > 0 then "PENDING" - else "SUCCESS" end - - end' -} - -bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK - local review state commit - review="$(jq -c --arg u "$1" \ - '[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \ - <<<"$REVIEWS_JSON")" - if [ -z "$review" ]; then echo MISSING; return; fi - state="$(jq -r '.state' <<<"$review")" - commit="$(jq -r '.commit_id' <<<"$review")" - case "$state" in - CHANGES_REQUESTED) - # blocks at ANY head — GitHub's own semantic: only a newer review - # from the same reviewer clears it - echo BLOCK ;; - APPROVED) - if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;; - *) - # COMMENTED and anything else: a non-verdict. The machine does not - # read bodies — if the comment is really an agreement, the AUTHOR - # says so by requesting the human's review. - echo FEEDBACK ;; - esac -} - -human_request_needed() { # 0 when needs-human requires a FRESH human request - # already requested → the handoff is live; head-current human approval → - # nothing left to ask. Anything else (never reviewed, an old comment, an - # approval of an older head) stalls the handoff unless we request — - # guarding on "has the human ever reviewed" wedged exactly that way. - if requested "$HUMAN"; then return 1; fi - if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi - return 0 -} - -blockers() { # → the blocker:* labels this PR should carry, one per line - # The second axis. These are FACTS ABOUT THE BRANCH, and they are mutually - # independent — a PR can be conflicted and red and unasked at once — so they - # are a set, not an ordering. That is the whole point of splitting them out - # of state:*: every precedence bug this machine has had (needs-human - # surviving a conflict, MISSING swallowing STALE) came from projecting - # independent facts onto one totally-ordered label. A set has no precedence - # to get wrong. - # - # UNKNOWN mergeability is deliberately NOT a conflict: GitHub reports it for - # about a minute after every merge while it recomputes, and flapping every - # open PR on each merge would be worse than the bug. Same for a failed read - # of either fact — both default to the "do not know" value, which blocks - # nothing. An unset global (an older fixture, a failed fetch) must never - # invent a verdict it did not read. - case "${MERGEABLE:-UNKNOWN}" in CONFLICTING) echo blocker:conflict ;; esac - case "${CHECKS:-NONE}" in FAILURE) echo blocker:ci-red ;; esac - - # Nobody is on the hook for a verdict somebody still owes. Distinct from - # bots-reviewing, which says a request is live and an answer is coming: - # here the round is stalled because no one was ever asked, and the board - # said "waiting on the bots" for the 48h it took `stale` to notice. - # A draft is exempt (the bots ignore drafts by design), and so is an - # explicit human request — a maintainer claiming a PR early is deliberate, - # not a dropped ball. - if [ "$DRAFT" != true ] && ! requested "$HUMAN"; then - local b v owed=false any_requested=false - for b in "${BOTS[@]}"; do - requested "$b" && any_requested=true - # MISSING and STALE are both verdicts this head does not have: nobody - # reviewed it, or everybody reviewed something else. The agent owes an - # ask either way — the stale round is if anything the worse of the two, - # since it has approvals on the page that no longer describe the tree. - v="$(bot_verdict "$b")" - case "$v" in MISSING | STALE) owed=true ;; esac - done - if [ "$owed" = true ] && [ "$any_requested" = false ]; then - echo blocker:unrequested - fi - fi -} - -decide_state() { # → the one state:* label this PR should carry - if [ "$DRAFT" = true ]; then echo state:building; return; fi - - local s - s="$(round_state)" - - # The one rule joining the two axes: state:needs-human means a human could - # merge this RIGHT NOW, so it requires a clear branch. Any blocker at all - # means the work is the agent's — whatever the review round says — and the - # blocker label says which work it is. Nothing else in this function reads - # the branch, which is what keeps the ordering below purely about reviews. - if [ "$s" = state:needs-human ] && [ -n "$(blockers)" ]; then - echo state:addressing; return - fi - echo "$s" -} - -round_state() { # → the state the REVIEW ROUND alone implies; knows no branch facts - local b verdicts="" - for b in "${BOTS[@]}"; do - if requested "$b"; then echo state:bots-reviewing; return; fi - done - # Collect the WHOLE round before applying any precedence. Deciding inside - # the loop let BOTS order pick the winner: a MISSING returned immediately, - # so a STALE belonging to a later bot was never even read, and the mixed - # round (one approval staled by a push, another bot yet to review) came out - # needs-human — the #136 headline shape, with zero reviews bound to the head. - for b in "${BOTS[@]}"; do - verdicts="$verdicts $(bot_verdict "$b")" - done - case "$verdicts" in - # STALE = a verdict for an older head. Unlike MISSING, this outranks the - # human request: every approval it covers was invalidated by a push, so - # NOBODY has reviewed this tree. Handing that to the human is the #136 case - # where everything reads green — mergeable, CI passing, "waiting on the - # human" — over code no reviewer has seen. The agent owes a re-request. - # Checked before MISSING because "unfinished" must not swallow "and also - # stale": a round that is both is a push that outran the re-requests, not - # a maintainer deliberately claiming the PR early. - *STALE*) echo state:addressing; return ;; - esac - case "$verdicts" in - # No verdict at all from some bot, and nothing staled. An explicit human - # request still outranks an unfinished round — a maintainer pulling a PR - # to themselves early is a deliberate act, and the original precedence. - # - # Otherwise it is the AGENT's ball, not the bots'. The loop above already - # returned for every live bot request, so reaching here with a MISSING - # means somebody owes a verdict and nobody was asked for one — the round - # is not running. Calling that bots-reviewing was the lie that let a - # forgotten PR read "waiting on the reviewers" for the 48h it took the - # stale sweep to notice. blocker:unrequested says why. - *MISSING*) - if requested "$HUMAN"; then echo state:needs-human; return; fi - echo state:addressing; return ;; - esac - # an explicit human request outranks the remaining bot outcomes — it is the - # final gate, and a maintainer pulling a PR to themselves early counts too - if requested "$HUMAN"; then echo state:needs-human; return; fi - case "$verdicts" in - # FEEDBACK = a comment with no verdict → the agent owes the round-reply. - *BLOCK* | *FEEDBACK*) echo state:addressing; return ;; - esac - # the bots all approve — but if the human's standing word is - # changes-requested (and nobody re-requested them yet), the agent owes - # fixes, not the human a nag - if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then - echo state:addressing - else - echo state:needs-human - fi -} - -# --------------------------------------------------------------------------- -# The sweep: fetch facts, decide, converge. One PR's failure never aborts the -# others — each PR reconciles in a subshell and a failure just logs. -# --------------------------------------------------------------------------- - -bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick - while IFS='|' read -r name color desc; do - [ -n "$name" ] || continue - run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force - done <<'EOF' -state:building|FBCA04|PR is a draft — the coding agent is still building -state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round -state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes -state:needs-human|8250DF|No blockers, all bots approve — waiting on the human reviewer -blocker:conflict|B60205|Does not merge — the branch conflicts and the agent owes a rebase -blocker:ci-red|B60205|A check is failing — the agent owes a fix (not a rebase) -blocker:unrequested|E99695|Somebody still owes a verdict and nobody was asked for one -merge-next|0E8A16|Head of the merge queue — merge this one next (set by hand/agent, cleared here) -stale|B60205|No activity for 48h — needs a poke (sweep-managed) -blocked|6A737D|Waiting on another PR or issue to land first -release|0E8A16|Release flow and version/packaging work -scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node -scope:users|C5DEF5|users-* — class model, apply/status, close-root -scope:runner|C5DEF5|runner-* — GitHub runner lifecycle -scope:coolify|C5DEF5|coolify-* — Coolify and backup install -scope:db|C5DEF5|db.sh — dump/restore -scope:installer|C5DEF5|install.sh — how rig lands on a machine -EOF -} - -has_label() { grep -qxF "$1" <<<"$LABELS"; } - -reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch - local n="$1" desired remove s args last_activity age - - desired="$(decide_state)" - - # encode the runbook's last step for the no-judgment case: three formal - # head-current approvals → the human is asked, once. The guard asks whether - # a FRESH human review is needed for THIS head — never "has the human ever - # reviewed", which wedged the handoff after any earlier human comment. - # Idempotent (a live request suppresses it); race-free via the shared - # concurrency group in labels.yml. With a comment-only bot on the panel - # this path stays cold and the AUTHOR requests the human. - if [ "$desired" = state:needs-human ] && human_request_needed; then - run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent - log "#$n: requested $HUMAN (round passed)" - fi - - # ---- converge both axes ---- - # state:* is exclusive (everything but $desired comes off); blocker:* is a - # set (each one on or off on its own); RETIRED always comes off. One edit - # call for all of it, so a PR never flickers through a half-applied board. - local want_blockers add="" - want_blockers="$(blockers)" - - remove="" - for s in "${STATES[@]}"; do - if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi - done - for s in "${RETIRED[@]}"; do - if has_label "$s"; then remove="$remove,$s"; fi - done - for s in "${BLOCKERS[@]}"; do - if grep -qxF "$s" <<<"$want_blockers"; then - has_label "$s" || add="$add,$s" - else - has_label "$s" && remove="$remove,$s" - fi - done - add="${add#,}" - remove="${remove#,}" - - # Never NAME a label the repo does not have. `gh issue edit --add-label` - # rejects the WHOLE call on one unknown name — nothing is applied — so a - # single missing blocker would take the state convergence down with it, on - # exactly the PRs this change exists to fix, surfacing only as a log line. - # Batching state and blockers into one edit for anti-flicker is what widened - # that blast radius; filtering the add side is what closes it again. - # Removals need no filter: they are built from has_label, so the label - # provably exists. REPO_LABELS unreadable means no filtering rather than - # filtering everything out — a failed read must not silently strip the board. - local skip_edit=false - if [ -n "${REPO_LABELS:-}" ]; then - local kept="" missing="" want - for want in ${add//,/ }; do - if grep -qxF "$want" <<<"$REPO_LABELS"; then kept="$kept,$want" - else missing="$missing $want"; fi - done - add="${kept#,}" - # A missing STATE label skips only the EDIT — never the rest of this - # function. Everything below is independent of the state:* taxonomy, and - # returning here stranded it: `merge-next` kept claiming "merge this one - # next" on a PR the board had moved to the agent, and the stale sweep - # stopped running. That is the original false-invitation bug, reintroduced - # in the very fix meant to survive a cold-start repo — and a regression - # against the old behaviour, which failed the edit and fell through. - if ! grep -qxF "$desired" <<<"$REPO_LABELS"; then - log "#$n: WARNING: state label '$desired' does not exist — skipping the label edit; dispatch the workflow to bootstrap" - skip_edit=true - elif [ -n "$missing" ]; then - log "#$n: WARNING: missing label(s)$missing — state still converged; dispatch the workflow to bootstrap" - fi - fi - if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then - args=(--add-label "$desired${add:+,$add}") - [ -n "$remove" ] && args+=(--remove-label "$remove") - if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then - log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}" - else - # a deleted label must not wedge the sweep — dispatch heals the taxonomy - log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)" - fi - fi - - # ---- merge-next: cleared, never set ---------------------------------- - # Queue order is INTENT — which PR should land first is a judgement about - # conflicts and dependencies that GitHub knows nothing about, so the - # reconciler must not guess it (LABELS.md's rule for `blocked`/`release`). - # What it CAN do is stop the label going stale the way needs-human did: - # the moment the PR is no longer the thing a human should merge next, the - # claim is removed. Setting it stays with whoever owns the queue. - if has_label merge-next && [ "$desired" != state:needs-human ]; then - run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null - log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)" - fi - - # ---- stale: real activity only, and blocked is legitimately quiet ---- - last_activity="$( - { - jq -r '.created_at' <<<"$PR_JSON" - jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" - gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' - gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' - gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' - } | sort | tail -n1 - )" - age=$((NOW - $(date -d "$last_activity" +%s))) - if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then - if has_label stale; then - run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null - log "#$n: unstale" - fi - elif ! has_label stale; then - run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null - log "#$n: stale ($((age / 3600))h quiet)" - fi -} - -main() { - REPO="${REPO:?set REPO to owner/name}" - NOW="$(date +%s)" - - if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then - log "workflow_dispatch: bootstrapping the taxonomy" - bootstrap_labels - fi - - # The repo's label set, read ONCE per sweep — reconcile_pr filters every - # add against it, because one unknown name fails the whole edit call. - REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")" - [ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered" - - local n - for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do - ( - PR_JSON="$(gh api "repos/$REPO/pulls/$n")" - DRAFT="$(jq -r '.draft' <<<"$PR_JSON")" - HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")" - LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" - REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")" - # PENDING reviews are unsubmitted drafts in someone's browser — not a verdict - REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \ - | jq -s '[.[] | select(.state != "PENDING")]')" - # mergeability + the check rollup, the two facts the state machine was - # blind to (#136). `gh pr view` rather than the REST PR object: the API's - # `mergeable` is a tri-state boolean that GitHub computes lazily, while - # this returns the same MERGEABLE/CONFLICTING/UNKNOWN string the UI shows. - # Failure to read them is NOT fatal and NOT treated as broken — an API - # hiccup must never flap every PR into needs-rebase, so both degrade to - # the "do not know" value that triggers nothing. - GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>/dev/null || echo '{}')" - MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")" - CHECKS="$(checks_state <<<"$GH_VIEW")" - # Read failed: leave this PR exactly as it is. Recomputing on facts we - # did not read is how an API hiccup turns into a false "merge me" — - # and the next tick is 15 minutes away, not 15 hours. - if [ "$CHECKS" = UNREADABLE ]; then - log "#$n: could not read mergeability/checks — left alone this pass" - exit 0 - fi - reconcile_pr "$n" - ) || log "#$n: reconcile failed — continuing with the remaining PRs" - done - log "reconciled." -} - -# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI -if [ "${BASH_SOURCE[0]}" = "$0" ]; then - main "$@" -fi diff --git a/.github/scripts/release-lib.sh b/.github/scripts/release-lib.sh deleted file mode 100644 index aaddc11..0000000 --- a/.github/scripts/release-lib.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -# Release plumbing shared by .github/workflows/release.yml and the test -# harness (test/release.sh) — pure functions, sourced, never executed on -# their own (repo precedent: labels-reconcile.sh's decide_state, the -# commands/lib/*.sh parsers). - -# changelog_section -# -# Print the BODY of that version's CHANGELOG.md section: everything between -# its heading and the next '## ' heading (or EOF). A release heading is -# stamped '## ' and the Unreleased one is bare -# '## Unreleased'; the second field is the version either way, so both -# shapes match. The heading itself is not printed — the release title -# already names the version — and leading blank lines are dropped. Empty -# output means "no such section", which release.yml turns into a refusal: a -# tag with no changelog entry must not ship an empty release. -changelog_section() { - awk -v ver="$2" ' - /^## / { if (found) exit; found = ($2 == ver); next } - found && !body && /^[[:space:]]*$/ { next } - found { body = 1; print } - ' "$1" -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74c46b0..6734cf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,16 +9,10 @@ jobs: steps: - uses: actions/checkout@v4 with: - # fetch-depth: 0, for the changelog-monotonic step below and only - # for it. That check is about a DIFF — which release headings the - # merge base had — so it needs the base branch's history present, - # and the default depth-1 checkout has none of it. An explicit - # `git fetch origin ` would be narrower, but it has to be - # right on both event types and on fork PRs, and getting it subtly - # wrong degrades to a SKIP (a guard that silently stops guarding — - # the exact failure this repo keeps refusing). Full history on a - # pure-bash tree costs a second; the STRICT flag below turns any - # remaining skip red rather than green. + # changelog-monotonic compares HEAD against the merge base; a + # checkout that cannot resolve it is a hard failure in CI, not + # a skip (a guard that can quietly stop guarding is the failure + # shape these checks exist to refuse). fetch-depth: 0 - name: shellcheck # -x follows the `source=SCRIPTDIR/...` directives into commands/lib/. @@ -43,61 +37,27 @@ jobs: shellcheck -x "${files[@]}" - name: cli tests run: bash test/cli.sh - # test/labels-reconcile.sh existed here since #87 but ran nowhere: the - # label state machine gates every PR on this repo and its fixtures were - # green only when someone remembered to run them by hand. Same step, same - # place as heavy-duty/box. - - name: labels state-machine tests - run: bash test/labels-reconcile.sh - - name: release-flow tests + - name: release tests — rig's own surfaces run: bash test/release.sh - # No SHIPPED release heading was deleted or DUPLICATED (#98). Its own step - # rather than a line inside test/release.sh: that suite drives the arming - # rule against constructed VERSION + CHANGELOG.md trees that are not git - # repos, and this assert needs a git history — folding it in would make - # those cases skip or lie. It is also a DIFFERENT invariant: arming is a - # fact about this tree, monotonicity is a fact about this tree versus its - # merge base. STRICT=1 so a checkout that cannot reach the base ref fails - # here instead of skipping quietly forever. + # The release guards, doctrine in heavy-duty/ceremony's README (#13's + # conversion). Each one's war story — why it exists, what it refuses — + # lives with its implementation upstream; the four pins below and the + # two workflow callers must always name the same ceremony tag. # - # NOT pull-request-only, and that is the #98 fix at the workflow level. - # The two halves have different vacuity: DELETION is vacuous on a push to - # main (the merge base IS HEAD), but DUPLICATION is vacuous on no tree at - # all, so gating the whole script on `pull_request` left a duplicate that - # reached main by any other route unasserted forever. - # - # The `|| github.ref_name` fallback is load-bearing, not defensive. On a - # push event `github.base_ref` is EMPTY, so the argument would collapse to - # a bare `origin/`, which does not resolve — and STRICT=1 correctly - # promotes that to a hard failure, turning every push to main red. With - # the fallback it resolves to the pushed branch, whose merge base with - # HEAD is HEAD or its parent: containment passes vacuously, exactly as the - # old `if` intended, while uniqueness now runs on every push. - - name: no shipped changelog heading was deleted or duplicated - env: - CHANGELOG_MONOTONIC_STRICT: '1' - run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}" - # The release this tree would ship has a recorded real-hardware drill - # (drills/.md). CONTRIBUTING ("Releasing") has always required one and - # nothing enforced it, so no release in this family has ever carried one - # — the drill was the single ceremony step checked by a reviewer - # remembering rather than by a script. - # - # Deliberately NOT trigger-scoped, and for the opposite reason to the - # step above. That one needs a base ref, so its argument has to be right - # on both event types; this one reads two files in the checkout and is - # VACUOUS BY CONSTRUCTION on a `-dev` VERSION, which every ordinary PR - # and every push to main carries. It has something to say on exactly one - # tree — the `release: X.Y.Z` ceremony PR — so an `if:` could only add a - # way for that one tree to slip past. - # - # PER-REPO on purpose: rig reads rig's own record, never box's repo. A - # cross-repo lookup fails on a token, a network blip or a fork checkout, - # and every one of those lands on "could not read" — which degrades to - # green on precisely the tree that ships (the UNREADABLE-vs-NONE shape - # #90 fixed). - - name: a release version has a recorded drill - run: bash .github/scripts/drill-recorded.sh + # changelog-armed: the version-keyed arming rule (rig#66; the + # unconditional form rig#44 reverted — this is its correct return). + - uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0 + # changelog-monotonic: no shipped heading deleted or duplicated + # (#98, box#122). Strict by default: an unresolvable base ref is red, + # never a quiet skip — hence the fetch-depth: 0 above. + - uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0 + # drill-recorded: a release version carries drills/.md + # (rig's drill meaning: drills/README.md). Vacuous on -dev trees. + - uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0 + # docs-sync: the .ceremony/ doctrine mirror is byte-identical to the + # pin read from release.yml (ceremony#19) — a hand edit or a + # half-done pin bump goes red here. + - uses: heavy-duty/ceremony/actions/docs-sync@0.1.0 # Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and # stands up throwaway containers, and a slow image pull must never delay the diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 26587ab..9eca9bb 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -1,78 +1,18 @@ name: labels -# The automation LABELS.md promises. Two halves: -# scope — path-derived scope:* labels on PRs (actions/labeler) -# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh) -# -# pull_request_target, not pull_request: every PR here arrives from a fork, -# where pull_request (and pull_request_review) run with a READ-ONLY token and -# cannot label anything. _target is safe in this workflow because no PR code -# is ever checked out or executed — labeler reads changed paths via the API, -# and reconcile checks out the BASE branch only. Keep it that way. -# -# There is no pull_request_review_target, so a review landing cannot wake this -# workflow directly — and the */15 cron is advisory: GitHub deprioritises short -# intervals hard enough that a quiet repo goes hours between ticks. So the -# handoff wakes the sweep itself: the author sets state:needs-human when handing -# the PR to the maintainer (CONTRIBUTING step 6), and `labeled` fires this -# workflow, which confirms or corrects that optimistic write within seconds. The -# cron stays as the last resort, for the round an agent forgets to hand off. -# -# This cannot loop: the reconciler's own label writes use GITHUB_TOKEN, and -# GitHub does not create workflow runs from GITHUB_TOKEN-triggered events. Agent -# writes use a PAT and therefore do trigger — exactly the asymmetry wanted. +# The automation LABELS.md promises, now implemented upstream +# (heavy-duty/ceremony#13): scope labeling and the state reconciler live in +# the reusable workflow this caller pins. Rig keeps the triggers and +# permissions (a called workflow cannot define them), its path map in +# .github/labeler.yml, and its panel + scope taxonomy in .github/labels.conf. on: - schedule: - - cron: "*/15 * * * *" - workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo + 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, - ] - + types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] permissions: contents: read issues: write pull-requests: write - jobs: - scope: - # Not on labeled/unlabeled: those events change no paths, so labeler has - # nothing new to derive — and label churn is precisely what they are. - if: >- - github.event_name == 'pull_request_target' && - github.event.action != 'labeled' && - github.event.action != 'unlabeled' - runs-on: ubuntu-latest - concurrency: - group: labels-scope-${{ github.event.pull_request.number }} - cancel-in-progress: true - steps: - - uses: actions/labeler@v5 - with: - # additive only — a hand-applied scope must survive the machine - sync-labels: false - - reconcile: - runs-on: ubuntu-latest - # ONE shared group: every reconcile sweeps every open PR, so cron and - # PR-event runs must serialize or two sweeps race the same PR's labels - # and both pass the request-the-human-once guard. GitHub keeps at most - # one queued run per group (older queued runs are superseded), which - # coalesces bursts instead of piling them up. - concurrency: - group: labels-reconcile - cancel-in-progress: false - steps: - - uses: actions/checkout@v4 # base branch only — never the PR's code - - name: reconcile state + stale - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - run: bash .github/scripts/labels-reconcile.sh + labels: + uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ce4ea5..91a99c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,238 +1,22 @@ name: release -# Two ways in, one release out (#47; box#96's design — the merge path — on -# top of #32/box#83's tag flow, kept verbatim as the fallback): -# -# - MERGE (the paved road): a release is a PR — `release: X.Y.Z`, carrying -# the `release` label, bumping VERSION and stamping CHANGELOG.md's -# Unreleased section — and MERGING it is the ship decision. The -# release-on-merge job asserts its way to certainty, then tags the merge -# commit and publishes, same job. No separate, silent-when-forgotten -# tagging step: a forgotten tag produces no red X, a failed run on main -# does — of two unreliabilities, pick the loud one. -# - TAG PUSH (the manual fallback and backfill): tag the merge commit bare -# `X.Y.Z` (no `v` prefix — box's tag scheme) and push; the release job -# below turns it into the GitHub release. -# -# Either way the body is the changelog section — the curated prose, never -# the auto-generated PR list — and no assets are uploaded on purpose: for a -# pure-bash tree, GitHub's source tarball for the tag IS the package -# (install.sh downloads archive/refs/tags/). +# The ceremony moved upstream (heavy-duty/ceremony — the doctrine is its +# README; ceremony#13 is this conversion). Both doors — merge and tag push — +# live in the reusable workflow this caller pins; rig keeps only what a +# called workflow cannot define: triggers and permissions. +# Triggers and permissions MUST live here (a called workflow cannot define them): on: - # ONE push key, both filters — YAML maps are last-key-wins, so a second - # sibling `push:` would silently REPLACE the first and kill a door - # (grok's round-2 catch: the tag fallback had stopped triggering). + # 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: - # Every tag, not a shape filter: a tag that mismatches VERSION must fail - # LOUDLY below, not be silently skipped by a pattern that didn't match. - tags: ['**'] - # The merge-is-the-release path (#47) rides pushes to MAIN, not - # pull_request events: a pull_request run from a public FORK gets a - # READ-ONLY GITHUB_TOKEN — `permissions:` cannot raise that ceiling — - # and every ceremony PR this org merges is cross-repo from the bot - # fork; the tag create would 403 after green asserts. A push to main - # is an in-repo event with the full write token, whoever authored the - # PR. The jobs split on the pushed ref. + 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 # the tag ref, the release publish, the bump push - # Two consumers (a declared permissions: block zeroes every unspecified - # scope): the decide step's label read (commits//pulls) and the bump - # fallback's `gh pr create --label`. - pull-requests: write - # ...and the --label on that fallback PR rides the ISSUES API (labels.yml - # grants the same pair for the same reason). - issues: write - + 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: - # The tag-push path — a pushed TAG ref. The merge path (a pushed main - # head) is release-on-merge below; the two doors split on the ref. - if: startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - # The tag names a tree; the tree names its own version. When they - # disagree, creating a release would put a version label on a tree - # that is not that version — exactly the lie the release flow exists - # to end — so: fail, create nothing. - - name: assert the tag matches the tree's VERSION - run: | - ver="$(cat VERSION)" - if [ "$GITHUB_REF_NAME" != "$ver" ]; then - echo "tag '$GITHUB_REF_NAME' != VERSION '$ver' — refusing to create a release for a tree that says it is something else" >&2 - exit 1 - fi - - name: create the release from the changelog section - env: - GH_TOKEN: ${{ github.token }} - run: | - . .github/scripts/release-lib.sh - notes="$(changelog_section CHANGELOG.md "$GITHUB_REF_NAME")" - if [ -z "$notes" ]; then - echo "CHANGELOG.md has no '## $GITHUB_REF_NAME' section — stamp the Unreleased section in the release PR before tagging" >&2 - exit 1 - fi - gh release create "$GITHUB_REF_NAME" --verify-tag \ - --title "$GITHUB_REF_NAME" --notes "$notes" - - # The merge path (#47; box#96): the `release` label is the intent, the - # VERSION transition is the interlock. Four asserts in order, each - # fail-loud and creating NOTHING, then tag + publish in this same job. - # Same-job is load-bearing: the tag is created with GITHUB_TOKEN via the - # API, and GITHUB_TOKEN-created refs do not fire `on: push: tags` - # workflows — so the publish MUST live here (nothing else would run), and - # the fallback job above CANNOT double-publish off our tag. A manually - # pushed tag racing this run is caught by the nothing-exists assert. - # NOTE: test/release.sh pins this block by awk-ing from - # 'release-on-merge:' to EOF — keep it the last job. - release-on-merge: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - env: - MERGE_SHA: ${{ github.sha }} - BASE_SHA: ${{ github.event.before }} - steps: - - uses: actions/checkout@v4 - with: - # The pushed head is what ships. Full history so the before-side - # VERSION is readable for the interlock below. - ref: ${{ github.sha }} - fetch-depth: 0 - # The decide step — asserts 1+2 fused, because the `release` label - # carries TWO legitimate meanings (LABELS.md: "release flow and - # version/packaging work"): the ceremony PR that ships a version, and - # ordinary work ON the release machinery — this very PR included. - # The version tells them apart. A `-dev` VERSION left UNTOUCHED by the - # PR is release-flow work: a green no-op, not a red run on main every - # time the flow itself is improved. Everything in between is a - # half-ceremony and dies loudly: - # -dev, unchanged → work under the label: NOTICE + green no-op - # -dev, changed → still a dev tree, so still work — the - # post-release bump PR above all (bare -> -dev - # after every release): green NOTICE no-op - # bare, unchanged, - # already released → work merged in the post-release window - # (ceremony landed, the -dev bump has not): - # NOTICE + green no-op - # bare, unchanged, - # never released → the label says ship, the tree names an - # unshipped version this PR did not mint: - # genuinely ambiguous, refuse - # bare, changed → the ceremony: proceed - - name: 'decide: ceremony, or release-flow work under the label?' - id: decide - env: - GH_TOKEN: ${{ github.token }} - run: | - ver="$(cat VERSION)" - # event.before is all-zeros on a branch-create push; the merge - # commit's first parent is main the instant before, either way. - if ! git cat-file -e "$BASE_SHA" 2>/dev/null; then BASE_SHA="$(git rev-parse "$MERGE_SHA^1")"; fi - base_ver="$(git show "$BASE_SHA:VERSION")" - case "$ver" in - *-dev) - if [ "$base_ver" = "$ver" ]; then - echo "NOTICE: VERSION '$ver' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish." - echo "ceremony=no" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "NOTICE: VERSION changed ('$base_ver' -> '$ver') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish." - echo "ceremony=no" >> "$GITHUB_OUTPUT" - exit 0 ;; - esac - if [ "$base_ver" = "$ver" ]; then - if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "NOTICE: VERSION '$ver' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish." - echo "ceremony=no" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "VERSION '$ver' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess." >&2 - exit 1 - fi - # The version transitioned — now the LABEL, the operator's declared - # intent, read via the API because a push event carries no PR - # payload (and the PR itself lives on a fork — see the trigger - # comment). No release-labeled PR behind this commit = a version - # transition nobody declared: refuse. - if ! gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \ - -q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then - echo "VERSION transitioned ('$base_ver' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#47), not a bare push. Refusing." >&2 - exit 1 - fi - echo "ceremony=yes" >> "$GITHUB_OUTPUT" - # Assert 3 — the changelog names exactly this version, and the one - # extractor (shared with the tag job and test/release.sh) gets a - # non-empty body out of it. The notes are kept for the publish. - - name: assert the changelog section for this version extracts - if: steps.decide.outputs.ceremony == 'yes' - run: | - . .github/scripts/release-lib.sh - ver="$(cat VERSION)" - changelog_section CHANGELOG.md "$ver" > "$RUNNER_TEMP/notes.md" - if [ ! -s "$RUNNER_TEMP/notes.md" ]; then - echo "CHANGELOG.md has no '## $ver' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2 - exit 1 - fi - cat "$RUNNER_TEMP/notes.md" - # Assert 4 — nothing exists yet, tag or release: a re-run of this job - # (or a manual tag that beat it) must refuse, not clobber. - - name: assert no tag and no release exist yet (idempotent re-runs) - if: steps.decide.outputs.ceremony == 'yes' - env: - GH_TOKEN: ${{ github.token }} - run: | - ver="$(cat VERSION)" - if git ls-remote --exit-code origin "refs/tags/$ver" >/dev/null 2>&1; then - echo "tag '$ver' already exists — this release already happened (or is mid-flight on the manual path); refusing to re-release" >&2 - exit 1 - fi - if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "release '$ver' already exists — refusing to re-release" >&2 - exit 1 - fi - # Act — tag the merge commit via the API, then publish with the notes - # assert 3 extracted. (GITHUB_TOKEN-created tag: no recursive - # workflow runs — see the job comment.) - - name: tag the merge commit and publish the release - if: steps.decide.outputs.ceremony == 'yes' - env: - GH_TOKEN: ${{ github.token }} - run: | - ver="$(cat VERSION)" - gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" \ - -f ref="refs/tags/$ver" -f sha="$MERGE_SHA" - gh release create "$ver" --verify-tag \ - --title "$ver" --notes-file "$RUNNER_TEMP/notes.md" \ - -R "$GITHUB_REPOSITORY" - # The post-release bump, folded into the release act (#47 followup — - # operator decision: a mechanical one-liner deserves no PR of its - # own). X.Y.(Z+1)-dev is arithmetic, not judgment: derived, committed - # straight to main with this job's token. A GITHUB_TOKEN push fires - # no workflows (anti-recursion), so the bump triggers neither this - # door nor a red run; and should branch protection ever refuse the - # direct push, the step opens the bump PR itself and says so, loudly, - # instead of leaving main armed to impersonate the release. - - name: bump main to the next -dev — the release re-arms main itself - if: steps.decide.outputs.ceremony == 'yes' - env: - GH_TOKEN: ${{ github.token }} - run: | - ver="$(cat VERSION)" - next="$(printf '%s' "$ver" | awk -F. '{ printf "%s.%s.%s-dev", $1, $2, $3 + 1 }')" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git fetch origin main - git checkout -B main origin/main - printf '%s\n' "$next" > VERSION - git add VERSION - git commit -m "chore: bump main to $next — a dev install must not impersonate $ver" - if ! git push origin main; then - echo "direct push refused (branch protection?) — opening the bump PR instead" >&2 - git checkout -b "chore/bump-$next" - git push origin "chore/bump-$next" - gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \ - --title "chore: bump main to $next" \ - --body "The post-release re-arm, opened by release.yml because the direct push was refused. One file, one line." \ - --label release - fi + uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0 + with: + version-source: file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e047dee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# AGENTS.md — start at .ceremony/ + +This repository is governed by +[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read +`.ceremony/AGENTS.md` first — it routes you to your role file, vendored +beside it. Repo specifics (the review panel roster, the scope labels, what +a drill means here, code conventions) live in CONTRIBUTING.md. diff --git a/CHANGELOG.md b/CHANGELOG.md index fdfa2b8..7063c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ on the way to cutting its first release, and this file starts there. ### Added - `kimi-box` joins the box tenant roles — the Kimi CLI agent guest (#109) +- The `changelog-armed` guard returns, version-keyed (#112, ceremony#13) +- The `.ceremony/` doctrine mirror, verified by `docs-sync` on every PR (#112, ceremony#19) + +### Changed + +- Release and labels machinery is consumed from heavy-duty/ceremony@0.1.0 by reference — the workflows shrink to caller stubs, the guard scripts and their tests move upstream (#112, ceremony#13) ## 0.3.0 — 2026-07-21 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8dc4945..e0cf609 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,61 +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 rig's. -## The PR loop +## The PR loop, rig 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**: `shellcheck`, `bash test/cli.sh` and +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**: `shellcheck`, `bash test/cli.sh` and `bash test/release.sh` locally mirror what CI runs; the db dump/restore round-trip (`test/db-integration.sh`) executes in CI where Docker is - present. -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. + present. 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 @@ -91,134 +62,39 @@ Not an entry — that is a PR body: ## Releasing -A release is a PR, and merging it is the release (#47; box#96's design, on -top of #32/box#83's tag flow). It takes the ordinary PR loop above, with one -extra gate before the handoff: +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, and the guards run in `ci.yml` from the same pin. +Bare `X.Y.Z` tags, no `v`; the tag's source tarball is the package +`install.sh` downloads — rig ships no other artifact. -**draft → ready → bot round → drill → `state:needs-human` → maintainer merge -(which IS the release).** - -The **drill** is a real-hardware run — tenant guests minted and converged via -box, `test/db-integration.sh`, the GitHub runner lifecycle against a fork, a -coolify install — recorded in **one file per version**: - -``` -drills/.md -``` - -named for the version exactly as `VERSION` carries it. See -[`drills/README.md`](drills/README.md) for what a record should contain. - -`.github/scripts/drill-recorded.sh` enforces it on every release: a bare -`VERSION` with no non-empty `drills/.md` turns CI red, naming the -version. It is **not a thing a reviewer has to remember** — that is how every -release in this family shipped undrilled until a bot finally blocked on one. On -a `-dev` tree it asserts nothing, so it is invisible to ordinary PRs. rig reads -rig's own record and never box's repo: a cross-repo lookup fails on a token, -a fork checkout or a network blip, and all of those degrade to "pass" — -the UNREADABLE-vs-NONE shape #90 fixed. - -One file per version is what keeps the guard small. Records used to share a -single log, which forced a heading grammar, an optional-date tail, a -whole-version comparison and a non-blank-body rule just to read them back — and -both sibling repos shipped a defect out of that complexity in review. Now -`0.3.0.md` and `0.3.0-rc1.md` are simply different files. - -**The three repos' drills are INDEPENDENT.** Run them in any order, on any -schedule, in separate sittings. What makes that safe is that every drill **pins -the same fixed set of candidate refs**: rig's drill runs `--host yes` with -`BOX_REF=release/`, so it exercises the box that will actually -ship; box's drill mints with `RIG_REF=release/`, so it exercises -the rig that will actually ship. Both measure the same pair. - -That — not sequencing — is what dissolves the box↔rig recursion. box and rig -are mutually recursive (`rig bootstrap … --host yes` installs box and runs -box's `setup-host`; box's `box new` seeds converge back through rig's installer -at `@RIG_REPO@/@RIG_REF@`), but the 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. Within a single drill -you naturally bring the substrate up before probing it — a host before a guest -— but that is how you run a drill, not an ordering rule between repos. - -Each repo drills in a **different way** and asserts a different thing: rig -asserts **convergence** (a machine reaches its role, idempotently), box asserts -the **isolation contract** (the VM trust boundary), cast asserts **promotion** -(A→B reproduces, the diff is idempotent). Three different exercises sharing a -substrate, not three phases of one script — which is exactly why the records -are per-repo. - -It drills **candidate refs, not released artifacts.** `RIG_REPO`/`RIG_REF` are -mint-time environment variables (default `heavy-duty/rig@main`), so a run pins -the exact commits under test. That is what dissolves the chicken-and-egg: no -repo has to be released before another can be drilled. - -**Drilling the candidate IS drilling the release.** A release PR's diff is -`VERSION` + `CHANGELOG.md` and nothing else — no executable difference exists -between the tree that was drilled and the tree that ships. - -Drills that share a substrate share **one run ID**. Each repo records *its own* -legs in its own `drills/.md`, citing that run ID and the other two -repos' commit SHAs, so the records can be joined after the fact by anyone -reading them. The guard still reads only this repo's file — there is no -cross-repo lookup anywhere in the gate. Releases do **not** have to be -published in a fixed order. 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 — a doc-only release, a hardware outage — -but it must be **recorded in `drills/.md` for that version**, saying -who waived it and why. The guard asks for a *record*, not a passing result, -precisely so that skipping is a deliberate, reviewable commit instead of a -silence. Deleting the check is not the move. - -The mechanics: - -1. A small PR — `release: X.Y.Z`, carrying the `release` label — bumps - `VERSION` from `X.Y.Z-dev` and stamps `CHANGELOG.md`'s Unreleased - section as `## X.Y.Z — YYYY-MM-DD`. **Then re-arm the file in the same - PR**: add a fresh, empty `## Unreleased` immediately above the section - you just stamped (#66). Stamping alone *disarms* main — a PR authored - before the release and merged after it wrote its entry under - `## Unreleased`, and with that heading gone git files the entry under - whatever now occupies the position, which is the release that already - shipped. It lands cleanly, with no conflict and nothing for the author - to notice, so the empty section is the only thing standing between a - late merge and a changelog that misattributes a shipped release. No - workflow does this for you: `release.yml` re-arms `VERSION`, never the - changelog. `test/release.sh` enforces the pairing — whenever `VERSION` - ends in `-dev` the top section must be `## Unreleased`. CI green on it, - same loop as any PR. -2. Merge it — that IS the ship decision. `release.yml`'s - `release-on-merge` job asserts, in order, fail-loud, creating nothing: - the merged tree's `VERSION` is non-`-dev`; this PR is the one that - changed it (a mislabeled ordinary PR fails here); the changelog section - for that version extracts non-empty; no tag or release exists yet. - Then, same job, it tags the merge commit bare `X.Y.Z` (no `v` prefix — - box's tag scheme) and publishes the GitHub release with that section as - the body. No assets — the source tarball for the tag is the package - `install.sh` downloads. -3. The release re-arms main itself: the same workflow run bumps `VERSION` - 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, loudly). A dev install therefore never impersonates the - release in the `versions/` layout. On the *manual* tag path the - bump stays yours: open the one-line PR after publishing. - -Manual fallback (and backfill): if the merge-path run fails, fix what it -named, then tag the merge commit `X.Y.Z` by hand and push the tag — the -original tag-push job still turns any correct tag into the release, and -the merge path's nothing-exists-yet assert keeps the two from -double-publishing. +What stays rig's is the **drill** — the real-hardware gate before the +handoff of a release PR: tenant guests minted and converged via box, +`test/db-integration.sh`, the runner lifecycle against a fork, a coolify +install. Rig's drill asserts **convergence** (a machine reaches its role, +idempotently), it runs `--host yes` with `BOX_REF=release/` so +it exercises the box that will actually ship, and drills that share a +substrate share **one run ID** so the per-repo records can be joined after +the fact. 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); rig'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/LABELS.md b/LABELS.md deleted file mode 100644 index 865541f..0000000 --- a/LABELS.md +++ /dev/null @@ -1,187 +0,0 @@ -# Labels - -How this repo uses GitHub labels. The taxonomy is shared across the -heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo, -because it names this repo's actual surfaces. - -## State — who is the ball with? (PRs, exactly one) - -Every open PR carries exactly one `state:` label, and it answers the only -question a board scan actually asks: *who is this PR waiting on?* The states -mirror the review loop this repo runs — PRs open as drafts, three reviewer -bots pick up ready PRs with reviews requested, each round is answered in a -single reply, and a human takes the final review. - -| Label | Color | Waiting on | Enters when | Leaves when | -|---|---|---|---|---| -| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested | -| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round | -| `state:addressing` | `#D93F0B` | the coding agent to reply, fix, or ask | all bots reviewed and not all approved; or nobody was asked; or a blocker is up | the round-reply is posted and fixes pushed — and any blocker named alongside is cleared | -| `state:needs-human` | `#8250DF` | the human reviewer | the PR **could be merged right now**: no blockers, three formal head-current approvals — and the human review is requested | merged — or changes requested, which cycles back to `state:addressing` | - -`bots-reviewing` and `addressing` are deliberately distinct: staleness in the -first means *poke the bots*, staleness in the second means *the agent dropped -the ball*. Collapsing them loses exactly the information a sweep needs. -`bots-reviewing` therefore means strictly *a request is live and an answer is -coming* — a PR nobody was asked to review is the agent's ball, not the bots'. - -## The second axis: `blocker:*` - -State answers *whose ball is it*. Blockers answer *what is in the way*, and -unlike states they are *facts about the branch* — mutually independent, so a -PR carries as many as apply. - -| Label | Color | Means | Clears when | -|---|---|---|---| -| `blocker:conflict` | `#B60205` | GitHub says `CONFLICTING` — the agent owes a **rebase** | it merges cleanly | -| `blocker:ci-red` | `#B60205` | a check failed — the agent owes a **fix**, which a rebase will not provide | checks are green | -| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody — never reviewed, or staled by a push — and **nobody was asked** for one | reviews are requested | -| `blocker:drill-pending` | `#E99695` | a `release` PR whose version has **no drill record** at [`drills/.md`](drills/README.md) — the ceremony is correct but *unevidenced* | the drill is run and recorded, or a maintainer waiver is recorded for that version | - -`blocker:drill-pending` is the one blocker that is not about the code: the -branch merges, the checks that read the tree are green, and the release is -still not shippable because nothing says it was ever run on real hardware. -`.github/scripts/drill-recorded.sh` is the authority — the label just makes -the reason legible on the board, so a release PR sitting still reads as -"waiting on an afternoon of hardware", not as "forgotten". It only ever -appears on a `release` PR: every `-dev` tree satisfies the guard vacuously. - -It is the one `blocker:*` the reconciler does **not** compute — its `BLOCKERS` -set is the three above — so it is applied by hand and, being outside that set, -is not stripped on the next sweep. (The red check itself still shows up as -`blocker:ci-red`; this label says *which* red.) - -**A maintainer account must create this label.** The bot account 403s on label -creation, so until someone with push access runs the `gh label create` line -below, use plain `blocked` on such a PR — it carries the right meaning -(waiting on something else to happen first) and the staleness sweep already -exempts it. - -One rule joins the axes: **`state:needs-human` requires zero blockers.** Any -blocker means the work is the agent's, whatever the review round says. - -This split exists because the single-label version kept lying. Independent -facts were projected onto one totally-ordered label, so one always had to win -and the losers vanished off the board: a PR that was *both* conflicted and red -could only say one of them, and `needs-rebase` told an agent to rebase when -what it actually owed was a bug fix. Precedence between two blockers is not a -question a set has to answer, which is why every ordering bug this machine has -had — `needs-human` surviving a conflict, `MISSING` swallowing `STALE` — lived -on the axis that had to be totally ordered. - -`state:needs-rebase` was the first attempt at this and is **retired**; the -reconciler strips it on sight so no PR is left carrying a label nothing -recomputes. - -**`state:needs-human` means one thing: a human could merge this right now.** -The label is the only signal a maintainer scanning the board (or a phone) -actually reads, and one that says "your turn" on an unmergeable PR is worse -than no label at all. So beyond the blockers, one review fact also outranks an -explicit human request: - -- **nobody reviewed *this* head** — every approval staled by a push → `state:addressing`, - because the agent owes a re-request - -That case is more dangerous than any blocker: a blocked PR at least shows an X -or a disabled merge button, while a staled-approval PR reads green, mergeable -and "waiting on the human" over code no reviewer has seen. - -`UNKNOWN` mergeability is deliberately **not** treated as a conflict. GitHub -reports it for about a minute after every merge while it recomputes, and -flapping every open PR through `blocker:conflict` on each merge would be worse -than the bug this fixes. A failed read of either branch fact degrades to the -same "do not know" value, for the same reason. - -An *unfinished* round still yields to an explicit human request — a maintainer -pulling a PR to themselves early is a deliberate act. `MISSING` (nobody has -reviewed yet) and `STALE` (everyone reviewed something else) are different -facts and are treated differently. - -## Cross-cutting (PRs and issues) - -| Label | Color | Meaning | -|---|---|---| -| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. | -| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. | -| `release` | `#0E8A16` | Release flow, versioning, and packaging work. | -| `merge-next` | `#0E8A16` | Head of the merge queue — **merge this one next**. Queue order is *intent* (which PR lands first, given how they conflict), so the reconciler never sets it: you or the agent maintaining the queue do. The reconciler only **clears** it, the moment the PR stops being something a human could merge — so it cannot go stale the way `state:needs-human` did. | - -## Scope — which surface? (PRs and issues, any number) - -All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. - -| Label | Covers | -|---|---| -| `scope:bootstrap` | `commands/bootstrap.sh` — hardening a pristine server into a node | -| `scope:users` | `commands/users-*` — the root-door model, apply/status, close-root | -| `scope:runner` | `commands/runner-*` — GitHub runner install/remove/repoint/status | -| `scope:coolify` | `commands/coolify-*` — Coolify and its backup install | -| `scope:db` | `commands/db.sh` — dump/restore and the round-trip proof | -| `scope:installer` | `install.sh` — how rig itself lands on a machine | - -## Issue types - -`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in -the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label -would just say the same thing twice, drifting apart eventually. - -## Maintenance - -State labels are machine-owned, with exactly one exception. Every state above -is derivable from GitHub's own facts — the draft flag, requested reviewers, -review states, push timestamps — so the labels workflow -([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the -state and reconciles labels statelessly, on PR events (label changes included) -plus a 15-minute cron. A hand-moved label is a lie waiting to happen; the -workflow asserts the effective state instead. - -The exception is `state:needs-human`, which the author sets at handoff -([CONTRIBUTING.md](CONTRIBUTING.md), step 6). That is an optimistic write, not -a transfer of ownership: because `pull_request_target: labeled` wakes the -workflow, the author's own label write fires the sweep that validates it, and -a handoff that had not earned the label is corrected within seconds. - -It exists because the wake signal was missing. There is no -`pull_request_review_target` — on fork PRs, which is all of them here, -`pull_request_review` runs read-only and cannot label anything — so the moment -the label becomes true, the third approval landing, fired nothing at all. What -was left was the `*/15` cron, and GitHub deprioritises short intervals hard -enough that the delivered rate is closer to hourly. The label could therefore -lag the round it described by hours, worst on the quietest repo: every sweep -reconciles the whole board, so a busy repo stays fresh by piggybacking on -unrelated PR events, while a quiet one depends on the cron most and receives -it least. `scope:` labels on PRs are applied from the changed -paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml)); -[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what. - -The same workflow bootstraps the taxonomy: a manual dispatch creates any -missing label idempotently. To create them by hand (needs push access): - -```sh -gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force -gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force -gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force -gh label create "blocker:conflict" --color B60205 --description "Does not merge — the branch conflicts and the agent owes a rebase" --force -gh label create "blocker:ci-red" --color B60205 --description "A check is failing — the agent owes a fix (not a rebase)" --force -gh label create "blocker:unrequested" --color E99695 --description "Somebody still owes a verdict and nobody was asked for one" --force -# Needs a MAINTAINER account — the bot 403s on label creation. Until it exists, `blocked` stands in. -gh label create "blocker:drill-pending" --color E99695 --description "Release PR with no drill record at drills/.md — correct but unevidenced" --force -# retired — the reconciler strips it; delete it once no PR carries it -# gh label delete "state:needs-rebase" -gh label create "state:needs-human" --color 8250DF --description "No blockers, all bots approve — waiting on the human reviewer" --force -gh label create "merge-next" --color 0E8A16 --description "Head of the merge queue — merge this one next (set by hand/agent, cleared here)" --force -gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force -gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force -gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force -gh label create "scope:bootstrap" --color C5DEF5 --description "bootstrap — hardening a pristine server into a node" --force -gh label create "scope:users" --color C5DEF5 --description "users-* — root-door model, apply/status, close-root" --force -gh label create "scope:runner" --color C5DEF5 --description "runner-* — GitHub runner lifecycle" --force -gh label create "scope:coolify" --color C5DEF5 --description "coolify-* — Coolify and backup install" --force -gh label create "scope:db" --color C5DEF5 --description "db.sh — dump/restore" --force -gh label create "scope:installer" --color C5DEF5 --description "install.sh — how rig lands on a machine" --force -# delete is not an upsert: a label that is already gone exits non-zero. Swallow -# that, so this block converges on re-run instead of erroring after first success. -for L in duplicate invalid question wontfix "help wanted" "good first issue"; do - gh label delete "$L" --yes 2>/dev/null || true -done -``` diff --git a/drills/README.md b/drills/README.md index 28afea0..3a8af5b 100644 --- a/drills/README.md +++ b/drills/README.md @@ -24,7 +24,8 @@ The gate reads a file in this repo, and nothing else. ## What the gate requires -`.github/scripts/drill-recorded.sh` runs on every PR. On a `-dev` tree it +The `drill-recorded` guard (heavy-duty/ceremony's action, pinned in +`ci.yml`) runs on every PR. On a `-dev` tree it asserts nothing — a development tree has no release to evidence. On a bare `VERSION` — a release ceremony tree — it requires `drills/.md` to exist and to hold at least one non-whitespace character. An empty file, or one diff --git a/test/labels-reconcile.sh b/test/labels-reconcile.sh deleted file mode 100644 index cce4691..0000000 --- a/test/labels-reconcile.sh +++ /dev/null @@ -1,416 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Fixture tests for the labels-reconcile state machine: a comment is a -# non-verdict whatever its body says (the AUTHOR escalates by requesting the -# human), a stale approval does not promote unreviewed code, and an explicit -# human request outranks everything. -# Dependency-free beyond jq; no network, no daemon — pure decide_state. - -cd "$(dirname "$0")/.." -# shellcheck source=.github/scripts/labels-reconcile.sh -. .github/scripts/labels-reconcile.sh - -# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state -# machine's inputs, consumed inside the sourced decide_state — not unused. -# shellcheck disable=SC2034 -BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}" -pass=0 fail=0 - -expect() { # $1 = description, $2 = want, $3 = got - if [ "$2" = "$3" ]; then - pass=$((pass + 1)) - else - fail=$((fail + 1)) - printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3" - fi -} - -rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object - jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \ - '{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}' -} - -reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array - -# -- drafts are building, whoever is requested -------------------------------- -DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]' -expect "draft PR is building" state:building "$(decide_state)" - -# -- fresh ready PR with bots requested --------------------------------------- -DRAFT=false REQUESTED="$BOT1 -$BOT2 -$BOT3" REVIEWS_JSON='[]' -expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)" - -# -- a bot that never reviewed keeps the round open --------------------------- -# With a live request that is the bots' ball; with NO request outstanding it -# is the agent's, because nothing is coming until somebody asks. -REQUESTED="$BOT3" REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)")" -expect "a missing bot WITH a live request is bots-reviewing" state:bots-reviewing "$(decide_state)" -REQUESTED="" -expect "...but with nobody asked it is the agent's ball" state:addressing "$(decide_state)" -expect "...and the blocker names the stall" blocker:unrequested "$(blockers)" - -# -- a comment is a non-verdict, agreement body or not: the author escalates -- -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)" -# ...and the author's escalation — requesting the human — flips it -REQUESTED="$HUMAN" -expect "author escalation flips to needs-human" state:needs-human "$(decide_state)" -REQUESTED="" - -# -- three formal approvals need no author judgment --------------------------- -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)" - -# -- a comment WITHOUT a verdict parks the PR on the agent -------------------- -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "comment without verdict is addressing" state:addressing "$(decide_state)" - -# -- changes requested blocks, at any head ------------------------------------ -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)" - -# -- a stale approval must not promote unreviewed code ------------------------ -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED old1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)" - -# -- a re-requested bot reopens the round even with an old approval on file --- -REQUESTED="$BOT1" -expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)" -REQUESTED="" - -# -- only the LATEST review per bot counts ------------------------------------ -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \ - "$(rev "$BOT1" APPROVED head1 "" t2)" \ - "$(rev "$BOT2" APPROVED head1 "" t3)" \ - "$(rev "$BOT3" APPROVED head1 "" t4)")" -expect "later approval supersedes earlier block" state:needs-human "$(decide_state)" - -# -- an explicit human request outranks the bot rounds ------------------------ -REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")" -expect "human requested outranks bots" state:needs-human "$(decide_state)" -REQUESTED="" - -# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------ -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)" \ - "$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")" -expect "human block with bots approving is addressing" state:addressing "$(decide_state)" -# ...and re-requesting the human hands it back to them -REQUESTED="$HUMAN" -expect "re-requested human is needs-human again" state:needs-human "$(decide_state)" -REQUESTED="" - -# -- an old human comment must not wedge the handoff (codex, #85 round 3) ----- -REVIEWS_JSON="$(reviews \ - "$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)" -expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)" -# ...a stale human APPROVAL likewise needs a re-request for the new head -REVIEWS_JSON="$(reviews \ - "$(rev "$HUMAN" APPROVED old1 "" t0)" \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)" -# ...a HEAD-CURRENT human approval needs nothing more -REVIEWS_JSON="$(reviews \ - "$(rev "$HUMAN" APPROVED head1 "" t0)" \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" -expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)" -# ...and a live request suppresses re-requesting -REQUESTED="$HUMAN" -expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)" -REQUESTED="" - -# --------------------------------------------------------------------------- -# #136: state:needs-human must mean "a human could merge this RIGHT NOW". -# Both cases below were observed live in this repo on 2026-07-20, and both -# showed state:needs-human while being unmergeable in different ways. -# --------------------------------------------------------------------------- -ALL_APPROVE="$(reviews \ - "$(rev "$BOT1" APPROVED head1 "" t1)" \ - "$(rev "$BOT2" APPROVED head1 "" t2)" \ - "$(rev "$BOT3" APPROVED head1 "" t3)")" - -# -- flavour 1: not mergeable. The merge button is disabled, yet the board -# said "your turn" on #119/#120/#127 for hours. The branch fact now rides -# the blocker axis; the state says whose ball it is, which is the agent's. -DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=CONFLICTING CHECKS=SUCCESS -expect "a CONFLICTING PR is the agent's, not the human's" state:addressing "$(decide_state)" -expect "...and says WHY on the blocker axis" blocker:conflict "$(blockers)" -REQUESTED="$HUMAN" -expect "...even with the human explicitly requested" state:addressing "$(decide_state)" - -# -- red CI is the same claim, but NOT the same work: a rebase does not fix a -# failing test. Collapsing both into one needs-rebase label told the agent -# to do the wrong thing, which is why the axis split exists. -REQUESTED="" MERGEABLE=MERGEABLE CHECKS=FAILURE -expect "a red PR is the agent's" state:addressing "$(decide_state)" -expect "...and is distinguishable from a conflict" blocker:ci-red "$(blockers)" -REQUESTED="$HUMAN" -expect "...and a human request does not override red CI" state:addressing "$(decide_state)" - -# -- both at once. The single-axis design could not say this at all: one label -# had to win, and the loser silently vanished off the board. -REQUESTED="" MERGEABLE=CONFLICTING CHECKS=FAILURE -expect "a conflicted AND red PR reports both blockers" "blocker:conflict -blocker:ci-red" "$(blockers)" -expect "...and is still just the agent's ball" state:addressing "$(decide_state)" - -# -- UNKNOWN is NOT unmergeable. GitHub reports it for ~a minute after every -# merge while it recomputes; treating it as broken would flap every open PR -# on each merge — worse than the bug being fixed. -REQUESTED="" MERGEABLE=UNKNOWN CHECKS=PENDING -expect "UNKNOWN mergeability blocks nothing" state:needs-human "$(decide_state)" -expect "...and raises no blocker" "" "$(blockers)" - -# -- blocker:unrequested — the stalled round. Nobody owes an answer because -# nobody was ever asked, yet the board read "waiting on the bots" until -# `stale` noticed 48h later. -MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]' -expect "ready, nobody asked, nothing reviewed raises unrequested" blocker:unrequested "$(blockers)" -# ...the partial case is equally stalled: one verdict in, nobody asked for the rest -REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")" -expect "one bot in, none requested is still unrequested" blocker:unrequested "$(blockers)" -# ...a STALE round with nobody asked is the same debt, and arguably worse: the -# page carries approvals that no longer describe the tree. Guarding on -# MISSING alone let this one through with no blocker at all. -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED oldhead "" t1)" \ - "$(rev "$BOT2" APPROVED oldhead "" t2)" \ - "$(rev "$BOT3" APPROVED oldhead "" t3)")" -expect "a stale round with nobody asked is unrequested too" blocker:unrequested "$(blockers)" -expect "...and is still the agent's ball" state:addressing "$(decide_state)" -# ...but a live request means an answer IS coming -REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")" -REQUESTED="$BOT2" -expect "a live bot request is not a stalled round" "" "$(blockers)" -# ...and a draft is exempt: the bots ignore drafts by design -DRAFT=true REQUESTED="" REVIEWS_JSON='[]' -expect "a draft with nobody asked is not stalled" "" "$(blockers)" -# ...as is an explicit human request — claiming a PR early is deliberate -DRAFT=false REQUESTED="$HUMAN" -expect "an early human claim is not a stalled round" "" "$(blockers)" -REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS - -# -- flavour 2 (the dangerous one): mergeable, green, human requested, and -# NOBODY has reviewed this head. Observed on #119 after a rebase: every -# signal read "merge me" and nothing on the page contradicted it. -MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="$HUMAN" -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED oldhead "" t1)" \ - "$(rev "$BOT2" APPROVED oldhead "" t2)" \ - "$(rev "$BOT3" APPROVED oldhead "" t3)")" -expect "stale approvals outrank the human request (nobody reviewed this tree)" state:addressing "$(decide_state)" - -# -- ...and a round that is BOTH unfinished and staled is still the agent's. -# Deciding inside the bot loop made this depend on BOTS order: the MISSING -# returned before any later bot's STALE was read, so the mixed round came -# out needs-human with nothing bound to the head. Pinned at both ends of -# the array, because the whole failure was one of ordering. -MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="$HUMAN" -REVIEWS_JSON="$(reviews \ - "$(rev "$BOT1" APPROVED oldhead "" t1)" \ - "$(rev "$BOT2" APPROVED oldhead "" t2)")" -expect "stale approvals + a bot yet to review is addressing, not needs-human" \ - state:addressing "$(decide_state)" -REVIEWS_JSON="$(reviews "$(rev "$BOT3" APPROVED oldhead "" t3)")" -expect "...and the same when the stale verdict is the LAST bot in BOTS" \ - state:addressing "$(decide_state)" - -# -- but an UNFINISHED round still yields to an explicit human request: a -# maintainer pulling a PR to themselves early is deliberate, and was the -# original precedence. MISSING differs from STALE — nobody has reviewed -# YET, versus everyone reviewed something else. -REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")" -expect "an unfinished round still yields to an explicit human request" state:needs-human "$(decide_state)" -REQUESTED="" -expect "...and without that request the agent owes the ask" state:addressing "$(decide_state)" - -# --------------------------------------------------------------------------- -# checks_state: the rollup classifier. It lived inline in main() for the first -# round of this PR, which is why nothing here caught it calling ERROR, -# CANCELLED and STALE green. Extracted so the enum can be pinned down. -# --------------------------------------------------------------------------- -rollup() { jq -n --argjson c "$1" '{statusCheckRollup: $c}'; } -run_() { jq -n --arg n "$1" --arg o "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \ - '{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o, completedAt:$t}'; } -ctx_() { jq -n --arg n "$1" --arg s "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \ - '{__typename:"StatusContext", context:$n, state:$s, createdAt:$t}'; } - -expect "no checks at all is NONE" NONE "$(rollup '[]' | checks_state)" -# A failed fetch leaves no rollup KEY; a PR with no checks leaves an empty -# ARRAY. Collapsing the two let an API hiccup read as "nothing is failing" — -# the same unknown-certified-as-green shape as #136, in the one place that -# fix did not look. The caller skips an UNREADABLE PR rather than relabelling. -expect "a failed read is UNREADABLE, not NONE" UNREADABLE "$(echo '{}' | checks_state)" -expect "...and a real empty rollup is still NONE" NONE \ - "$(echo '{"mergeable":"MERGEABLE","statusCheckRollup":[]}' | checks_state)" -expect "all green is SUCCESS" SUCCESS \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS)]" | checks_state)" -expect "a queued run is PENDING" PENDING \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b QUEUED)]" | checks_state)" -expect "a plain failure is FAILURE" FAILURE \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b FAILURE)]" | checks_state)" - -# -- the round-1 gap: outcomes that are neither success nor pending, and that -# leave a required check unsatisfied. All three reached the old `else`. -expect "a commit status ERROR blocks" FAILURE \ - "$(rollup "[$(run_ a SUCCESS),$(ctx_ lint ERROR)]" | checks_state)" -expect "a CANCELLED run blocks" FAILURE \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b CANCELLED)]" | checks_state)" -expect "a STALE run blocks" FAILURE \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b STALE)]" | checks_state)" -expect "an outcome the enum does not know blocks, it does not pass" FAILURE \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b SOME_FUTURE_STATE)]" | checks_state)" - -# -- NEUTRAL and SKIPPED satisfy branch protection; path-filtered jobs skip -# constantly, and calling that red would park every PR on the agent. -expect "NEUTRAL and SKIPPED are not failures" SUCCESS \ - "$(rollup "[$(run_ a SUCCESS),$(run_ b NEUTRAL),$(run_ c SKIPPED)]" | checks_state)" - -# -- latest-wins. The rollup keeps superseded runs, so this PR's own tip -# carried a CANCELLED `scope` beside the SUCCESS `scope` that replaced it. -# Without collapsing, making CANCELLED block would strand it forever. -expect "a re-run supersedes the cancelled original" SUCCESS \ - "$(rollup "[$(run_ scope CANCELLED 2026-07-20T15:19:39Z),\ - $(run_ scope SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)" -expect "...and the reverse order is not a re-run passing, it is one failing" FAILURE \ - "$(rollup "[$(run_ scope SUCCESS 2026-07-20T15:19:39Z),\ - $(run_ scope CANCELLED 2026-07-20T15:19:45Z)]" | checks_state)" -# same job name in a different workflow is a different context, not a re-run -expect "same name in another workflow does not supersede" FAILURE \ - "$(rollup "[$(jq -n '{__typename:"CheckRun",workflowName:"labels",name:"scope",conclusion:"FAILURE",completedAt:"2026-07-20T15:00:00Z"}'),\ - $(run_ scope SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)" - -# -- a run still IN FLIGHT. `run_()` cannot express this: it always carries a -# real completedAt, which is exactly why the supersede rule shipped dating -# runs by completion and nothing caught it. Both spellings of "no -# completion" are pinned, because `gh` emits the zero sentinel (a string, -# which `//` does not fall through) while the API emits null. -inflight_() { jq -n --arg n "$1" --arg t "$2" --arg c "${3:-0001-01-01T00:00:00Z}" \ - '{__typename:"CheckRun", workflowName:"ci", name:$n, status:"IN_PROGRESS", - conclusion:"", startedAt:$t, completedAt:(if $c == "null" then null else $c end)}'; } - -expect "a re-run in flight beats the success it superseded (zero sentinel)" PENDING \ - "$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\ - $(inflight_ build 2026-07-20T15:10:00Z)]" | checks_state)" -expect "...and the same when the absent completion is null" PENDING \ - "$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\ - $(inflight_ build 2026-07-20T15:10:00Z null)]" | checks_state)" -expect "a replacement in flight for a CANCELLED run is pending, not failed" PENDING \ - "$(rollup "[$(run_ build CANCELLED 2026-07-20T15:00:00Z),\ - $(inflight_ build 2026-07-20T15:10:00Z)]" | checks_state)" -# an entry carrying no usable timestamp is treated as newest, not oldest — -# ambiguity resolves toward "not settled" rather than toward a stale success. -# Guarded by the sort tiebreak rather than the dating expression: reverting -# only `at:` leaves this passing, so the two changes are separately pinned. -expect "an undateable in-flight run is not discarded for a stale success" PENDING \ - "$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\ - $(jq -n '{__typename:"CheckRun",workflowName:"ci",name:"build",conclusion:"",startedAt:null,completedAt:null}')]" \ - | checks_state)" -# ...and the reverse direction, which stops "in flight sorts last" being -# widened into "in flight always wins": a run that FINISHED after an earlier -# in-flight entry is the newer word, and the context is settled. -expect "a finished re-run supersedes an earlier in-flight run" SUCCESS \ - "$(rollup "[$(inflight_ build 2026-07-20T15:19:00Z),\ - $(run_ build SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)" - -# -- the wind-down window. A predecessor cancelled by the concurrency group -# does not stop the instant its replacement starts, so its completion -# routinely lands AFTER the successor's start — on box's aa5a6ba the -# replacement started 15:19:38 and the run it cancelled finished 15:19:51. -# Dating by "newest stamp of any kind" compares the dead run's completion -# against the live run's start, which is not an ordering on runs, and the -# predecessor wins. Every fixture above spaces completion before start, so -# none of them can see it. run_() cannot express the overlap either — it -# carries no startedAt — hence the explicit payloads. -overlap_() { jq -n --arg n "$1" --arg o "$2" --arg s "$3" --arg c "$4" \ - '{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o, - startedAt:$s, completedAt:$c}'; } -expect "a predecessor finishing after its replacement started is still older (CANCELLED)" PENDING \ - "$(rollup "[$(overlap_ scope CANCELLED 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\ - $(inflight_ scope 2026-07-20T15:19:38Z)]" | checks_state)" -expect "...and the same when it finished green — mid-flight is not mergeable" PENDING \ - "$(rollup "[$(overlap_ build SUCCESS 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\ - $(inflight_ build 2026-07-20T15:19:38Z)]" | checks_state)" - -# -- the classifier feeds the state machine: a cancelled required check must -# take the PR off the human's plate, which is the whole point of #136. -DRAFT=false HEAD_SHA=head1 REQUESTED="$HUMAN" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE -CHECKS="$(rollup "[$(run_ a SUCCESS),$(run_ b CANCELLED)]" | checks_state)" -expect "a cancelled check reaches decide_state as the agent's ball" state:addressing "$(decide_state)" -expect "...via blocker:ci-red, not a conflict" blocker:ci-red "$(blockers)" - -# -- the happy path survives all of the above. -REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" -expect "mergeable + green + three head-current approvals is needs-human" state:needs-human "$(decide_state)" -# -- and a draft outranks everything, including a conflict. -DRAFT=true MERGEABLE=CONFLICTING -expect "a draft is building even when conflicted" state:building "$(decide_state)" -DRAFT=false MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]' - -# --------------------------------------------------------------------------- -# reconcile_pr's cold-start path. Everything above tests pure functions, which -# is exactly why a per-PR `return` in the label pre-flight got through review: -# the fixtures could not reach it. A missing state:* label must skip the label -# EDIT only — merge-next clearing and the stale sweep are independent of the -# taxonomy, and stranding them reintroduced the false-invitation bug (a -# `merge-next` claim surviving on a PR the board had moved to the agent). -# --------------------------------------------------------------------------- -reconcile_probe() { # $1 = REPO_LABELS content → the log lines reconcile_pr emits - ( - REPO_LABELS="$1" REPO=owner/repo NOW="$(date +%s)" - LABELS="merge-next" # the PR carries a queue claim - DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]' - MERGEABLE=MERGEABLE CHECKS=SUCCESS - PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}' - run() { :; } # swallow mutations - gh() { :; } # no network - reconcile_pr 777 2>&1 - ) -} - -cold="$(reconcile_probe "merge-next")" # state:* labels absent entirely -expect "a cold-start repo still clears merge-next" \ - yes "$(grep -q 'cleared merge-next' <<<"$cold" && echo yes || echo no)" -expect "...and still runs the stale sweep" \ - yes "$(grep -q 'stale (' <<<"$cold" && echo yes || echo no)" -expect "...while warning that the state label is missing" \ - yes "$(grep -q "state label 'state:addressing' does not exist" <<<"$cold" && echo yes || echo no)" - -warm="$(reconcile_probe "$(printf 'state:addressing\nmerge-next\nstale\nblocker:unrequested')")" -expect "a bootstrapped repo converges the state as well" \ - yes "$(grep -q 'state -> state:addressing' <<<"$warm" && echo yes || echo no)" - -printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" -[ "$fail" -eq 0 ] diff --git a/test/release.sh b/test/release.sh index 2ff49b0..d890489 100644 --- a/test/release.sh +++ b/test/release.sh @@ -1,17 +1,16 @@ #!/usr/bin/env bash -# The release flow's testable half (#32): changelog extraction, latest-tag -# resolution, and the installer's three channels. Dependency-free and +# Rig's own half of the release surface (#32; trimmed in ceremony#13's +# conversion): latest-tag resolution and the installer's three channels. +# The machinery halves — changelog extraction, the arming rule, +# monotonicity, the drill gate, the workflow-shape pins — moved to +# heavy-duty/ceremony, which tests them in its own test/; what stays is +# everything that drives rig's install.sh and bin/. Dependency-free and # NETWORK-FREE — wherever the code under test would call curl, the curl on # PATH is a stub this harness wrote. Run: bash test/release.sh # Deliberately no `set -e` — the harness asserts on failing commands. set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" || exit 1 -# The extraction the workflow runs is the extraction under test — one -# function, sourced by release.yml and by this harness (repo precedent: -# test/labels-reconcile.sh sourcing the reconciler's decide_state). -# shellcheck source=.github/scripts/release-lib.sh -. "$ROOT/.github/scripts/release-lib.sh" PASS=0 FAIL=0 # check @@ -37,745 +36,6 @@ check() { WORK="$(mktemp -d)" FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME" -# --- changelog_section: the release body, extracted -------------------------- -# A fixture changelog with the three heading shapes the flow produces: the -# bare '## Unreleased', stamped '## X.Y.Z — date' releases, and a last -# section that runs to EOF. -FIXCH="$WORK/CHANGELOG.fixture.md" -cat > "$FIXCH" <<'MD' -# Changelog - -History before 0.1.0 lives in git. - -## Unreleased - -- an unreleased entry - -## 0.2.0 — 2026-07-18 - -### Added - -- **the newer entry** (#42) — prose. - -### Fixed - -- a fix in 0.2.0 - -## 0.1.0 — 2026-07-01 - -- the first entry -MD -sect_has() { changelog_section "$1" "$2" | grep -qF -e "$3"; } -check "changelog: extracts the asked-for section" 0 "the newer entry" \ - changelog_section "$FIXCH" 0.2.0 -check "changelog: the whole section, subheadings included" 0 "a fix in 0.2.0" \ - changelog_section "$FIXCH" 0.2.0 -check "changelog: stops at the next release heading" 1 "" \ - sect_has "$FIXCH" 0.2.0 "the first entry" -check "changelog: never leaks the preceding section" 1 "" \ - sect_has "$FIXCH" 0.2.0 "an unreleased entry" -check "changelog: the heading itself is not the body" 1 "" \ - sect_has "$FIXCH" 0.2.0 "## 0.2.0" -first_line() { changelog_section "$1" "$2" | head -n1; } -check "changelog: leading blank lines are dropped" 0 "### Added" \ - first_line "$FIXCH" 0.2.0 -check "changelog: the bare Unreleased heading matches too" 0 "an unreleased entry" \ - changelog_section "$FIXCH" Unreleased -check "changelog: the last section runs to EOF" 0 "the first entry" \ - changelog_section "$FIXCH" 0.1.0 -absent() { [ -z "$(changelog_section "$1" "$2")" ]; } -check "changelog: an unknown version yields NOTHING (the refusal signal)" 0 "" \ - absent "$FIXCH" 3.3.3 -check "changelog: a date-stamped heading never matches by date" 0 "" \ - absent "$FIXCH" 2026-07-18 - -# ...and the SHIPPED changelog fits the extractor. The real file has two -# legitimate states, and the old check knew only one (#44, found the day the -# first release PR turned CI red): BETWEEN releases there is an `## Unreleased` -# section feature PRs append to; on a `release: X.Y.Z` tree — and on main -# right after it, until the next feature PR — that section IS the stamped -# `## X.Y.Z — date`. Demanding the literal heading (or, worse, an issue -# number inside it) made the release PR of the ceremony unshippable by -# construction. What the guard is FOR is format drift: whatever the top -# section is called, the exact function release.yml runs must extract it -# non-empty. -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "CHANGELOG.md: has a top section (Unreleased or a stamped release)" 0 "" \ - bash -c '[ -n "$(grep -m1 "^## " "$1")" ]' _ "$ROOT/CHANGELOG.md" - -# --- the arming rule: is main's changelog ready for a late merge? ------------ -# #66: stamping the Unreleased heading DISARMS the file. A PR authored before -# a release and merged after it wrote its entry under `## Unreleased`; once -# that heading has become `## X.Y.Z — date`, git lands the entry under the -# release that already shipped — cleanly, no conflict, nothing for the author -# to notice. It happened here: #60's #58 entry landed inside `## 0.1.0` at -# 67386b4, repaired two minutes later by 0ff520c. -# -# The check above cannot see this, and #44 is why: demanding a literal -# `## Unreleased` is FALSE BY CONSTRUCTION on the tree the ceremony's own PR -# produces, which made the release PR unshippable. That relaxation must not -# be undone. -# -# What distinguishes the two states the old guard collapsed is VERSION. -# A stamped top section is legal exactly when VERSION is bare — the ceremony -# PR, and main until the -dev bump lands. The moment VERSION carries -dev, -# main is a place feature PRs merge into, and the top section MUST be -# `## Unreleased` or the next late merge is misfiled. -# -# Note the asymmetry, which is deliberate: on a BARE version the top heading -# is not constrained at all. The ceremony re-arms in the same PR -# (CONTRIBUTING step 1), so its tree legitimately carries an EMPTY -# `## Unreleased` above the section it just stamped — and an empty top -# section is exactly what the old non-empty assert would have rejected. -# What must extract non-empty on a bare VERSION is the section that SHIPS, -# which is the same assert release.yml makes before it publishes. -# -# changelog_armed — 0 armed, 1 disarmed. -changelog_armed() { - local ver="$1" file="$2" top - top="$(grep -m1 '^## ' "$file")" - [ -n "$top" ] || return 1 - case "$ver" in - *-dev) [ "$top" = "## Unreleased" ] ;; - *) [ -n "$(changelog_section "$file" "$ver")" ] ;; - esac -} - -# The guard itself, against the real tree. -check "CHANGELOG.md: armed for the VERSION it carries (#66)" 0 "" \ - changelog_armed "$(cat "$ROOT/VERSION")" "$ROOT/CHANGELOG.md" - -# ...and the rule proven against trees built for the purpose, because a guard -# that is only ever run against a passing tree has not been shown to fail. -# Each is a real VERSION + CHANGELOG.md pair the flow actually produces. -armtree() { # armtree -> prints the dir - local d="$WORK/arm-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION" - shift 2; printf '%s\n' "$@" > "$d/CHANGELOG.md"; printf '%s' "$d" -} -armed() { changelog_armed "$(cat "$1/VERSION")" "$1/CHANGELOG.md"; } - -# The ceremony PR's own tree, re-armed per CONTRIBUTING step 1: VERSION bare, -# an empty Unreleased sitting above the section it just stamped. GREEN — this -# is the case #44 was about, and the empty section must not break it. -T="$(armtree ceremony 0.2.0 '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')" -check "arming: the re-armed ceremony tree passes (#44 stays fixed)" 0 "" armed "$T" - -# The same ceremony WITHOUT the re-arm — old-style, stamped straight over the -# heading. Also GREEN: VERSION is bare, so a stamped top is legal. The guard -# refuses to make the ceremony unshippable, which is the whole #44 lesson. -T="$(armtree ceremony-old 0.2.0 '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')" -check "arming: an un-re-armed ceremony tree still passes (bare VERSION)" 0 "" armed "$T" - -# main AFTER release.yml's -dev bump, with the changelog left disarmed. This -# is #66 exactly, and the state cast sat in at the time of writing. RED. -T="$(armtree disarmed 0.2.1-dev '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')" -check "arming: a -dev main with a stamped top section FAILS (#66)" 1 "" armed "$T" - -# The same main, re-armed. The Unreleased section is EMPTY — no feature PR has -# merged since the release — and that is a correct, expected state. GREEN. -T="$(armtree rearmed 0.2.1-dev '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')" -check "arming: a -dev main with an EMPTY Unreleased passes (no entries yet)" 0 "" armed "$T" - -# Steady state between releases: entries accumulating under Unreleased. -T="$(armtree steady 0.2.1-dev '# Changelog' '' '## Unreleased' '' '### Fixed' '' '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')" -check "arming: the normal between-releases tree passes" 0 "" armed "$T" - -# A release PR that bumped VERSION but forgot to stamp: the version it claims -# to ship has no section, so release.yml would publish empty notes. RED here, -# one round earlier than the workflow's own refusal. -T="$(armtree unstamped 0.3.0 '# Changelog' '' '## Unreleased' '' '- **A pending thing** (#2) — prose.')" -check "arming: a bare VERSION whose section was never stamped FAILS" 1 "" armed "$T" - -# And a file with no '## ' heading at all is disarmed, not silently fine. -T="$(armtree headless 0.2.1-dev '# Changelog' '' 'no sections here')" -check "arming: a changelog with no sections FAILS" 1 "" armed "$T" - -# --- the monotonicity rule: was a SHIPPED heading deleted? ------------------- -# #98. Arming asks about ONE heading — does the top section agree with -# VERSION? — so it is silent about the rest of the file. The failure it cannot -# see is an entry written under '## Unreleased' that REPLACES the heading -# below it instead of inserting above it: git merges the one-line edit -# cleanly, arming stays green (the top section is still right), and the -# shipped release loses its section entirely. "A heading disappeared" is not a -# property of a tree, it is a property of a DIFF — so unlike every check -# above, these cases need real git repos, which is why the guard is its own -# script rather than a function sourced here. -MONO="$ROOT/.github/scripts/changelog-monotonic.sh" -check "changelog-monotonic.sh: exists and is the guard under test" 0 "" test -f "$MONO" - -# The stock changelog every case below starts from: an Unreleased section and -# two shipped releases, committed on branch 'base' — which plays origin/main. -# The caller then rewrites CHANGELOG.md on 'work' and commits. -MONO_BASE=('# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ - '- **The first thing** (#0) — prose.') -monorepo() { # monorepo -> prints the dir, left checked out on 'work' - local d="$WORK/mono-$1"; mkdir -p "$d" - git -C "$d" init -q -b base - git -C "$d" config user.email harness@example.invalid - git -C "$d" config user.name harness - printf '%s\n' "${MONO_BASE[@]}" > "$d/CHANGELOG.md" - git -C "$d" add CHANGELOG.md - git -C "$d" commit -qm 'base: two shipped releases' - git -C "$d" checkout -q -b work - printf '%s' "$d" -} -monowrite() { # monowrite — rewrite CHANGELOG.md and commit - local d="$1"; shift - printf '%s\n' "$@" > "$d/CHANGELOG.md" - git -C "$d" commit -qam 'work: edit the changelog' -} -mono() { # mono [VAR=val ...] — run the guard there, base ref 'base' - local d="$1"; shift - ( cd "$d" && env "$@" bash "$MONO" base 2>&1 ) -} - -# An untouched branch with NO commit of its own: 'work' still points at the -# base commit, so the merge base IS HEAD and containment compared the file -# against itself. That is the vacuous path (#98), not a containment result — -# the green message therefore names uniqueness, the half that actually ran. -# A guard that prints nothing is indistinguishable from one that did nothing, -# but a guard that prints the WRONG half is worse: it is a false receipt. -T="$(monorepo clean)" -check "monotonic: an untouched branch passes" 0 "uniqueness on HEAD checked 2" mono "$T" -check "monotonic: ...saying containment was VACUOUS, not that it verified 2" 0 \ - "containment vacuous" mono "$T" -# A negative, because the point is that the two wordings do NOT collapse: with -# the pull_request gate gone (#98) this is the shape of EVERY push to main, and -# "are still present" there would be a containment claim on the one event where -# deletion is undetectable by construction. -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "monotonic: ...and never claims the headings are still present" 1 "" \ - bash -c 'cd "$1" && bash "$2" base | grep -q "are still present"' _ "$T" "$MONO" - -# The same shape against a REAL base — an unrelated commit on 'work', the -# changelog untouched — which is what an untouched-changelog PR branch -# actually looks like. Here containment genuinely ran and held, so this is -# the case that pins the containment wording and its count. The two forms -# must not collapse into one another. -T="$(monorepo clean-realbase)" -printf '%s\n' '# rig' > "$T/README.md" -git -C "$T" add README.md -git -C "$T" commit -qm 'work: an unrelated commit, changelog untouched' -check "monotonic: an untouched changelog on a REAL base reports containment" 0 \ - "all 2 release heading(s)" mono "$T" -check "monotonic: ...and says they are still present, the containment claim" 0 \ - "are still present" mono "$T" - -# The legitimate edit this guard must never object to: a new entry INSERTED -# above the shipped heading, which is left alone. -T="$(monorepo insert)" -monowrite "$T" '# Changelog' '' '## Unreleased' '' '### Fixed' '' \ - '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ - '- **The first thing** (#0) — prose.' -check "monotonic: an entry inserted ABOVE the shipped heading passes" 0 "" mono "$T" - -# ...and the bug itself: the same entry typed OVER '## 0.2.0'. 0.2.0's body is -# now under '## Unreleased' and 0.2.0 has no section. RED, naming the version. -T="$(monorepo deleted)" -monowrite "$T" '# Changelog' '' '## Unreleased' '' '### Fixed' '' \ - '- **A pending thing** (#2) — prose.' '' \ - '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ - '- **The first thing** (#0) — prose.' -check "monotonic: a DELETED shipped heading FAILS (#98)" 1 "DELETES release heading" mono "$T" -check "monotonic: ...and the failure names the version that vanished" 1 "## 0.2.0" mono "$T" - -# Deleting the OLDEST release is the same defect, not a lesser one — the set -# is a set, position in the file buys no leniency. -T="$(monorepo deleted-old)" -monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A shipped thing** (#1) — prose.' -check "monotonic: deleting an OLDER release heading fails too" 1 "## 0.1.0" mono "$T" - -# The duplicate half. Containment cannot catch this: the second copy is -# head-side SURPLUS and `comm -23` (base minus head) is blind to extras on the -# head side, so uniqueness-on-HEAD is a separate assert. rig's symptom is not -# box's — changelog_section() has `if (found) exit`, so it stops at the second -# copy and TRUNCATES rather than absorbing. -T="$(monorepo dupe)" -monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ - '- **The first thing** (#0) — prose.' -check "monotonic: a DUPLICATED version heading FAILS" 1 "DUPLICATE release heading" mono "$T" -check "monotonic: ...and the failure names the repeated version" 1 "## 0.2.0" mono "$T" -# ...and that the duplicate really does truncate, so the assert above is -# guarding a live defect rather than a stylistic preference: extraction stops -# at the second copy, dropping the body that sits under it. -check "monotonic: the duplicate TRUNCATES extraction (rig's symptom, not box's)" 0 \ - "A pending thing" changelog_section "$T/CHANGELOG.md" 0.2.0 -check "monotonic: ...the real body under the second copy is dropped" 1 "" \ - sect_has "$T/CHANGELOG.md" 0.2.0 "A shipped thing" - -# '## Unreleased' is deliberately OUTSIDE the guarded set: it fails the -# version shape, so the ceremony stamping it away — the one edit that legally -# removes a top heading — is invisible here. This is the case that would make -# every release PR unshippable if the set were "all '## ' headings". -T="$(monorepo stamp)" -monowrite "$T" '# Changelog' '' '## 0.3.0 — 2026-07-20' '' \ - '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ - '- **The first thing** (#0) — prose.' -check "monotonic: stamping '## Unreleased' into a release passes (not guarded)" 0 "" mono "$T" -# ...and the ceremony's re-arm — a fresh empty Unreleased above the stamp — -# is equally fine, which is CONTRIBUTING step 1's tree. -T="$(monorepo stamp-rearmed)" -monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.3.0 — 2026-07-20' '' \ - '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ - '- **The first thing** (#0) — prose.' -check "monotonic: the re-armed ceremony tree passes too" 0 "" mono "$T" - -# The skip path, both halves. A base ref that does not resolve is a sensible -# local degradation — and a silent one, which is the failure shape this family -# of checks exists to refuse. So STRICT flips exactly that case red. -mono_noref() { local d="$1"; shift; ( cd "$d" && env "$@" bash "$MONO" no/such/ref 2>&1 ); } -T="$(monorepo noref)" -check "monotonic: an unresolvable base ref SKIPS containment locally" 0 "containment SKIPPED" mono_noref "$T" -check "monotonic: ...and the skip says uniqueness already ran, not that nothing did" 0 \ - "already ran and passed" mono_noref "$T" -check "monotonic: ...but is a FAILURE under STRICT=1 (what CI sets)" 1 "STRICT=1" \ - mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1 -check "monotonic: ...and the STRICT failure blames the checkout, not the script" 1 \ - "fetch-depth: 0" mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1 - -# A missing changelog is an error on any setting — it is not a degradation, -# it is a wrong invocation. -T="$(monorepo nofile)" -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "monotonic: a missing changelog file is an error, never a skip" 1 "no such file" \ - bash -c 'cd "$1" && bash "$2" base nope.md 2>&1' _ "$T" "$MONO" - -# --- #98: uniqueness is a property of HEAD, so nothing base-side may gate it -- -# Containment needs the merge base. Uniqueness needs only the file in front of -# it. As first written (and as inherited from heavy-duty/box, fixed there in -# box#144 for box#143) the duplicate check sat DOWNSTREAM of the base-ref, -# merge-base and base-blob conditions, so each of the degradation paths below -# exited 0 on a tree carrying a duplicate in plain sight — the base-blob one -# not even through skip(), but a bare `exit 0` that STRICT could not reach. -# -# These cases pin the ORDER, which is the actual invariant. Every monorepo -# fixture above commits MONO_BASE on 'base', so no case up there ever reaches -# the base-absent branch at all; and asserting the exit code alone is what let -# the original ship, since the clean base-absent case is green either way. -mononocl() { # mononocl -> a repo whose 'base' has NO changelog, on 'work' - local d="$WORK/mono-$1"; mkdir -p "$d" - git -C "$d" init -q -b base - git -C "$d" config user.email harness@example.invalid - git -C "$d" config user.name harness - printf '%s\n' '# rig' > "$d/README.md" - git -C "$d" add README.md - git -C "$d" commit -qm 'base: no changelog yet' - git -C "$d" checkout -q -b work - printf '%s' "$d" -} -monoadd() { # monoadd — the branch INTRODUCES CHANGELOG.md - local d="$1"; shift - printf '%s\n' "$@" > "$d/CHANGELOG.md" - git -C "$d" add CHANGELOG.md - git -C "$d" commit -qm 'work: introduce the changelog' -} - -# The changelog is absent at the merge base AND the branch introduces a -# duplicate. Before the fix this exited 0 on "nothing could have been deleted". -T="$(mononocl 98-newdup)" -monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A shipped thing** (#1) — prose.' -check "monotonic: a duplicate introduced where the base had NO changelog is CAUGHT (#98)" 1 \ - "DUPLICATE release heading" mono "$T" -check "monotonic: ...and STRICT does not change that (it was never a skip)" 1 \ - "DUPLICATE release heading" mono "$T" CHANGELOG_MONOTONIC_STRICT=1 -# ...and the clean counterpart still passes, now SAYING uniqueness ran. Without -# this the case above could be satisfied by failing the base-absent path -# outright, which would redden every changelog-introducing branch. -T="$(mononocl 98-newok)" -monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A shipped thing** (#1) — prose.' -check "monotonic: ...while a CLEAN introduced changelog still passes" 0 \ - "nothing could have been deleted" mono "$T" -check "monotonic: ...saying uniqueness was checked, not that nothing was" 0 \ - "uniqueness on HEAD already passed" mono "$T" - -# No git at all (a tarball, an unpacked release): uniqueness still has -# everything it needs, so a duplicate is caught rather than skipped past. -mkdir -p "$WORK/mono-98-nogit" -printf '%s\n' '# Changelog' '' '## 0.2.0 — 2026-07-19' '' \ - '## 0.2.0 — 2026-07-19' > "$WORK/mono-98-nogit/CHANGELOG.md" -check "monotonic: a duplicate OUTSIDE a git work tree is caught (#98)" 1 \ - "DUPLICATE release heading" mono "$WORK/mono-98-nogit" - -# An unresolvable base ref: same — the skip belongs to containment, not to the -# script, so uniqueness has already run by the time skip() is reachable. -T="$(monorepo 98-nobase)" -monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \ - '- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \ - '- **The first thing** (#0) — prose.' -check "monotonic: a duplicate is caught even when the base ref will not resolve (#98)" 1 \ - "DUPLICATE release heading" mono_noref "$T" - -# --- ci.yml: the monotonic step is actually wired (#98) ---------------------- -# The guard runs from ci.yml, not from this suite, so pin the wiring the same -# way release.yml's is pinned — a script nothing invokes is not a check. -CIY="$ROOT/.github/workflows/ci.yml" -check "ci.yml: runs the monotonic guard" 0 "" \ - grep -q "changelog-monotonic.sh" "$CIY" -check "ci.yml: ...with STRICT=1, so a skip is red rather than quietly green" 0 "" \ - grep -qF "CHANGELOG_MONOTONIC_STRICT: '1'" "$CIY" -# shellcheck disable=SC2016 # the $-string is a literal in the target file -check "ci.yml: ...against the PR's base branch" 0 "" \ - grep -qF 'origin/${{ github.base_ref' "$CIY" -# The step must NOT be pull-request-only. Deletion is vacuous on a push to main -# (the merge base IS HEAD), but DUPLICATION is vacuous on no tree at all, so -# gating the whole script left a duplicate reaching main by any other route -# unasserted. Dropping the gate is only safe with the ref_name fallback: -# `github.base_ref` is EMPTY on a push, a bare `origin/` does not resolve, and -# STRICT=1 promotes that to a hard failure on every push to main. -# -# Scoped to the step's OWN block, deliberately. As a file-wide grep this -# negative forbade any FUTURE step in ci.yml from being pull_request-gated and -# would have failed citing #98 when one legitimately was — #98 constrains this -# step, not the file. The companion check below is what keeps the awk honest: -# an extractor that matched nothing would turn the negative into a tautology -# that passes forever, including after someone renames the step and re-adds -# the gate. -# Terminates on a new STEP or a new JOB. The job boundary is not optional: the -# monotonic step is the LAST step of its job, so stopping only at the next -# `- name:` runs the block into the job below and swallows that job's -# level `if:` — the same bug this scoping fixed, moved from "any step in the -# file" to "this step plus the head of the next job" (found on box#144). -mono_step_block() { - awk '/^ - name: no shipped changelog heading/ {f=1; print; next} - f && (/^ - / || /^ [^ ]/) {exit} - f {print}' "$CIY" -} -# Anchored: an `if:` inside a `run:` line is not a step condition. -mono_step_gated() { mono_step_block | grep -q '^ if:'; } -check "ci.yml: the monotonic step itself is NOT pull_request-gated (#98)" 1 "" \ - mono_step_gated -check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \ - "changelog-monotonic" mono_step_block -# shellcheck disable=SC2016 # the $-string is a literal in the target file -check "ci.yml: ...and falls back to ref_name, so a push has a base to resolve" 0 "" \ - grep -qF 'github.base_ref || github.ref_name' "$CIY" -# Without full history the base ref does not resolve, and STRICT turns that -# into a red run — so the fetch depth is load-bearing, not incidental. -check "ci.yml: the checkout has full history (the base ref must resolve)" 0 "" \ - grep -qF "fetch-depth: 0" "$CIY" - -# --- the drill rule: does the version being shipped have a record? ---------- -# CONTRIBUTING ("Releasing") has always required a real-hardware drill and -# nothing enforced it, so no release in this family has ever carried one: every -# other ceremony step is checked by a script, and the one that costs an -# afternoon was checked by a reviewer remembering. A bot finally blocked on it. -# -# Records are ONE FILE PER VERSION, at drills/.md. The first cut of -# this guard kept them as sections in a single drill/RUNS.md and needed a -# heading grammar, an optional-date tail, a whole-version comparison and a -# non-blank-body rule to read them back — all of it there only because the -# records shared a file, and both sibling repos shipped a defect out of it in -# review. Splitting the files deletes most of these tests along with the code -# they covered: `0.3.0.md` and `0.3.0-rc1.md` cannot be confused by any -# grammar, because there is no grammar. -# -# Fixtures carry their OWN version file and their OWN drills dir, inside the -# fixture dir. This is not tidiness — it is heavy-duty/box#146, verbatim: -# fixtures that read the REPO's VERSION exercised the `-dev` branch on every -# ordinary tree, so the whole bare-version half of the guard was untested and -# went red for the first time while somebody was cutting a release. A fixture -# must state the tree it is about. -DRILL="$ROOT/.github/scripts/drill-recorded.sh" -check "drill-recorded.sh: exists and is the guard under test" 0 "" test -f "$DRILL" -check "drill-recorded.sh: is executable" 0 "" test -x "$DRILL" - -drilltree() { # drilltree -> prints the dir (no drills/ yet) - local d="$WORK/drill-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION" - printf '%s' "$d" -} -drillrec() { # drillrec — write drills/.md - mkdir -p "$1/drills"; local f="$1/drills/$2.md"; shift 2 - printf '%s\n' "$@" > "$f" -} -drill() { bash "$DRILL" "$1/drills" "$1/VERSION" 2>&1; } - -# A development tree. Vacuous by construction — every ordinary PR looks like -# this, and none of them can be asked to have drilled a release that does not -# exist. It passes with no drills/ directory present AT ALL, which is the -# state this repo ships in today. -T="$(drilltree dev 0.2.1-dev)" -check "drill: a -dev tree passes with NO drills dir at all" 0 "" drill "$T" -check "drill: ...saying so out loud, not exiting 0 in silence" 0 \ - "nothing to assert" drill "$T" - -# The release ceremony tree, drilled and recorded. GREEN. -T="$(drilltree recorded 0.3.0)" -drillrec "$T" 0.3.0 '# Release drill — 0.3.0 — 2026-07-21' '' \ - 'Host: bare Debian 13. Candidate refs pinned: box@1a2b3c4, cast@9a0b1c2.' '' \ - '- convergence, then re-converge: clean' \ - '- db-integration: 14/14' '- runner lifecycle: PASS' -drillrec "$T" 0.2.0 '# Release drill — 0.2.0 — 2026-07-01' '' 'an older run' -check "drill: a bare VERSION with a non-empty record for it passes" 0 \ - "records a drill for 0.3.0" drill "$T" - -# The gate itself: a release tree with no drills/ directory at all. RED, -# naming the version, because "which release is unevidenced" is the only fact -# the author needs. This is the state a repo is in the first time it cuts a -# release under the gate — it must read as a to-do, not a broken invocation. -T="$(drilltree norecord 0.3.0)" -check "drill: a bare VERSION with NO drills dir FAILS" 1 \ - "no drill record" drill "$T" -check "drill: ...and the failure names the version" 1 "VERSION is 0.3.0" drill "$T" -check "drill: ...and names the file it wanted" 1 "drills/0.3.0.md" drill "$T" - -# A drills/ that exists but holds nothing for THIS version. Same failure — -# other releases having been drilled says nothing about this one. -T="$(drilltree otherversion 0.4.0)" -drillrec "$T" 0.3.0 '# Release drill — 0.3.0' '' 'the previous release' -check "drill: a drills dir with no file for THIS version FAILS" 1 \ - "no drill record" drill "$T" -check "drill: ...naming the version that is unevidenced" 1 "VERSION is 0.4.0" drill "$T" - -# ...and the failure has to say how to get out of it. Both moves are a commit -# on the PR, and the second one is the point of asking for a RECORD rather -# than a RESULT: a waiver is allowed, it just cannot be silent. -check "drill: ...and the failure names the unblock — run the drill" 1 \ - "RUN THE DRILL" drill "$T" -check "drill: ...and the waiver, recorded, as the other way out" 1 \ - "MAINTAINER WAIVER" drill "$T" -check "drill: ...and points at the README for what a record contains" 1 \ - "README.md" drill "$T" - -# An EMPTY file at the right path. This is the failure a laxer guard invites — -# the ceremony PR touches the file to get green and fills it in never. -T="$(drilltree emptyfile 0.3.0)" -mkdir -p "$T/drills"; : > "$T/drills/0.3.0.md" -check "drill: a PRESENT but EMPTY record FAILS" 1 "no drill record" drill "$T" - -# ...and WHITESPACE is not a record either. This is the ONE piece of the old -# section-parsing rule set that splitting the files did not make -# unrepresentable, so it is the one that still needs a test. It is here because -# the siblings got it wrong: box#149 and cast#138 both extracted with -# `sed '/./,$!d'`, where `.` matches a space, so one tab satisfied the gate and -# shipped an evidence-free release. All three reviewers caught it there. -# Nothing caught it here, because there was nothing to catch — which is exactly -# the state in which a later simplification quietly reintroduces it. -T="$(drilltree blank 0.3.0)" -drillrec "$T" 0.3.0 ' ' ' ' '' -check "drill: a record of only spaces, tabs and newlines FAILS (box#149, cast#138)" \ - 1 "no drill record" drill "$T" - -# The version is matched WHOLE, both directions — and now the filesystem does -# it, since the version IS the filename. A drill run against a release -# candidate is not evidence for the final release, and the reverse is equally -# false: in both cases the string that matched is not the artefact that ships. -T="$(drilltree whole-rc 0.3.0)" -drillrec "$T" 0.3.0-rc1 '# Release drill — 0.3.0-rc1' '' 'the rc drill' -check "drill: an -rc1 record does NOT satisfy the bare version" 1 \ - "no drill record" drill "$T" -T="$(drilltree whole-final 0.3.0-rc1)" -drillrec "$T" 0.3.0 '# Release drill — 0.3.0' '' 'the final drill' -check "drill: ...and a bare-version record does NOT satisfy the -rc1" 1 \ - "no drill record" drill "$T" - -# A missing VERSION file is a wrong invocation, not a degradation — there is -# no version to be lenient about, so it must never read as a pass. -T="$(drilltree noversion 0.3.0)" -rm -f "$T/VERSION" -check "drill: a missing VERSION file is an error, never a pass" 1 "no such file" drill "$T" - -# The real files, last. The shipped README must be readable, and the guard the -# repo actually runs must pass on the VERSION the repo actually carries. -check "drills/README.md: exists" 0 "" test -f "$ROOT/drills/README.md" -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "drills/README.md: documents the one-file-per-version naming rule" 0 "" \ - bash -c 'grep -qF "drills/.md" "$1"' _ "$ROOT/drills/README.md" -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "drills/README.md: says a FAILED drill is still a valid record" 0 "" \ - bash -c 'grep -qi "failed drill is still a valid record" "$1"' _ "$ROOT/drills/README.md" -# The old single-file record must be gone, not merely unreferenced: a stale -# drill/RUNS.md would be a second place to write a record that nothing reads. -check "drill/RUNS.md: is gone — records live one per version now" 1 "" \ - test -e "$ROOT/drill/RUNS.md" -# The property is that the guard's VERDICT IS CORRECT FOR THIS TREE — not that -# it always passes. Those come apart on a ceremony tree: a -dev tree is vacuous -# and must pass, but a ceremony tree passes only once a human has run the drill -# and written the record, which is the entire point of the gate. Asserting -# exit 0 unconditionally made test/release.sh UN-GREENABLE on every release -# branch before its drill, and surfaced as a `release-flow tests` failure rather -# than as the gate doing its job — the same misattribution shape as box#146, -# where a fixture read the repo's real VERSION and only misbehaved on the -# ceremony tree. Caught when box#148 went red for the wrong-looking reason. -THIS_VER="$(tr -d '[:space:]' < "$ROOT/VERSION")" -case "$THIS_VER" in - *-dev) - check "drill-recorded.sh: THIS tree is -dev, and the guard is vacuous on it" 0 "" \ - bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION" ;; - *) - if [ -s "$ROOT/drills/$THIS_VER.md" ]; then - check "drill-recorded.sh: THIS ceremony tree HAS its record, and the guard accepts it" 0 "" \ - bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION" - else - check "drill-recorded.sh: THIS ceremony tree has NO record yet, and the guard refuses it" 1 "no drill record at" \ - bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION" - fi ;; -esac -# ...and with no arguments at all, since that is how ci.yml invokes it. The -# defaults must be the paths this repo actually uses. -# ...and with its DEFAULT arguments, as CI runs it. What this pins is that the -# defaults ARE the paths this repo uses — so it asserts the defaults reach the -# same verdict as the explicit call above, not a fixed exit code. Hard-coding 0 -# here would fail on a ceremony tree for the same wrong reason the check above -# used to. -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "drill-recorded.sh: ...and its DEFAULT arguments agree, as CI runs it" 0 "" \ - bash -c ' - cd "$1" || exit 9 - bash .github/scripts/drill-recorded.sh >/dev/null 2>&1; d=$? - bash .github/scripts/drill-recorded.sh drills VERSION >/dev/null 2>&1; e=$? - [ "$d" -eq "$e" ]' _ "$ROOT" - -# ci.yml: the guard runs from there, so pin the wiring — a script nothing -# invokes is not a check (same reasoning as the monotonic pins above). -check "ci.yml: runs the drill guard" 0 "" grep -q "drill-recorded.sh" "$CIY" -# ...and is NOT trigger-gated. It is vacuous on every -dev tree already, so an -# `if:` could only ever exempt the one tree it exists for. -drill_step_block() { - awk '/^ - name: a release version has a recorded drill/ {f=1; print; next} - f && (/^ - / || /^ [^ ]/) {exit} - f {print}' "$CIY" -} -drill_step_gated() { drill_step_block | grep -q '^ if:'; } -check "ci.yml: the drill step itself is NOT trigger-gated" 1 "" drill_step_gated -check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \ - "drill-recorded" drill_step_block - -# CONTRIBUTING must state the gate, and must state what the drill actually is. -# The three repos' drills are INDEPENDENT — run in any order, on any schedule — -# and what makes that safe is that each one pins the same fixed set of -# CANDIDATE refs, so box and rig measure the same pair. That, not sequencing, -# is what dissolves the mutual recursion (rig builds the host box runs on, and -# box mints the seeds rig converges). An earlier draft of this doc claimed a -# fixed box → rig → cast release order; it is wrong, and this pins the -# correction. -CONTRIB="$ROOT/CONTRIBUTING.md" -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "CONTRIBUTING: the release flow names the drill gate" 0 "" \ - bash -c 'grep -qF "drills/.md" "$1"' _ "$CONTRIB" -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "CONTRIBUTING: ...and says the three repos' drills are INDEPENDENT" 0 "" \ - bash -c 'grep -qi "drills are independent" "$1"' _ "$CONTRIB" -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "CONTRIBUTING: ...pinned to one fixed set of candidate refs" 0 "" \ - bash -c 'grep -qi "same fixed set of candidate refs" "$1"' _ "$CONTRIB" -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "CONTRIBUTING: ...which is what dissolves the recursion, not sequencing" 0 "" \ - bash -c 'grep -qF "RIG_REF" "$1"' _ "$CONTRIB" -# The negative that keeps the correction from being re-lost: no fixed release -# order may be claimed. Nothing requires box to ship before rig. -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "CONTRIBUTING: ...and never claims a fixed box-then-rig release order" 1 "" \ - bash -c 'grep -qi "box first, then rig" "$1"' _ "$CONTRIB" - -# --- release.yml: the pins --------------------------------------------------- -# The workflow itself runs only on a tag push upstream, so pin its -# load-bearing pieces the way the harness pins root-only paths (repo -# precedent: the tag-refusal greps in test/cli.sh). -RY="$ROOT/.github/workflows/release.yml" -check "release.yml: exists" 0 "" test -f "$RY" -check "release.yml: triggers on tag pushes" 0 "" grep -q "tags:" "$RY" -check "release.yml: sources the shared lib (one extractor, not a copy)" 0 "" \ - grep -q "release-lib.sh" "$RY" -check "release.yml: the body comes from changelog_section" 0 "" \ - grep -q "changelog_section CHANGELOG.md" "$RY" -check "release.yml: a tag/VERSION mismatch refuses to create" 0 "" \ - grep -q "refusing to create a release" "$RY" -check "release.yml: an empty changelog section refuses too" 0 "" \ - grep -q "has no '## " "$RY" -check "release.yml: gh release create verifies the tag" 0 "" \ - grep -q -- "--verify-tag" "$RY" -# Ordering: the mismatch assert must precede the create (line compare, the -# repo's marker-then-box idiom; defaults fail closed). -assert_at="$(grep -n "refusing to create a release" "$RY" | head -n1 | cut -d: -f1)" -create_at="$(grep -n "gh release create" "$RY" | head -n1 | cut -d: -f1)" -check "release.yml: the assert precedes the create" \ - 0 "" test "${assert_at:-999999}" -lt "${create_at:-0}" - -# --- release.yml, the merge path: the pins (#47; box#96's design) ------------ -# Merging the release-labeled ceremony PR IS the release. Same grep-pin -# treatment for the merge path's load-bearing pieces: the gate, the four -# fail-loud asserts, the same-job tag+publish, and the surviving tag-push -# fallback. -# The merge door rides pushes to MAIN, not pull_request events: a fork PR's -# pull_request run gets a read-only GITHUB_TOKEN (permissions: cannot raise -# it), and every ceremony PR this org merges is cross-repo from the bot -# fork — the tag create would 403 after green asserts (#48 round 1). The -# label — the operator's intent — is read via the API off the merge commit. -check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \ - grep -qF "branches: [main]" "$RY" -# YAML maps are last-key-wins: a second sibling push: key silently replaces -# the first and kills a door (grok's round-2 catch — the tag fallback had -# stopped triggering). Exactly ONE push key may exist. -check "release.yml: exactly one on.push key (duplicate keys drop a door)" 0 "1" \ - grep -cE '^ push:' "$RY" -check "release.yml: ...and the doors split on the ref (tag door takes tags)" 0 "" \ - grep -qF "startsWith(github.ref, 'refs/tags/')" "$RY" -# shellcheck disable=SC2016 # the $-string is a literal in the target file -check "release.yml: the release label is read via the API off the merge commit" 0 "" \ - grep -qF 'commits/$MERGE_SHA/pulls' "$RY" -check "release.yml: a transition without a labeled PR refuses" 0 "" \ - grep -qF "no merged, release-labeled PR is behind this commit" "$RY" -# The decide step tells the label's two meanings apart (LABELS.md gives -# `release` to release-flow WORK as well as to the ceremony PR): work under -# the label is a green NOTICE no-op — in the -dev steady state and in the -# post-release window (bare, unchanged, already released) — while every -# half-ceremony refuses. Pin each verdict's message and the gating output. -check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \ - grep -qF "release-flow work under the release label, not a ceremony" "$RY" -check "release.yml: decide — a -dev endstate is always work (the bump PR no-ops green)" 0 "" \ - grep -qF "a dev tree is by definition not a release" "$RY" -check "release.yml: decide — post-release-window work no-ops green" 0 "" \ - grep -qF "release-flow work merged in the post-release window" "$RY" -check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \ - grep -qF "Refusing to guess" "$RY" -# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately -check "release.yml: decide gates every later step on ceremony=yes" 0 "" \ - bash -c '[ "$(grep -cF "if: steps.decide.outputs.ceremony == '\''yes'\''" "$1")" -ge 3 ]' _ "$RY" -check "release.yml: assert 3 — an empty section refuses to publish" 0 "" \ - grep -qF "refusing to publish an empty release" "$RY" -check "release.yml: assert 4 — an existing tag or release refuses (idempotent)" 0 "" \ - grep -qF "refusing to re-release" "$RY" -# Same-job matters: a GITHUB_TOKEN-created tag fires no tag-push workflow, -# so the publish must live NEXT TO the tag creation. The workflow keeps -# release-on-merge as its last job (pinned by comment there) so the awk -# range runs to EOF; both acts must land inside it. -MJOB="$(awk '/^ release-on-merge:/,0' "$RY")" -mjob_has() { printf '%s' "$MJOB" | grep -qF -e "$1"; } -check "release.yml: the merge job API-creates the tag itself" 0 "" \ - mjob_has "git/refs" -# shellcheck disable=SC2016 # the $-string is a literal in the target file -check "release.yml: ...at the pushed main head (github.sha = the merge commit)" 0 "" mjob_has 'sha="$MERGE_SHA"' -# The release re-arms main itself: the post-release -dev bump is arithmetic, -# not judgment, so it rides the same job — direct push, PR fallback. -check "release.yml: the release bumps main to the next -dev itself" 0 "" \ - grep -qF "bump main to the next -dev" "$RY" -check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \ - grep -qF "opening the bump PR instead" "$RY" -check "release.yml: ...and publishes in the SAME job" 0 "" \ - mjob_has "gh release create" -# Ordering, the marker-then-box idiom again: the last assert's refusal must -# precede the tag creation (asserts first, acts last; defaults fail closed). -massert_at="$(grep -n "refusing to re-release" "$RY" | head -n1 | cut -d: -f1)" -mtag_at="$(grep -n "git/refs" "$RY" | head -n1 | cut -d: -f1)" -check "release.yml: the merge-path asserts precede the tag" \ - 0 "" test "${massert_at:-999999}" -lt "${mtag_at:-0}" -# ...and the manual path SURVIVES: tag-push trigger plus a push-gated job, -# the documented fallback and backfill. -check "release.yml: the tag-push trigger survives (manual fallback intact)" 0 "" \ - grep -qF "tags: ['**']" "$RY" -check "release.yml: the fallback job is gated to push events" 0 "" \ - grep -qF "github.event_name == 'push'" "$RY" # --- the installer's ref logic, extracted ------------------------------------ # install.sh must stay a single curl|bash file, so its channel functions live