Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33794ce70 | ||
|
|
7e05328a6d | ||
|
|
7247768465 | ||
|
|
f8ca59c22e | ||
|
|
e4d9005aca | ||
|
|
3bae42157e | ||
|
|
b0d4aff964 | ||
|
|
1c3d873bf2 | ||
|
|
c93e230396 | ||
|
|
a4684f7afa | ||
|
|
bf74744bda | ||
|
|
a17903f07c |
33 changed files with 698 additions and 2999 deletions
49
.ceremony/AGENTS.md
Normal file
49
.ceremony/AGENTS.md
Normal file
|
|
@ -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.
|
||||
76
.ceremony/BUILDER.md
Normal file
76
.ceremony/BUILDER.md
Normal file
|
|
@ -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.
|
||||
98
.ceremony/LABELS.md
Normal file
98
.ceremony/LABELS.md
Normal file
|
|
@ -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.
|
||||
14
.ceremony/README.md
Normal file
14
.ceremony/README.md
Normal file
|
|
@ -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@<ref>` 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).
|
||||
70
.ceremony/REVIEWER.md
Normal file
70
.ceremony/REVIEWER.md
Normal file
|
|
@ -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.
|
||||
95
.ceremony/TRIAGE.md
Normal file
95
.ceremony/TRIAGE.md
Normal file
|
|
@ -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.
|
||||
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope
|
||||
# path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's scope
|
||||
# story (issues are hand-scoped at triage; paths only exist on PRs). Additive
|
||||
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
|
||||
"scope:cli":
|
||||
|
|
|
|||
7
.github/labels.conf
vendored
Normal file
7
.github/labels.conf
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
|
||||
scope:cli|C5DEF5|bin/box — the command surface
|
||||
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
|
||||
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
|
||||
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
|
||||
scope:templates|C5DEF5|templates/ — the box seeds
|
||||
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
|
||||
145
.github/scripts/changelog-armed.sh
vendored
145
.github/scripts/changelog-armed.sh
vendored
|
|
@ -1,145 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# changelog-armed.sh [<changelog>] [<version-file>] — assert that
|
||||
# CHANGELOG.md is ARMED: that there is a heading for the next PR's entry to
|
||||
# land under, and that it is the right one for the state this tree is in.
|
||||
#
|
||||
# The failure it exists to catch (#108, heavy-duty/rig#66) leaves no trace:
|
||||
# the ceremony PR stamps '## Unreleased' into '## X.Y.Z — DATE' by hand, and
|
||||
# nothing puts the heading back. A PR authored BEFORE the release wrote its
|
||||
# entry under '## Unreleased'; that heading is gone by the time it merges, so
|
||||
# git lands the entry under whatever heading now occupies that position — the
|
||||
# just-shipped section — CLEANLY, with no conflict. The one signal an author
|
||||
# would trust ("git told me to look") is absent exactly when the result is
|
||||
# wrong, and the drift is only ever discovered by reading the file.
|
||||
#
|
||||
# The rule, keyed on VERSION, because the two states are genuinely different:
|
||||
#
|
||||
# VERSION ends in -dev -> the top section MUST be '## Unreleased'
|
||||
# VERSION is bare -> the top section may be '## Unreleased' (armed,
|
||||
# the ceremony's own re-arm) or the stamped
|
||||
# section for exactly that VERSION — AND the
|
||||
# section for that VERSION must exist and carry
|
||||
# prose, because it is the one about to ship
|
||||
#
|
||||
# Keying on VERSION is the whole design, and the reason this is not simply
|
||||
# "require '## Unreleased'". That unconditional form is what rig#44 and
|
||||
# heavy-duty/cast#108 had to REVERT: it is false by construction on the
|
||||
# ceremony PR's own tree, which makes the release unshippable through a green
|
||||
# CI. Anyone tempted to simplify this back should read those two first.
|
||||
#
|
||||
# The consequence worth stating plainly: a ceremony PR that stamps and forgets
|
||||
# to re-arm still passes here — its VERSION is bare, and a bare tree is
|
||||
# allowed to be stamped. It goes red the moment the '-dev' bump lands on main,
|
||||
# which release.yml does automatically in the same job as the publish. So the
|
||||
# guard does not block the release; it refuses to let main SIT disarmed, which
|
||||
# is the window a late PR can fall into.
|
||||
#
|
||||
# A file of its own (not inlined in ci.yml) so test/release.sh can drive it
|
||||
# against constructed trees for both states — the same discipline as
|
||||
# release-notes.sh.
|
||||
|
||||
changelog="${1:-CHANGELOG.md}"
|
||||
version_file="${2:-VERSION}"
|
||||
|
||||
# release-notes.sh lives beside this script; the bare-VERSION branch runs it
|
||||
# rather than re-implementing the extraction, so the guard and the publisher
|
||||
# cannot disagree about what a section is or when one counts as empty.
|
||||
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
[ -f "$changelog" ] || { echo "changelog-armed: no such file: $changelog" >&2; exit 1; }
|
||||
[ -f "$version_file" ] || { echo "changelog-armed: no such file: $version_file" >&2; exit 1; }
|
||||
|
||||
ver="$(tr -d '[:space:]' < "$version_file")"
|
||||
[ -n "$ver" ] || { echo "changelog-armed: $version_file is empty" >&2; exit 1; }
|
||||
|
||||
# The TOP section: the first '## ' heading in the file. Everything above it is
|
||||
# the changelog's own preamble and belongs to no section.
|
||||
top="$(grep -m1 '^## ' "$changelog" || true)"
|
||||
[ -n "$top" ] || {
|
||||
echo "changelog-armed: $changelog has no '## ' section at all — nothing for a PR entry to land under" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# '## 0.7.0 — 2026-07-19' -> '0.7.0'. Split on whitespace, same shape
|
||||
# release-notes.sh matches on, so the two cannot disagree about what a
|
||||
# section header is.
|
||||
top_ver="$(printf '%s\n' "$top" | awk '{ print $2 }')"
|
||||
|
||||
case "$ver" in
|
||||
*-dev)
|
||||
if [ "$top_ver" != "Unreleased" ]; then
|
||||
cat >&2 <<EOF
|
||||
changelog-armed: VERSION is '$ver' (a development tree) but the top section of
|
||||
$changelog is:
|
||||
|
||||
$top
|
||||
|
||||
A -dev tree MUST carry '## Unreleased' at the top. Without it, a PR that
|
||||
wrote its entry under '## Unreleased' before the release merges CLEANLY into
|
||||
the section above — the one that already shipped — and the changelog quietly
|
||||
misattributes it (#108, heavy-duty/rig#66).
|
||||
|
||||
The fix is to re-arm: add an empty '## Unreleased' immediately above
|
||||
'$top'. The release ceremony is supposed to do this in the same edit that
|
||||
stamps the version — see CONTRIBUTING.md, "Releases".
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# A bare VERSION is the ceremony tree and the merge commit that publishes
|
||||
# it. Both arrangements are legal there: re-armed ('## Unreleased' back on
|
||||
# top, above the section just stamped) or not yet re-armed (the stamped
|
||||
# section still on top). What is NOT legal is a stamped top section naming
|
||||
# some OTHER version — that is a ceremony that stamped the wrong number,
|
||||
# and release.yml would publish a body that is not this release's.
|
||||
if [ "$top_ver" != "Unreleased" ] && [ "$top_ver" != "$ver" ]; then
|
||||
cat >&2 <<EOF
|
||||
changelog-armed: VERSION is '$ver' but the top section of $changelog is:
|
||||
|
||||
$top
|
||||
|
||||
A bare VERSION means this tree is a release. Its top section must be either
|
||||
'## Unreleased' (re-armed after stamping) or the stamped section for '$ver'
|
||||
itself. A stamped section naming a different version means the ceremony
|
||||
stamped the wrong number, and the published release body would come from
|
||||
the wrong section.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
# The top heading is deliberately left UNCONSTRAINED above — both ceremony
|
||||
# shapes must stay legal, which is the #44 / cast#108 lesson and is not
|
||||
# negotiable. That asymmetry leaves a gap of its own, the HALF-ceremony
|
||||
# tree: VERSION bumped to the release, a populated '## Unreleased' still on
|
||||
# top, and no stamped section for the version anywhere. The test above is
|
||||
# false on its first clause, short-circuits, and passes. Nothing else
|
||||
# refuses until release.yml extracts the notes — which happens AFTER the
|
||||
# merge, on main, and publishes a release with an empty body, the worst
|
||||
# place for this to land. So make the same assert one step earlier by
|
||||
# running the very script release.yml runs (heavy-duty/rig#67).
|
||||
if ! bash "$here/release-notes.sh" "$ver" "$changelog" >/dev/null 2>&1; then
|
||||
cat >&2 <<EOF
|
||||
changelog-armed: VERSION is '$ver' but $changelog has no non-empty section for
|
||||
'$ver'. The top section is:
|
||||
|
||||
$top
|
||||
|
||||
This is a HALF-DONE ceremony: the version was bumped but its section was
|
||||
never stamped — the stamp is MISSING, not misnumbered. A bare VERSION means
|
||||
this tree is a release, and the section it is about to publish has to exist
|
||||
and have prose in it. Left alone, this passes CI, merges, and only then does
|
||||
release.yml refuse to extract the notes — on main, after the fact, with the
|
||||
release already half-shipped.
|
||||
|
||||
The fix is the ceremony's first edit (CONTRIBUTING.md, "Releases"): stamp
|
||||
'## Unreleased' into '## $ver — DATE', then put an empty '## Unreleased'
|
||||
back above it.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "changelog-armed: VERSION '$ver' agrees with the top section ($top_ver)"
|
||||
225
.github/scripts/changelog-monotonic.sh
vendored
225
.github/scripts/changelog-monotonic.sh
vendored
|
|
@ -1,225 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# changelog-monotonic.sh [<base-ref>] [<changelog>] — 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 (#122, caught in review of #118) leaves no
|
||||
# trace either. An author adding an entry under '## Unreleased' REPLACES the
|
||||
# line below it instead of inserting above it:
|
||||
#
|
||||
# -## 0.8.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 — and the shipped section's whole body is silently
|
||||
# absorbed into '## Unreleased'. 0.8.0 no longer HAS a section; the notes
|
||||
# anchor release-notes.sh extracts by is gone, and the next release cut from
|
||||
# that state republishes 0.8.0's prose as if it were new.
|
||||
#
|
||||
# changelog-armed.sh is green on exactly that tree, correctly: it asks only
|
||||
# whether the TOP section agrees with VERSION, and deleting '## 0.8.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 rule, and why it needs no tuning: release headings are APPEND-ONLY. The
|
||||
# ceremony (#96) adds one and never removes one; nothing else in the documented
|
||||
# flow (CONTRIBUTING.md, "Releases") 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 — changelog-armed.sh owns that
|
||||
# heading, keyed on VERSION, and the ceremony legitimately consumes it.
|
||||
#
|
||||
# A file of its own, NOT a clause inside changelog-armed.sh, 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
|
||||
# changelog-armed.sh is driven by test/release.sh against constructed
|
||||
# two-file 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-notes.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: '## <token> ...' where <token> looks like a
|
||||
# version. Field $2, the same split changelog-armed.sh and release-notes.sh
|
||||
# use, so the three 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 #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.8.0} minus head
|
||||
# {0.8.0, 0.8.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 #118's bad rebase actually produced: two
|
||||
# `## 0.8.0 — 2026-07-19` headings with the incoming entry between them. Every
|
||||
# other guard stayed green — markers absent, changelog-armed.sh happy (the top
|
||||
# section was still right), tests and shellcheck clean — while
|
||||
# release-notes.sh re-armed its grab on the second heading and folded post-cut
|
||||
# prose into the shipped release body.
|
||||
#
|
||||
# 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 <<EOF
|
||||
Each version heading must appear exactly once. A repeat splits one release
|
||||
into two same-named sections, and release-notes.sh re-arms its extraction on
|
||||
every matching '## ' line — so the published body for that version absorbs
|
||||
whatever sits between the copies, and an entry stranded there is dropped from
|
||||
the NEXT release's notes as well.
|
||||
|
||||
This is the #118 shape: an entry meant for '## Unreleased' was inserted after
|
||||
a shipped heading, and the heading re-added below it. The fix is one heading,
|
||||
with the entry above it under '## Unreleased':
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your entry**
|
||||
|
||||
## $(printf '%s\n' "$dupes" | head -1) — DATE <- exactly once
|
||||
|
||||
Quick check on any changelog-touching rebase:
|
||||
|
||||
diff <(git show origin/main:$changelog | grep '^## ') <(grep '^## ' $changelog)
|
||||
EOF
|
||||
} >&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 (#143). 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 <<EOF
|
||||
Present at the merge base ($(git rev-parse --short "$merge_base")), absent on HEAD.
|
||||
|
||||
Release headings are APPEND-ONLY. The ceremony adds one (#96); nothing ever
|
||||
legitimately removes one. So this is not a judgement call — it is a defect,
|
||||
and almost always the same one (#122, caught in review of #118): an entry
|
||||
written under '## Unreleased' REPLACED the heading below it instead of being
|
||||
inserted ABOVE it. The shipped section's body is now sitting under
|
||||
'## Unreleased', and the version it belonged to has no section at all.
|
||||
|
||||
Nothing else will say so. git merges that edit cleanly — no conflict, no
|
||||
signal — and changelog-armed.sh stays green, because the TOP section is
|
||||
still the right one for this VERSION. The damage surfaces at the NEXT
|
||||
release, when release-notes.sh cannot find the section it extracts by
|
||||
heading, or worse, republishes the absorbed prose as if it were new.
|
||||
|
||||
The fix is to put the heading back and INSERT above it, never over it:
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your entry**
|
||||
|
||||
## $(printf '%s\n' "$missing" | head -1) — DATE <- untouched, still here
|
||||
|
||||
If you are genuinely renaming a released version, that is a rewrite of
|
||||
history this guard is meant to stop; say so in the PR and change the guard
|
||||
deliberately, in its own commit.
|
||||
EOF
|
||||
} >&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 — 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
|
||||
125
.github/scripts/drill-recorded.sh
vendored
125
.github/scripts/drill-recorded.sh
vendored
|
|
@ -1,125 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# drill-recorded.sh [<drills-dir>] [<version-file>] — assert that a RELEASE
|
||||
# tree carries a drill record: that the full real-hardware drill this repo
|
||||
# says a release rests on was actually run for this version, and written
|
||||
# down as drills/<version>.md.
|
||||
#
|
||||
# CONTRIBUTING.md has said since #96 that "this PR is where the release
|
||||
# ritual hangs: the full drill on real hardware, recorded". No release ever
|
||||
# did it. #95, #114 and #148 all shipped as a VERSION bump plus a
|
||||
# CHANGELOG.md stamp and nothing else. That is three releases through the
|
||||
# same gap, because the gate was a sentence in a document and the only thing
|
||||
# standing on it was a reviewer remembering to ask. A reviewer finally did —
|
||||
# which is the point: the ONE time it was caught is the time somebody
|
||||
# happened to look, and that is not a gate, it is luck with good manners.
|
||||
#
|
||||
# So the rule moves into CI, where it fires on every release PR whether or
|
||||
# not anyone is paying attention. It is keyed on VERSION for the same reason
|
||||
# changelog-armed.sh is — the two states are genuinely different:
|
||||
#
|
||||
# VERSION ends in -dev -> PASS. A development tree ships nothing, so
|
||||
# there is nothing for it to have proven. Almost
|
||||
# every PR in this repo is this case, and a guard
|
||||
# that nagged all of them would be turned off.
|
||||
# VERSION is bare -> the ceremony tree, the one about to ship.
|
||||
# <drills-dir>/<version>.md MUST exist and carry
|
||||
# at least one non-whitespace character.
|
||||
#
|
||||
# ONE FILE PER VERSION, and that is the whole design. Records used to be
|
||||
# sections sharing drill/RUNS.md, and every hard edge this script used to
|
||||
# have existed only because of that sharing: em-dash field matching, an
|
||||
# optional ' — DATE' tail, whole-version comparison so '0.9.0-rc1' could not
|
||||
# satisfy '0.9.0', avoiding '\x' escapes because CI runs mawk not gawk, and a
|
||||
# non-blank body rule to tell an empty section from a filled one. Two
|
||||
# separate defects were found in review because of that complexity — a
|
||||
# `sed '/./,$!d'` whitespace bypass, and heading-grammar drift from the
|
||||
# sibling repos. Splitting the file makes almost all of it UNREPRESENTABLE:
|
||||
# '0.9.0.md' and '0.9.0-rc1.md' are simply different files, so whole-version
|
||||
# matching is free rather than a trap, and there is no grammar left to drift.
|
||||
#
|
||||
# The directory is plain 'drills', NOT '.drills'. A dot-directory is
|
||||
# invisible to a glob without dotglob, which is exactly what caused #116 and
|
||||
# #118 in this repo; evidence a sweep cannot see is evidence that goes
|
||||
# missing quietly.
|
||||
#
|
||||
# drills/ is RELEASE EVIDENCE, one file per shipped version. It is NOT
|
||||
# drill/RUNS.md, which stays exactly as it is: the harness's own run log,
|
||||
# traps table and lore, a different artifact with a different purpose. This
|
||||
# guard reads drills/ and never looks at drill/RUNS.md.
|
||||
#
|
||||
# What this guard asserts is a RECORD, deliberately — not a passing drill.
|
||||
# CI cannot run the drill: it wants real hardware, a real Incus, and the
|
||||
# better part of an hour (see ci.yml, which says exactly this about the
|
||||
# rehearsal job it runs instead). What CI can do is refuse to let a release
|
||||
# claim a ritual it left no evidence of. That also leaves the maintainer
|
||||
# waiver intact and honest: a release that must ship without a full drill
|
||||
# records WHY in its own file, which is a deliberate, reviewable commit in
|
||||
# the diff — rather than the silent skip that got us here.
|
||||
#
|
||||
# A file of its own (not inlined in ci.yml) so test/release.sh can drive it
|
||||
# against constructed trees for both states — the same discipline as
|
||||
# changelog-armed.sh and release-notes.sh.
|
||||
|
||||
drills="${1:-drills}"
|
||||
version_file="${2:-VERSION}"
|
||||
|
||||
# A missing or empty version file is an ERROR, never a silent pass. A guard
|
||||
# that cannot read the version cannot know whether this tree is its business,
|
||||
# and "could not tell" must not resolve to "allowed".
|
||||
[ -f "$version_file" ] || { echo "drill-recorded: no such file: $version_file" >&2; exit 1; }
|
||||
|
||||
ver="$(tr -d '[:space:]' < "$version_file")"
|
||||
[ -n "$ver" ] || { echo "drill-recorded: $version_file is empty" >&2; exit 1; }
|
||||
|
||||
case "$ver" in
|
||||
*-dev)
|
||||
# Nothing to assert, and saying so is the point: the operator reading a
|
||||
# green log should be able to tell "the guard passed" from "the guard
|
||||
# decided this tree was not its business".
|
||||
echo "drill-recorded: VERSION '$ver' is a development tree — nothing to assert; only ceremony trees ship"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
record="$drills/$ver.md"
|
||||
|
||||
# The one rule that survives the rewrite, and it survives because it was
|
||||
# never really about heading parsing: a file of only spaces, tabs and
|
||||
# newlines is NOT a record. The first cut of the old guard extracted with
|
||||
# `sed '/./,$!d'`, where `.` matches a space — so a record whose body was one
|
||||
# tab satisfied a guard that promised "at least one non-blank line". An
|
||||
# evidence-free release for the price of an invisible character, on the one
|
||||
# check whose entire job is to demand evidence. Existence alone is a weaker
|
||||
# claim than `touch` can defeat, so existence alone is not the test.
|
||||
if [ ! -f "$record" ] || ! grep -q '[^[:space:]]' "$record"; then
|
||||
cat >&2 <<EOF
|
||||
drill-recorded: VERSION is '$ver' — a release — and there is no drill record
|
||||
for it. The file this looks for is:
|
||||
|
||||
$record
|
||||
|
||||
...with something written in it. Either the file is absent entirely, or it
|
||||
is present and blank; both mean the same thing, which is that this release
|
||||
is asserting a ritual it has left no evidence of.
|
||||
|
||||
The unblock is to RUN THE DRILL (drill/drill.sh, on real hardware) and
|
||||
record it at that path — what it measured, what it found, what it cost.
|
||||
See $drills/README.md for what a record should contain. CI cannot run the
|
||||
drill for you; it can only refuse a release that never ran one.
|
||||
|
||||
(drill/RUNS.md is a different artifact — the harness's own run log and
|
||||
traps. Appending there does not satisfy this guard, and is not meant to.)
|
||||
|
||||
If this release must ship without a full drill, that is a maintainer's call
|
||||
to make and it is still recorded: create the same file and say plainly that
|
||||
the drill was WAIVED and why. The guard requires a record, not a passing
|
||||
result — so a skip is a visible, reviewable file in the diff rather than
|
||||
the silent gap that let #95, #114 and #148 all ship unproven. See
|
||||
CONTRIBUTING.md, "Releases".
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "drill-recorded: VERSION '$ver' has a drill record at $record"
|
||||
486
.github/scripts/labels-reconcile.sh
vendored
486
.github/scripts/labels-reconcile.sh
vendored
|
|
@ -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:cli|C5DEF5|bin/box — the command surface
|
||||
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
|
||||
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
|
||||
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
|
||||
scope:templates|C5DEF5|templates/ — the box seeds
|
||||
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
|
||||
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
|
||||
30
.github/scripts/release-notes.sh
vendored
30
.github/scripts/release-notes.sh
vendored
|
|
@ -1,30 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# release-notes.sh <version> [<changelog>] — print exactly <version>'s
|
||||
# section of the changelog: every line between its '## <version> — <date>'
|
||||
# header and the next '## '. This is what release.yml hands to
|
||||
# 'gh release create', so the release notes are the curated prose we wrote,
|
||||
# not the PR list GitHub would generate (#83). Fails loudly when the section
|
||||
# is missing or empty — a tag without its changelog section is a release
|
||||
# ritual skipped, and an empty release body would paper over it.
|
||||
#
|
||||
# A file of its own (not inlined in release.yml) so test/release.sh drives
|
||||
# the same extraction against fixtures and the real CHANGELOG.md.
|
||||
|
||||
ver="${1:-}"
|
||||
changelog="${2:-CHANGELOG.md}"
|
||||
[ -n "$ver" ] || { echo "usage: release-notes.sh <version> [<changelog>]" >&2; exit 2; }
|
||||
[ -f "$changelog" ] || { echo "release-notes: no such file: $changelog" >&2; exit 1; }
|
||||
|
||||
# $2 of a section header ('## 0.6.0 — 2026-07-18') is the bare version —
|
||||
# compared WHOLE, so 0.6.0 can never match a 0.6.0-rc1 section (or vice
|
||||
# versa), and no regex-escaping of dots. sed drops the blank padding under
|
||||
# the header; the command substitution eats the trailing blanks.
|
||||
notes="$(awk -v ver="$ver" '
|
||||
/^## / { grab = ($2 == ver); next }
|
||||
grab { print }
|
||||
' "$changelog" | sed '/./,$!d')"
|
||||
|
||||
[ -n "$notes" ] || { echo "release-notes: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag (#83)" >&2; exit 1; }
|
||||
printf '%s\n' "$notes"
|
||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
|
|
@ -32,9 +32,9 @@ jobs:
|
|||
# `.github/` — and the whole release path (changelog-armed.sh, which
|
||||
# gates every PR, release-notes.sh, labels-reconcile.sh) went unlinted
|
||||
# while the comment above told the next author it was covered.
|
||||
# Measured on this tree: dotglob adds exactly those three and nothing
|
||||
# else — a checkout's `.git` carries no `*.sh` (its hooks ship as
|
||||
# `*.sample`), so `**/*.sh` does not wander into it.
|
||||
# Measured when this invariant was introduced: dotglob added exactly
|
||||
# those three and nothing else — a checkout's `.git` carries no `*.sh`
|
||||
# (its hooks ship as `*.sample`), so `**/*.sh` does not wander into it.
|
||||
#
|
||||
# The sweep below is the CLASS check, same shape as the eof_guard_sweep
|
||||
# in test/cli.sh (#112): the one-time fix is `dotglob`, but what keeps
|
||||
|
|
@ -58,8 +58,6 @@ jobs:
|
|||
shellcheck -x "${files[@]}"
|
||||
- name: cli tests
|
||||
run: bash test/cli.sh
|
||||
- name: labels state-machine tests
|
||||
run: bash test/labels-reconcile.sh
|
||||
- name: release-flow tests
|
||||
run: bash test/release.sh
|
||||
# The changelog is ARMED for the next entry (#108). Its own step rather
|
||||
|
|
@ -67,7 +65,7 @@ jobs:
|
|||
# tree, not about the release machinery, so when it goes red the log
|
||||
# says which check found the drift without anyone reading a suite.
|
||||
- name: changelog is armed for the next entry
|
||||
run: bash .github/scripts/changelog-armed.sh
|
||||
uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0
|
||||
# ...and no SHIPPED release heading was deleted or DUPLICATED (#122, #143).
|
||||
# Its own step for the same reason as the one above — when it goes red the
|
||||
# log names the invariant that broke — but a DIFFERENT invariant: armed is
|
||||
|
|
@ -104,11 +102,11 @@ jobs:
|
|||
# every ordinary push and PR, the script no-ops green by design, so
|
||||
# running it everywhere costs nothing and closes the route.
|
||||
- name: a release tree has a drill record
|
||||
run: bash .github/scripts/drill-recorded.sh
|
||||
uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0
|
||||
- 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 }}"
|
||||
uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0
|
||||
- name: vendored ceremony doctrine matches the pin
|
||||
uses: heavy-duty/ceremony/actions/docs-sync@0.1.0
|
||||
|
||||
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
|
||||
# disposable VM, which is exactly the substrate the rehearsal needs. It runs
|
||||
|
|
|
|||
75
.github/workflows/labels.yml
vendored
75
.github/workflows/labels.yml
vendored
|
|
@ -1,78 +1,13 @@
|
|||
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.
|
||||
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
|
||||
|
|
|
|||
217
.github/workflows/release.yml
vendored
217
.github/workflows/release.yml
vendored
|
|
@ -1,213 +1,18 @@
|
|||
name: release
|
||||
# The release publisher — two doors into the same act (#83, #96):
|
||||
#
|
||||
# * The merge door (#96): merging the `release`-labeled PR into main IS the
|
||||
# release. The label is the intent, the version transition is the
|
||||
# interlock — VERSION at the merge commit must be non-`-dev` AND must have
|
||||
# changed in this PR, so a mislabeled ordinary PR fails loudly and creates
|
||||
# NOTHING. The job then tags the merge commit via the API and publishes,
|
||||
# in the SAME job on purpose: a GITHUB_TOKEN-created tag does not trigger
|
||||
# other workflows (GitHub's anti-recursion), so that tag can never re-enter
|
||||
# the tag door below and double-publish — publishing here is the only
|
||||
# chance, and the no-existing-tag/release assert covers a manual tag
|
||||
# racing the merge.
|
||||
#
|
||||
# * The tag door (#83) stays as the documented manual fallback and backfill,
|
||||
# on a bare X.Y.Z tag push (the 0.6.0 tag set the precedent — no 'v'
|
||||
# prefix). The tag must name the tree's own VERSION (a mismatch fails
|
||||
# loudly and creates NOTHING — a wrong release is worse than a missing
|
||||
# one).
|
||||
#
|
||||
# Both doors publish the release body from that version's CHANGELOG.md
|
||||
# section (.github/scripts/release-notes.sh, shared with test/release.sh) —
|
||||
# the curated prose, not the generated PR list. No assets are uploaded: for a
|
||||
# pure-bash tree, GitHub's source tarball for the tag IS the package, and
|
||||
# install.sh downloads exactly that.
|
||||
# Triggers and permissions MUST live here (a called workflow cannot define them):
|
||||
on:
|
||||
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
|
||||
# `push:` silently replaces the first and kills a door (rig's review catch).
|
||||
push:
|
||||
# The merge door rides pushes to MAIN, not pull_request events, for one
|
||||
# load-bearing reason the first review round caught (#97): a workflow
|
||||
# run triggered by a pull_request from a public FORK gets a READ-ONLY
|
||||
# GITHUB_TOKEN — `permissions:` cannot raise that ceiling — and every
|
||||
# ceremony PR this org has ever merged is cross-repo from the bot fork.
|
||||
# The asserts would pass and the tag create would 403, red on main,
|
||||
# every release. A push to main is an in-repo event with the full write
|
||||
# token, whoever authored the PR.
|
||||
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
|
||||
# never be skipped by a shape filter that didn't match
|
||||
branches: [main]
|
||||
# Every tag, not a shape filter (rig's precedent): a tag that mismatches
|
||||
# VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY
|
||||
# below, not be silently skipped by a pattern that didn't match.
|
||||
tags: ["**"]
|
||||
|
||||
permissions:
|
||||
contents: write # create the tag ref + gh release create + the bump push
|
||||
# Two consumers (a declared permissions: block zeroes every unspecified
|
||||
# scope): the decide step's label read (commits/<sha>/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:
|
||||
# The merge door (#96), riding pushes to main (see the trigger comment:
|
||||
# fork PRs get a read-only token on pull_request events). The hand-set
|
||||
# `release` label (LABELS.md: automation never guesses intent) is read via
|
||||
# the API off the merge commit's PR, inside the decide step below.
|
||||
release-on-merge:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The pushed head plus its first parent (fetch-depth: 2): the
|
||||
# first parent is main the instant before the PR landed, which the
|
||||
# changed-in-this-PR assert compares against.
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 2
|
||||
# The decide step — the version asserts 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 — the PR that added this very
|
||||
# job included. The version tells them apart, in four states:
|
||||
# -dev, unchanged → work under the label: green NOTICE
|
||||
# no-op, not a red run per infra PR
|
||||
# -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, released → work merged in the post-release
|
||||
# window (ceremony landed, the -dev
|
||||
# bump has not): green NOTICE no-op
|
||||
# bare, unchanged, UNreleased→ the label says ship but this PR did
|
||||
# not mint the version: refuse to guess
|
||||
# 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)"
|
||||
base="$(git show HEAD^1:VERSION)"
|
||||
case "$ver" in
|
||||
*-dev)
|
||||
if [ "$base" = "$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') 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" ]; then
|
||||
if gh release view "$ver" --json name >/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 — creating nothing." >&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 lives on a fork — the trigger comment). No
|
||||
# merged, release-labeled PR behind this commit = a transition
|
||||
# nobody declared: refuse.
|
||||
if ! gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
|
||||
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then
|
||||
echo "VERSION transitioned ('$base' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#96), not a bare push — creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
|
||||
- name: release notes — the version's own CHANGELOG.md section
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
# release-notes.sh fails loudly on a missing/empty section, which
|
||||
# fails the release here — before anything is created.
|
||||
run: |
|
||||
bash .github/scripts/release-notes.sh "$(cat VERSION)" > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$ver" --silent 2>/dev/null; then
|
||||
echo "tag '$ver' already exists — a manual tag beat this run, or this is a re-run of a published release — creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
if gh release view "$ver" --json name >/dev/null 2>&1; then
|
||||
echo "release '$ver' already exists — creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: tag the merge commit, then publish — one job, on purpose
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
# Same job as the asserts: the GITHUB_TOKEN-created tag triggers no
|
||||
# workflows (GitHub's anti-recursion), so the tag door cannot fire
|
||||
# off it — this step is the release's only chance to publish.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MERGE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
gh api "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"
|
||||
# The post-release bump, folded into the release act (#96 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; 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
|
||||
|
||||
# The tag door (#83) — the manual fallback and backfill, unchanged. Gated
|
||||
# to the push event so a closed PR (the trigger above) never runs it
|
||||
# against a branch ref.
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: the tag must name the tree's VERSION
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
||||
echo "tag '$GITHUB_REF_NAME' does not match VERSION '$ver' — creating nothing." >&2
|
||||
echo "A release is a PR, then a tag (#83): the release PR bumps VERSION and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: release notes — the version's own CHANGELOG.md section
|
||||
# release-notes.sh fails loudly on a missing/empty section, which
|
||||
# fails the release here — before anything is created.
|
||||
run: |
|
||||
bash .github/scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: create the release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md"
|
||||
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
|
||||
with:
|
||||
version-source: file
|
||||
|
|
|
|||
7
AGENTS.md
Normal file
7
AGENTS.md
Normal file
|
|
@ -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.
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -5,6 +5,18 @@ which records not just what changed but what each drill run proved.
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- `box exec` preserves newlines and command argv across its login-user boundary (#169)
|
||||
|
||||
### Changed
|
||||
|
||||
- Release and repository governance now use the shared ceremony pinned at `0.1.0` (heavy-duty/ceremony#14)
|
||||
|
||||
### Added
|
||||
|
||||
- `kimi-box` template — the Moonshot Kimi CLI agent seed (#158; rig#109's tenant)
|
||||
|
||||
## 0.9.0 — 2026-07-21
|
||||
|
||||
### Added
|
||||
|
|
|
|||
352
CONTRIBUTING.md
352
CONTRIBUTING.md
|
|
@ -1,313 +1,83 @@
|
|||
# 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 repository is governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Agents read
|
||||
[`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first, then the role file it
|
||||
selects. The files under `.ceremony/` are machine-managed and must never be
|
||||
edited in place.
|
||||
|
||||
## The PR loop
|
||||
Only triage mints issues. Everyone else opens or extends a discussion when
|
||||
they find work outside an existing issue contract. Only humans merge.
|
||||
|
||||
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
||||
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`),
|
||||
and include a `CHANGELOG.md` entry under `## Unreleased` when the change
|
||||
deserves one.
|
||||
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.
|
||||
## Review panel
|
||||
|
||||
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` and `bash test/cli.sh` locally
|
||||
mirror what CI runs; the multi-user rehearsal runs in CI on a real Incus.
|
||||
The review panel is:
|
||||
|
||||
## Changelog entries
|
||||
- `claude-bot-andresmgsl`
|
||||
- `codex-bot-andresmgsl`
|
||||
- `grok-bot-andresmgsl`
|
||||
- `kimi-bot-andresmgsl`
|
||||
|
||||
Every PR that changes behaviour adds **one line** to `## Unreleased`. One line
|
||||
is the whole rule — if it wraps more than twice in your editor, cut it down.
|
||||
Every PR needs a current-head verdict from the whole panel minus its author.
|
||||
`dan-claude-bot` is triage-only and is never a reviewer. Draft PRs remain
|
||||
invisible to the panel; when ready, request every eligible reviewer.
|
||||
|
||||
- **Say what changed, and stop.** Why it was wrong, how it was found, what it
|
||||
cost, what it implies — that belongs in the PR body and the commit message,
|
||||
which is where anyone chasing the reasoning already goes. This file answers
|
||||
one question: what is different in this version.
|
||||
- **Any word that can be removed, is removed.**
|
||||
- **Lead with the surface, not the mechanism.** "`state:needs-human` is set at
|
||||
handoff" beats "the labels workflow now also wakes on `labeled`".
|
||||
- **Cite the issue or PR** — `(#141)` — and let the reader follow it for the
|
||||
rest.
|
||||
- **Mark a breaking change** with a leading `BREAKING:`.
|
||||
- Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`.
|
||||
- No bold run-in headings, no sub-paragraphs, no code blocks, no prose essays.
|
||||
## Code and verification
|
||||
|
||||
Good:
|
||||
- Bash executables use `set -euo pipefail`; test harnesses use `set -u`
|
||||
because they assert failing commands.
|
||||
- Keep shellcheck clean. Run `bash test/cli.sh` and `bash test/release.sh`;
|
||||
CI also runs the Incus multi-user rehearsal.
|
||||
- Match whole versions: `0.7.0` must never match `0.7.0-rc1`.
|
||||
- Comments preserve the incident that bought a rule, including its issue
|
||||
number.
|
||||
|
||||
```markdown
|
||||
- `state:needs-human` is set at handoff, not by the cron (#141)
|
||||
- An unreadable check rollup no longer reads as "nothing is failing" (#136)
|
||||
- BREAKING: `--class human|server` is now `--root-door closed|open` (#77)
|
||||
```
|
||||
## Changelog
|
||||
|
||||
Not an entry — that is a PR body:
|
||||
|
||||
```markdown
|
||||
- **`state:needs-human` no longer waits on the cron to become true** (#141) —
|
||||
the labels workflow now also wakes on `pull_request_target: labeled` and
|
||||
`unlabeled`, and the author sets it themselves when handing a PR over. A
|
||||
review landing was never a trigger. There is no `pull_request_review_target`,
|
||||
and on fork PRs — which is all of them here — ...
|
||||
```
|
||||
Every behavior-changing PR adds one concise line under `## Unreleased`,
|
||||
above the shipped heading below it. Cite the issue or PR. Never replace or
|
||||
duplicate a shipped heading; the shared armed and monotonic guards enforce
|
||||
both halves of this rule.
|
||||
|
||||
## Releases
|
||||
|
||||
A release is a PR, and merging it ships it
|
||||
([#96](https://github.com/heavy-duty/box/issues/96), building on
|
||||
[#83](https://github.com/heavy-duty/box/issues/83)):
|
||||
The release ceremony, merge and tag doors, version stamps, guard semantics,
|
||||
and recovery paths are defined by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony/blob/0.1.0/README.md).
|
||||
Box pins the shared machinery and doctrine at `0.1.0`.
|
||||
|
||||
1. **The release PR** — `release: X.Y.Z`, labeled `release` — bumps `VERSION`
|
||||
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
|
||||
date (feature PRs land their changelog entry as part of the PR, so the
|
||||
section is already written).
|
||||
Box uses the `file` version backend and has no artifact hook: for this
|
||||
pure-Bash tree, GitHub’s source tarball for the tag is the package, and
|
||||
`install.sh` downloads exactly that. `VERSION`, `CHANGELOG.md`, and
|
||||
`drills/<version>.md` remain box-owned release inputs.
|
||||
|
||||
**Stamping is two edits, not one — the second is re-arming.** After
|
||||
rewriting `## Unreleased` into `## X.Y.Z — DATE`, put an **empty
|
||||
`## Unreleased` back at the top**, immediately above the section you just
|
||||
stamped:
|
||||
### What a box drill proves
|
||||
|
||||
```markdown
|
||||
## Unreleased
|
||||
The box drill is the 85-probe VM isolation contract: it exercises the trust
|
||||
boundary on real hardware. The lighter Incus container rehearsal in CI proves
|
||||
the tier mechanics but cannot substitute for that boundary measurement. The
|
||||
record format and operating procedure live in [drills/README.md](drills/README.md).
|
||||
|
||||
## 0.7.1 — 2026-07-19
|
||||
`drills/<version>.md` and [`drill/RUNS.md`](drill/RUNS.md) are deliberately
|
||||
different artifacts. The former is per-release evidence read by the release
|
||||
guard; the latter is the harness’s ongoing run log and lore. Updating one
|
||||
never satisfies the purpose of the other.
|
||||
|
||||
### Fixed
|
||||
...
|
||||
```
|
||||
The family drills are independent and may run in any order. Each pins the
|
||||
same fixed candidate refs: rig’s drill uses the candidate box ref, while
|
||||
box’s drill mints with the candidate rig ref. Static refs dissolve the
|
||||
box↔rig runtime recursion; no repository needs to release first.
|
||||
|
||||
Not cosmetic, and not deferrable to the next PR that happens to need it.
|
||||
Between the stamp and the next re-creation of that heading, `main` has no
|
||||
`## Unreleased`. A PR authored *before* the release wrote its entry under
|
||||
that heading; with the heading gone, git lands the entry under whatever
|
||||
now occupies the position — **the section that just shipped** — and it
|
||||
merges **cleanly**, no conflict, no signal. The changelog then credits a
|
||||
released version with a change it does not contain, and nothing but a
|
||||
human reading the file will ever say so
|
||||
([#108](https://github.com/heavy-duty/box/issues/108); confirmed in the
|
||||
sibling repo as
|
||||
[heavy-duty/rig#66](https://github.com/heavy-duty/rig/issues/66)).
|
||||
A known gap remains from box#81: released box templates still default
|
||||
`RIG_REF` to `main`, so a later mint may consume a rig revision other than
|
||||
the one drilled. This conversion does not change that behavior or claim the
|
||||
gap is closed.
|
||||
|
||||
CI enforces the arming rule with
|
||||
[.github/scripts/changelog-armed.sh](.github/scripts/changelog-armed.sh),
|
||||
keyed on `VERSION`: a `-dev` tree must carry `## Unreleased` on top; a
|
||||
bare-`VERSION` tree (the ceremony PR, and the merge that publishes it) may
|
||||
carry either `## Unreleased` or its own stamped section. That is why the
|
||||
guard cannot simply demand `## Unreleased` unconditionally — the
|
||||
unconditional form is false on the ceremony PR's own tree and makes the
|
||||
release unshippable, which is why rig and cast both reverted it. The
|
||||
practical consequence: forgetting to re-arm does **not** block the release
|
||||
PR, it turns `main` red on the very next push — the automatic `-dev` bump
|
||||
the release itself makes. Do it in the ceremony PR and main is never
|
||||
disarmed at all.
|
||||
## Scope labels
|
||||
|
||||
**Release headings are append-only.** When you add your entry under
|
||||
`## Unreleased`, *insert above* the heading below it — never type over
|
||||
that line. Replacing `## 0.8.0 — 2026-07-19` with your own `## Unreleased`
|
||||
block deletes a shipped section: its prose is absorbed into `Unreleased`,
|
||||
`release-notes.sh` can no longer find the version it extracts by heading,
|
||||
and the next release republishes the absorbed prose as if it were new. git
|
||||
merges that edit cleanly and `changelog-armed.sh` stays green on it — the
|
||||
top section is still the right one — so
|
||||
[.github/scripts/changelog-monotonic.sh](.github/scripts/changelog-monotonic.sh)
|
||||
asserts the other half on every PR: the set of `## X.Y.Z` headings on your
|
||||
branch must be a **superset** of the set at the merge base
|
||||
([#122](https://github.com/heavy-duty/box/issues/122), caught in review of
|
||||
[#118](https://github.com/heavy-duty/box/pull/118)). The ceremony's own
|
||||
stamp passes it by construction — rewriting `## Unreleased` into
|
||||
`## X.Y.Z — DATE` adds a heading and removes none.
|
||||
|
||||
**The drill gates the release, and CI enforces it.** The release PR's
|
||||
flow is: draft → ready → bot round → **drill** → `state:needs-human` →
|
||||
maintainer merge (which *is* the release). CI proves the tier's semantics
|
||||
on every PR; the drill is what proves the VM trust boundary, and it wants
|
||||
real hardware and the better part of an hour, so it runs on the release
|
||||
PR's branch and nowhere else.
|
||||
|
||||
Record it in its own file, named exactly for the version:
|
||||
|
||||
```
|
||||
drills/X.Y.Z.md
|
||||
```
|
||||
|
||||
...with the run in it — what it measured, what it found, what it cost.
|
||||
See [drills/README.md](drills/README.md) for the shape.
|
||||
[.github/scripts/drill-recorded.sh](.github/scripts/drill-recorded.sh)
|
||||
asserts exactly that on every tree with a bare `VERSION`, which is every
|
||||
`release` PR and the merge that publishes it; a `-dev` tree passes with
|
||||
nothing to assert. It is **no longer a thing a reviewer has to remember**.
|
||||
|
||||
**`drills/` is release evidence; [`drill/RUNS.md`](drill/RUNS.md) is the
|
||||
harness's own log.** Two artifacts, two purposes. `drill/RUNS.md` keeps
|
||||
being what it has always been — every run of the harness, the traps table,
|
||||
the lore — and is not release-scoped. `drills/X.Y.Z.md` is the per-release
|
||||
record the gate reads. Appending to `drill/RUNS.md` does not satisfy the
|
||||
gate, and is not meant to.
|
||||
|
||||
One file per version is also why the guard is short. When records shared a
|
||||
file it had to match em-dash heading fields, tolerate an optional date
|
||||
tail, and compare versions *whole* so `0.9.0-rc1` could not satisfy
|
||||
`0.9.0` — and two separate defects were found in review because of that
|
||||
complexity. Now `0.9.0.md` and `0.9.0-rc1.md` are simply different files
|
||||
and none of it is representable.
|
||||
|
||||
It became CI's job because remembering did not work. The sentence this
|
||||
paragraph replaces described a step **no release had ever performed** —
|
||||
there was no drill record anywhere in the repo — and #95, #114 and #148
|
||||
all shipped through the gap as a `VERSION` bump plus a `CHANGELOG.md`
|
||||
stamp. The one time it was caught was the one time somebody happened to
|
||||
look, which is not a gate.
|
||||
|
||||
**The three drills are INDEPENDENT — there is no fixed order.** box, rig
|
||||
and cast each drill separately, in any order, on any schedule, in separate
|
||||
sittings if you like. What makes that safe is not sequencing, it is that
|
||||
every drill **pins the same fixed set of candidate refs**: rig's drill
|
||||
runs `--host yes` with `BOX_REF=release/<box-version>`, so it exercises the
|
||||
box that will actually ship; box's drill mints with
|
||||
`RIG_REF=release/<rig-version>`, so it exercises the rig that will actually
|
||||
ship. Both measure the same pair.
|
||||
|
||||
That is what dissolves the box↔rig recursion. box and rig really are
|
||||
**mutually recursive** — rig sits *below* box as the host-builder
|
||||
(`rig bootstrap --host yes` installs box and runs `setup-host`) and
|
||||
*above* it as the guest-converger (a `box new` seed's cloud-init curls
|
||||
rig's installer and runs `rig bootstrap <tenant>-box`); box's own source
|
||||
says as much, the seeds "invert the rig→box install edge (rig#28: rig
|
||||
installs box on hosts; now box guests install rig)". A cycle at *runtime*
|
||||
becomes two independent tests against one fixed pair, because the refs are
|
||||
**static identifiers that exist as soon as the release branches do**, long
|
||||
before any drill runs. Nothing has to be released, or drilled, first.
|
||||
|
||||
Within a single drill you of course bring the substrate up before probing
|
||||
it — a host before a guest. That is how you run a drill; it is not an
|
||||
ordering rule between repos.
|
||||
|
||||
Each repo drills in a **different way** and asserts a different thing: box
|
||||
asserts the **isolation contract** (the 85-probe VM trust boundary), rig
|
||||
asserts **convergence** (a machine reaches its role, idempotently), 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` and
|
||||
`RIG_REF` are mint-time environment variables (defaults
|
||||
`heavy-duty/rig` and `main`, `bin/box`), so a run pins the exact commits
|
||||
under test. That dissolves the chicken-and-egg: **no repo has to be
|
||||
released before another can be drilled.** And drilling the candidate *is*
|
||||
drilling the release — a release PR's diff is `VERSION` plus
|
||||
`CHANGELOG.md` and nothing else, so no executable byte differs between
|
||||
the tree that was drilled and the tree that ships.
|
||||
|
||||
The drills of a release set share **one run ID**. Each repo records its own
|
||||
legs in its own `drills/X.Y.Z.md`, citing that run ID and the other two
|
||||
repos' commit SHAs, so the three records reconcile afterwards. The guard
|
||||
reads only this repo's file — it asserts box's record exists, never the
|
||||
other two.
|
||||
|
||||
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 known gap, and box is where it belongs.** A *released* box still
|
||||
defaults `RIG_REF` to `main`, so what a user mints a week after a drill is
|
||||
not the combination that was drilled — the guest converges against
|
||||
whatever rig's main has become since. Pinning `RIG_REF` to a released rig
|
||||
tag in the templates is the outstanding step from
|
||||
[#81](https://github.com/heavy-duty/box/issues/81) (rig#32 step 5), and it
|
||||
is what would make a drilled combination reproducible for users. This PR
|
||||
does not fix it; box's source already says the two directions "track main
|
||||
unpinned today, said honestly ... until a release flow exists".
|
||||
|
||||
A maintainer **waiver** is possible, and it is still written down. The
|
||||
guard requires a *record*, not a passing result, so a release that must
|
||||
ship without a full drill creates `drills/X.Y.Z.md` anyway and says plainly
|
||||
that the drill was waived and why. Skipping then costs a deliberate,
|
||||
reviewable file in the diff — which is precisely what the three silent
|
||||
skips above did not.
|
||||
2. **The maintainer's merge IS the release.**
|
||||
[release.yml](.github/workflows/release.yml) fires on the merged,
|
||||
`release`-labeled PR and asserts before creating anything: `VERSION` at
|
||||
the merge commit is non-`-dev` **and changed in this PR** (the `-dev`
|
||||
interlock — a mislabeled ordinary PR fails loudly and creates nothing),
|
||||
the version's `CHANGELOG.md` section extracts non-empty, and no tag or
|
||||
release exists for it yet. Then, in the same job, it tags the merge
|
||||
commit bare `X.Y.Z` (no `v` prefix, the `0.6.0` precedent) and publishes
|
||||
the GitHub release with that section as the body. No assets — the source
|
||||
tarball for the tag is the package, and `install.sh` downloads exactly
|
||||
that.
|
||||
|
||||
*Manual fallback/backfill*: the tag-push path stays. Tagging the merge
|
||||
commit bare `X.Y.Z` by hand and pushing the tag still publishes the same
|
||||
way (release.yml asserts the tag names the tree's own `VERSION`) — for
|
||||
backfills, or the day the merge path is red.
|
||||
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, and says so loudly). Not cosmetic — the versioned layout
|
||||
names install trees after `VERSION`, so a `main` install without the
|
||||
bump would land in `versions/X.Y.Z` and impersonate the release just
|
||||
cut. On the *manual* tag path the bump stays yours: open the one-line
|
||||
PR after publishing.
|
||||
|
||||
## 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:
|
||||
|
||||
| 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.* |
|
||||
| `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. |
|
||||
| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. |
|
||||
| `blocked`, `release` | you — automation never guesses intent. |
|
||||
| `merge-next` | you or the agent owning the queue. Which PR lands first is a judgement about how they conflict, so the workflow never sets it — it only **clears** it, the moment the PR stops being something a human could merge. |
|
||||
| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. |
|
||||
|
||||
## Issues
|
||||
|
||||
Give issues the same care as PR titles: say the surface in the title, apply a
|
||||
`scope:` label and a type label (`bug` / `enhancement` / `documentation`) when
|
||||
you open one, and `blocked` when it waits on something — that is what keeps
|
||||
the board navigable as the issue count grows.
|
||||
- `scope:cli` — `bin/box`, the command surface
|
||||
- `scope:installer` — `install.sh`, versioned installs, upgrade/uninstall
|
||||
- `scope:host` — host setup, teardown, firewall, and isolation stack
|
||||
- `scope:tiers` — grant/revoke and multi-user boundaries
|
||||
- `scope:templates` — template and profile seeds
|
||||
- `scope:drill` — rehearsals, doctor, and run evidence
|
||||
|
|
|
|||
181
LABELS.md
181
LABELS.md
|
|
@ -1,181 +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` | `#B60205` | a `release` PR whose version has no drill record at [`drills/X.Y.Z.md`](drills/README.md) — the ceremony is **correct but unevidenced** | the drill is run and recorded (or a waiver is recorded) at `drills/X.Y.Z.md` |
|
||||
|
||||
`blocker:drill-pending` is the one blocker that says the diff is *fine*. The
|
||||
version is stamped, the changelog is right, CI's other guards are green — what
|
||||
is missing is the evidence that the release was proven on real hardware, which
|
||||
[.github/scripts/drill-recorded.sh](.github/scripts/drill-recorded.sh) refuses
|
||||
to let a release ship without (CONTRIBUTING.md, "Releases"). Naming it
|
||||
separately from `blocker:ci-red` matters because the two owe different work: a
|
||||
red check is a fix in the branch, a pending drill is an hour on a real host.
|
||||
|
||||
It must be **created by a maintainer account** — the bot account gets a `403`
|
||||
creating labels, so the labels workflow's bootstrap dispatch cannot mint it.
|
||||
Until it exists in the repo, `blocked` stands in: it is the closest true thing
|
||||
(the PR is waiting on something outside itself) and, usefully, the staleness
|
||||
sweep already skips it, so a release parked overnight on a drill does not
|
||||
collect a `stale`.
|
||||
|
||||
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:cli` | `bin/box` — the command surface itself |
|
||||
| `scope:installer` | `install.sh`, the versioned install layout, upgrade/uninstall |
|
||||
| `scope:host` | `host/` — setup-host, teardown, the firewall and isolation stack |
|
||||
| `scope:tiers` | the restricted tier — grant/revoke, multi-user semantics |
|
||||
| `scope:templates` | `templates/` — the box seeds |
|
||||
| `scope:drill` | `drill/` — the rehearsals, doctor, RUNS.md |
|
||||
|
||||
## 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
|
||||
# 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:cli" --color C5DEF5 --description "bin/box — the command surface" --force
|
||||
gh label create "scope:installer" --color C5DEF5 --description "install.sh, versioned installs, upgrade/uninstall" --force
|
||||
gh label create "scope:host" --color C5DEF5 --description "host/ — setup, teardown, firewall, isolation stack" --force
|
||||
gh label create "scope:tiers" --color C5DEF5 --description "restricted tier — grant/revoke, multi-user" --force
|
||||
gh label create "scope:templates" --color C5DEF5 --description "templates/ — the box seeds" --force
|
||||
gh label create "scope:drill" --color C5DEF5 --description "drill/ — rehearsals, doctor, RUNS.md" --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
|
||||
```
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
||||
network-isolated Incus box from a **template**; the coding-agent templates
|
||||
hand you a CLI agent on Debian 13 — `claude-box` (Claude Code), `codex-box`
|
||||
(OpenAI Codex), `grok-box` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
|
||||
(OpenAI Codex), `grok-box` (xAI Grok), `kimi-box` (Moonshot Kimi) — **box mints, [rig](https://github.com/heavy-duty/rig)
|
||||
converges**: the template is a thin seed, and the agent tooling lands via a
|
||||
creds-free `rig bootstrap` role auto-run at mint
|
||||
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
|
||||
|
|
@ -222,7 +222,7 @@ box shell work # enter as the template's user
|
|||
```
|
||||
|
||||
Pick whichever coding-agent template you like — `claude-box`, `codex-box`,
|
||||
`grok-box` — or `blank` for none. Inside the box, authenticate as needed. The
|
||||
`grok-box`, `kimi-box` — or `blank` for none. Inside the box, authenticate as needed. The
|
||||
`claude-box` template looks like this; the others follow the same shape with
|
||||
their own login step:
|
||||
|
||||
|
|
@ -245,6 +245,7 @@ another is just another directory. What ships today:
|
|||
| `claude-box` | Claude Code, creds-free — where this project started |
|
||||
| `codex-box` | OpenAI Codex CLI, creds-free |
|
||||
| `grok-box` | xAI Grok CLI, creds-free |
|
||||
| `kimi-box` | Moonshot Kimi CLI, creds-free |
|
||||
| `staging-box` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
|
||||
|
||||
**Templates are thin seeds; rig does the becoming**
|
||||
|
|
@ -256,7 +257,7 @@ pin tokens below). The seed is deliberately small — the tenant user, tmux,
|
|||
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
|
||||
joins a tailnet or admits credentials — and after cloud-init settles, box
|
||||
auto-runs the template's **creds-free** tenant role inside the guest
|
||||
(`rig bootstrap claude-box` / `codex-box` / `grok-box` / `staging-box`,
|
||||
(`rig bootstrap claude-box` / `codex-box` / `grok-box` / `kimi-box` / `staging-box`,
|
||||
[rig#31](https://github.com/heavy-duty/rig/issues/31); the roles carry a
|
||||
family suffix — `-box` for box tenants, `-server` for fleet machines — and a
|
||||
template is named for the role it converges,
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.9.0
|
||||
0.9.1-dev
|
||||
|
|
|
|||
11
bin/box
11
bin/box
|
|
@ -265,7 +265,7 @@ one of its snapshots — login state, git creds and clones carry over,
|
|||
isolation is preserved, and the clone knows its template's user without
|
||||
being told.
|
||||
|
||||
A tenant template (claude-box, codex-box, grok-box, staging-box) is a THIN seed — the user,
|
||||
A tenant template (claude-box, codex-box, grok-box, kimi-box, staging-box) is a THIN seed — the user,
|
||||
tmux, rig (#81) — and after cloud-init box auto-runs the creds-free tenant
|
||||
role inside it ('rig bootstrap <role>', rig#31): that role installs the
|
||||
agent CLI / server posture and the agent-context file. rig is preinstalled
|
||||
|
|
@ -1845,13 +1845,18 @@ box_user() {
|
|||
}
|
||||
|
||||
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
|
||||
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }
|
||||
# sudo -i joins its command argv into one shell string. In that join, a
|
||||
# backslash-newline becomes a shell continuation and silently deletes the
|
||||
# newline from a multi-line `box exec` payload (#169). Keep the login
|
||||
# environment explicitly, but let the inner shell exec the original argv.
|
||||
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -H bash -lc 'cd ~ && exec "$@"' _ "${args[@]:1}"; }
|
||||
|
||||
# A shell is a child of the exec connection: drop the terminal and everything
|
||||
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
|
||||
# attaches when the session exists and creates it when it doesn't, so starting
|
||||
# work and reattaching after a disconnect are the same command. 'shell' stays
|
||||
# bare on purpose — two verbs, two contracts.
|
||||
# bare on purpose — two verbs, two contracts. Unlike cmd_exec, tmux has no
|
||||
# caller-supplied command payload; its session name is validated below.
|
||||
cmd_tmux() {
|
||||
local session="${args[1]:-main}"
|
||||
case "$session" in
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# box design
|
||||
|
||||
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
|
||||
with a coding agent installed** (`claude-box`, `codex-box`, `grok-box`, or
|
||||
`blank` for none). It is infrastructure, not a project provisioner.
|
||||
with a coding agent installed** (`claude-box`, `codex-box`, `grok-box`,
|
||||
`kimi-box`, or `blank` for none). It is infrastructure, not a project provisioner.
|
||||
|
||||
See issue #3 for the full reframe and rationale. This doc captures the durable
|
||||
design decisions.
|
||||
|
|
@ -94,7 +94,7 @@ A template is a **thin, creds-free seed** — base image, the tenant user,
|
|||
tmux, and [rig](https://github.com/heavy-duty/rig) preinstalled — and what
|
||||
the box *becomes* lives in rig's bootstrap roles (rig#31): box auto-runs the
|
||||
template's creds-free tenant role after cloud-init (`rig bootstrap claude-box`
|
||||
/ `codex-box` / `grok-box` / `staging-box` — the roles carry a family suffix,
|
||||
/ `codex-box` / `grok-box` / `kimi-box` / `staging-box` — the roles carry a family suffix,
|
||||
`-box` for box tenants and `-server` for fleet machines, and the templates are
|
||||
named for the roles they converge, rig#76), which installs the agent CLI or
|
||||
server posture. The split is deliberate: cloud-init is a first-boot one-shot —
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
`box` mints trust-less, creds-free, isolated VMs with a coding agent already
|
||||
installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`) — the
|
||||
`claude-box`, `codex-box`, and `grok-box` templates each ship a CLI agent. The
|
||||
tool knows **nothing** about your project. There is no `install` step and no
|
||||
`claude-box`, `codex-box`, `grok-box`, and `kimi-box` templates each ship a
|
||||
CLI agent. The tool knows **nothing** about your project. There is no `install` step and no
|
||||
host-run setup script.
|
||||
|
||||
A project makes itself easy to stand up inside a box by shipping an optional
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ wait_box() { # poll until exec answers (the VM agent can take a while), ~4 min
|
|||
# Read from inside a box WITHOUT ever hanging the drill.
|
||||
#
|
||||
# Two traps, both hit for real:
|
||||
# · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and
|
||||
# all). Fine for a person, needless machinery for a probe.
|
||||
# · 'box exec' crosses a login-user shell boundary. Fine for a person,
|
||||
# needless machinery for a probe.
|
||||
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
|
||||
# inheriting the exec session's stdout keeps the substitution open forever,
|
||||
# and 'timeout' does not save you: it kills the wrapper, not the holder of
|
||||
|
|
@ -492,10 +492,10 @@ fi
|
|||
|
||||
# --- templates: the mint surface is itself a surface to test ----------------
|
||||
tpl_missing=""
|
||||
for t in blank claude-box codex-box grok-box; do
|
||||
for t in blank claude-box codex-box grok-box kimi-box; do
|
||||
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
|
||||
done
|
||||
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude-box, codex-box, grok-box" \
|
||||
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude-box, codex-box, grok-box, kimi-box" \
|
||||
|| no "templates listing is missing:$tpl_missing"
|
||||
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
|
||||
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
|
||||
|
|
|
|||
|
|
@ -202,6 +202,31 @@ fi
|
|||
rm -f "$mintlog"
|
||||
as_u "$U1" box list 2>/dev/null | grep -q '^mine ' && ok "(b) box list shows mine" || no "(b) box list does not show mine"
|
||||
as_u "$U1" box exec mine -- true >/dev/null 2>&1 && ok "(b) box exec mine -- true" || no "(b) box exec failed"
|
||||
|
||||
# #169: compare the new explicit login-user boundary with sudo -i while the
|
||||
# latter is still safe (one fixed `env` argv), then exercise the exact
|
||||
# silent-success multiline shape that sudo -i used to corrupt.
|
||||
target_user="$(as_u "$U1" incus config get mine user.box.user)"
|
||||
legacy_env="$(as_u "$U1" incus exec mine -- sudo -u "$target_user" -i env \
|
||||
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
|
||||
exec_env="$(as_u "$U1" box exec mine -- env \
|
||||
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
|
||||
[ "$exec_env" = "$legacy_env" ] \
|
||||
&& ok "(b) box exec preserves cross-user login environment (USER/LOGNAME/SHELL/HOME/PWD/PATH)" \
|
||||
|| {
|
||||
no "(b) box exec changed the cross-user login environment (#169)"
|
||||
diff -u <(printf '%s\n' "$legacy_env") <(printf '%s\n' "$exec_env") | sed 's/^/ /' || true
|
||||
}
|
||||
as_u "$U1" box exec mine -- bash -lc '
|
||||
set -o errexit -o nounset -o pipefail
|
||||
touch /tmp/box-169-step-one
|
||||
touch /tmp/box-169-step-two
|
||||
' >/dev/null 2>&1 \
|
||||
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-one \
|
||||
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-two \
|
||||
&& ok "(b) box exec preserves multiline commands, including the silent-success set shape" \
|
||||
|| no "(b) box exec corrupted a multiline command (#169)"
|
||||
|
||||
as_u "$U1" box info mine 2>/dev/null | grep -qF "$(boxnet_pfx)" \
|
||||
&& ok "(g) box info shows a boxnet ($(boxnet_pfx)x) address — placed on the hardened network" \
|
||||
|| no "(g) mine has no boxnet address in box info"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ drills/0.9.0-rc1.md
|
|||
```
|
||||
|
||||
The name must match the contents of `VERSION` exactly.
|
||||
[.github/scripts/drill-recorded.sh](../.github/scripts/drill-recorded.sh)
|
||||
[the pinned ceremony drill-recorded action](https://github.com/heavy-duty/ceremony/tree/0.1.0/actions/drill-recorded)
|
||||
refuses any tree with a bare `VERSION` that has no such file, or whose file is
|
||||
blank. A `-dev` tree passes with nothing to assert.
|
||||
|
||||
|
|
|
|||
22
templates/kimi-box/box.env
Normal file
22
templates/kimi-box/box.env
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# The kimi-box template — a thin, creds-free seed (#81): Debian 13, the
|
||||
# 'kimi' user, tmux and rig. What the box BECOMES — the Moonshot Kimi CLI,
|
||||
# docker, the agent-context file with its #80 guard — is rig's job:
|
||||
# box auto-runs 'rig bootstrap kimi-box' after mint (heavy-duty/rig#31,
|
||||
# the role itself: heavy-duty/rig#109).
|
||||
# The template is named for the role it converges, suffix and all
|
||||
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
|
||||
# fleet machines, '-box' for box tenants — and a seed that named the bare
|
||||
# 'kimi' would ask a post-rename rig for a role that no longer exists.
|
||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||
# key for a network or a security flag, on purpose — the shared box-net
|
||||
# profile is the placement contract and no template can weaken it.
|
||||
# BOX_USER must match the user user-data.yaml creates (the duplication is
|
||||
# deliberate and by hand) — and it is the tenant user the rig role converges
|
||||
# (rig dies loudly if the seed did not create it).
|
||||
BOX_DESCRIPTION="Moonshot Kimi CLI on Debian 13, creds-free — box mints, rig converges"
|
||||
BOX_IMAGE="images:debian/13/cloud"
|
||||
BOX_USER="kimi"
|
||||
BOX_CPU="4"
|
||||
BOX_MEMORY="8GiB"
|
||||
BOX_DISK="60GiB"
|
||||
BOX_BOOTSTRAP_ROLE="kimi-box"
|
||||
33
templates/kimi-box/user-data.yaml
Normal file
33
templates/kimi-box/user-data.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#cloud-config
|
||||
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||
# 'rig bootstrap kimi-box' (heavy-duty/rig#31), which box auto-runs after
|
||||
# mint; the agent-context file — including the #80 guard (never run
|
||||
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||
# once, instead of being copy-pasted per template.
|
||||
users:
|
||||
- name: kimi
|
||||
shell: /bin/bash
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
lock_passwd: true
|
||||
package_update: true
|
||||
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
|
||||
# curl + ca-certificates: the rig installer below rides them, and a bare
|
||||
# cloud image is not guaranteed to ship either.
|
||||
packages:
|
||||
- tmux
|
||||
- curl
|
||||
- ca-certificates
|
||||
runcmd:
|
||||
# Preinstall rig so the box can converge — and re-converge — via
|
||||
# 'rig bootstrap kimi-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||
# release flow). The pin covers both the installer fetched AND the tree
|
||||
# it installs, so a branch under review is testable end to end.
|
||||
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
|
||||
# environment, and the rig installer (set -u) reads $HOME for its DEST —
|
||||
# measured live: the mint died with "HOME: unbound variable" without it.
|
||||
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
|
||||
49
test/cli.sh
49
test/cli.sh
|
|
@ -68,6 +68,55 @@ check "unknown flag on list exits 2" 2 "unknown option" "$BOX" list
|
|||
# A flag that needs a value and gets none.
|
||||
check "--name with no value exits 2" 2 "--name needs a value" "$BOX" new --name
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# box exec — preserve command argv across the login-environment boundary
|
||||
# (#169). `sudo -i <command...>` joins argv into one shell string; its escaped
|
||||
# newline becomes a continuation, so a multi-line body can fuse into a
|
||||
# different valid command and still return 0. Drive cmd_exec through a fake
|
||||
# incus boundary that validates the wrapper shape and then executes it. This
|
||||
# test therefore fails against the old -i implementation before trusting the
|
||||
# marker files.
|
||||
# ---------------------------------------------------------------------------
|
||||
EXECFN="$(mktemp)"
|
||||
grep '^cmd_exec()' "$BOX" > "$EXECFN"
|
||||
check "box exec: cmd_exec extracted (guards the grep)" 0 "exec \"\$@\"" cat "$EXECFN"
|
||||
check "box exec: extracted function is valid bash" 0 "" bash -n "$EXECFN"
|
||||
check "box exec: command argv never rides sudo -i" 1 "" grep -q -- ' -i ' "$EXECFN"
|
||||
|
||||
exec_fixture() { # exec_fixture <command> [arg...]
|
||||
bash -c '
|
||||
set -e
|
||||
. "$0"
|
||||
box_user() { printf "%s\n" fixture-user; }
|
||||
incus() {
|
||||
[ "$1" = exec ] && [ "$2" = fixture ] && [ "$3" = -- ]
|
||||
shift 3
|
||||
[ "$1" = sudo ] && [ "$2" = -u ] && [ "$3" = fixture-user ] &&
|
||||
[ "$4" = -H ]
|
||||
shift 4
|
||||
"$@"
|
||||
}
|
||||
inst=fixture
|
||||
args=(fixture "$@")
|
||||
cmd_exec
|
||||
' "$EXECFN" "$@"
|
||||
}
|
||||
|
||||
EXEC_STATE="$(mktemp -d)"
|
||||
exec_body="set -euo pipefail
|
||||
touch '$EXEC_STATE/step-one'
|
||||
touch '$EXEC_STATE/step-two'"
|
||||
check "box exec: silent-success multiline body executes each statement" 0 "" \
|
||||
exec_fixture bash -lc "$exec_body"
|
||||
check "box exec: multiline step one was not fused into set argv" 0 "" \
|
||||
test -f "$EXEC_STATE/step-one"
|
||||
check "box exec: multiline step two was not fused into set argv" 0 "" \
|
||||
test -f "$EXEC_STATE/step-two"
|
||||
check "box exec: plain argv remains separate" 0 "one argument" \
|
||||
exec_fixture printf '%s\n' "one argument"
|
||||
rm -rf "$EXEC_STATE"
|
||||
rm -f "$EXECFN"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A shim `id` on PATH: lets us drive install.sh's DEST branch with a canned uid +
|
||||
# group output, exactly the way rig drives assert_runner_repo against fixtures.
|
||||
|
|
|
|||
|
|
@ -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 ]
|
||||
822
test/release.sh
822
test/release.sh
|
|
@ -1,22 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
# The release flow (#83), proven offline. Run: bash test/release.sh
|
||||
#
|
||||
# Three surfaces: the changelog-section extraction release.yml publishes
|
||||
# (.github/scripts/release-notes.sh, driven against fixtures AND the real
|
||||
# CHANGELOG.md so the header format cannot drift under it), the
|
||||
# latest-release tag resolution install.sh defaults to (the extracted
|
||||
# function, driven against a shim curl serving canned redirects), and the
|
||||
# three install channels — REAL install.sh runs against throwaway roots,
|
||||
# with the shim curl standing in for GitHub. Nothing here touches the
|
||||
# network; the same discipline as test/cli.sh. Deliberately no `set -e` —
|
||||
# the harness asserts on failing commands.
|
||||
# Box-specific release-channel coverage. Shared release/guard machinery lives
|
||||
# in heavy-duty/ceremony and is tested there; this file drives real install.sh.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0 FAIL=0
|
||||
|
||||
# check <desc> <want_exit> <want_substr> <cmd...>
|
||||
# Runs cmd, asserts exit code and (if non-empty) that combined output
|
||||
# contains want_substr.
|
||||
check() {
|
||||
local desc="$1" want="$2" substr="$3"; shift 3
|
||||
local out rc
|
||||
|
|
@ -34,734 +22,10 @@ check() {
|
|||
echo "ok: $desc"; PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
NOTES="$ROOT/.github/scripts/release-notes.sh"
|
||||
WORK="$(mktemp -d)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# release-notes.sh — the extraction, against a fixture changelog that carries
|
||||
# every boundary: an Unreleased section that must never leak into a release,
|
||||
# two adjacent versions, a version that prefixes another (0.7.0 vs
|
||||
# 0.7.0-rc1), and a stamped-but-empty section that must refuse.
|
||||
# ---------------------------------------------------------------------------
|
||||
check "release-notes: runnable bash" 0 "" bash -n "$NOTES"
|
||||
|
||||
FIX="$WORK/CHANGELOG.md"
|
||||
cat > "$FIX" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
Intro prose that belongs to no section.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Not yet released** — must never appear in a release body.
|
||||
|
||||
## 0.7.0 — 2026-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- **The seven-oh entry** — prose for 0.7.0, and only 0.7.0.
|
||||
|
||||
## 0.7.0-rc1 — 2026-07-19
|
||||
|
||||
- **The rc entry** — must not ride along with 0.7.0.
|
||||
|
||||
## 0.6.0 — 2026-07-18
|
||||
|
||||
- **The six-oh entry** — the previous release's prose.
|
||||
|
||||
## 0.5.0 — 2026-07-15
|
||||
|
||||
EOF
|
||||
|
||||
check "extract: prints the asked-for version's prose" 0 "The seven-oh entry" bash "$NOTES" 0.7.0 "$FIX"
|
||||
check "extract: keeps the section's own subheaders" 0 "### Added" bash "$NOTES" 0.7.0 "$FIX"
|
||||
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
|
||||
check "extract: stops at the NEXT section" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "rc entry"' _ "$NOTES" "$FIX"
|
||||
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
|
||||
check "extract: never leaks Unreleased into a release" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "Not yet released"' _ "$NOTES" "$FIX"
|
||||
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
|
||||
check "extract: never prints the header itself" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "^## "' _ "$NOTES" "$FIX"
|
||||
check "extract: the version is matched WHOLE (rc1 is its own section)" \
|
||||
0 "The rc entry" bash "$NOTES" 0.7.0-rc1 "$FIX"
|
||||
check "extract: an adjacent older version still resolves" 0 "six-oh" bash "$NOTES" 0.6.0 "$FIX"
|
||||
check "extract: a missing version refuses by name" 1 "no section for '9.9.9'" bash "$NOTES" 9.9.9 "$FIX"
|
||||
check "extract: ...and names the ritual that was skipped" 1 "#83" bash "$NOTES" 9.9.9 "$FIX"
|
||||
check "extract: a stamped-but-EMPTY section refuses" 1 "no section for '0.5.0'" bash "$NOTES" 0.5.0 "$FIX"
|
||||
check "extract: no version argument is a usage error" 2 "usage:" bash "$NOTES"
|
||||
check "extract: a missing changelog refuses by path" 1 "no such file" bash "$NOTES" 1.0.0 "$WORK/nope.md"
|
||||
|
||||
# The REAL changelog: released sections must keep extracting, or release.yml
|
||||
# breaks the day it runs — this is the guard against header-format drift.
|
||||
check "extract: the real 0.6.0 section extracts" 0 "restricted tier" bash "$NOTES" 0.6.0 "$ROOT/CHANGELOG.md"
|
||||
check "extract: the real 0.5.0 section extracts" 0 "" bash "$NOTES" 0.5.0 "$ROOT/CHANGELOG.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# release.yml — a daemon-free run cannot push a tag, so the wiring is
|
||||
# grepped, fail-closed (the house discipline): the VERSION assertion, the
|
||||
# shared extraction script, and that the tag is verified before creation.
|
||||
# ---------------------------------------------------------------------------
|
||||
RY="$ROOT/.github/workflows/release.yml"
|
||||
check "release.yml: exists" 0 "" test -f "$RY"
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "release.yml: asserts tag == VERSION before creating anything" 0 "" \
|
||||
grep -qF 'GITHUB_REF_NAME" != "$ver"' "$RY"
|
||||
check "release.yml: the mismatch creates NOTHING (exit 1)" 0 "" \
|
||||
grep -qF 'creating nothing' "$RY"
|
||||
check "release.yml: the body comes from the shared extraction script" 0 "" \
|
||||
grep -qF '.github/scripts/release-notes.sh' "$RY"
|
||||
check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" \
|
||||
grep -qF -- '--verify-tag' "$RY"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# release.yml, the merge door (#96) — merging the release-labeled PR IS the
|
||||
# release. Same daemon-free discipline: the gate, the four asserts, and the
|
||||
# same-job tag+publish are grep-pinned, fail-closed.
|
||||
# ---------------------------------------------------------------------------
|
||||
check "release.yml: the tag-push trigger is still present (manual fallback)" 0 "" \
|
||||
grep -qF 'tags: ["**"]' "$RY"
|
||||
# The merge door rides pushes to MAIN, not pull_request events: a fork PR
|
||||
# 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 (#97 round 1). The label — the
|
||||
# operator's intent — is read via the API off the merge commit's PR.
|
||||
check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \
|
||||
grep -qF 'branches: [main]' "$RY"
|
||||
check "release.yml: the doors split on the ref — tags to the tag door..." 0 "" \
|
||||
grep -qF "startsWith(github.ref, 'refs/tags/')" "$RY"
|
||||
check "release.yml: ...main to the merge door" 0 "" \
|
||||
grep -qF "github.ref == 'refs/heads/main'" "$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/$GITHUB_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"
|
||||
check "release.yml: assert — VERSION at the merge commit is non--dev" 0 "" \
|
||||
grep -qF '*-dev)' "$RY"
|
||||
check "release.yml: assert — VERSION changed IN THIS PR (first parent vs merge)" 0 "" \
|
||||
grep -qF 'git show HEAD^1:VERSION' "$RY"
|
||||
check "release.yml: assert — no existing tag for the version" 0 "" \
|
||||
grep -qF 'git/ref/tags/' "$RY"
|
||||
check "release.yml: assert — no existing release for the version" 0 "" \
|
||||
grep -qF 'gh release view' "$RY"
|
||||
check "release.yml: BOTH doors extract notes via the shared script" 0 "2" \
|
||||
grep -cF 'bash .github/scripts/release-notes.sh' "$RY"
|
||||
check "release.yml: every failing assert creates NOTHING (both doors)" 0 "5" \
|
||||
grep -cF 'creating nothing' "$RY"
|
||||
check "release.yml: the merge door creates the tag ref via the API..." 0 "" \
|
||||
grep -qF 'ref=refs/tags/' "$RY"
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "release.yml: ...at the MERGE commit" 0 "" \
|
||||
grep -qF 'sha=$MERGE_SHA' "$RY"
|
||||
check "release.yml: BOTH doors publish bound to an existing tag (--verify-tag)" 0 "2" \
|
||||
grep -cF -- '--verify-tag' "$RY"
|
||||
check "release.yml: tag + publish share one job (the anti-recursion shape)" 0 "" \
|
||||
grep -qF 'anti-recursion' "$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 — the PR
|
||||
# that added the merge door included): work under the label no-ops GREEN —
|
||||
# in the -dev steady state and in the post-release window (bare, unchanged,
|
||||
# already released) — while every half-ceremony refuses. Pin each verdict
|
||||
# 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"
|
||||
check "release.yml: decide gates every later merge-door step on ceremony=yes" 0 "4" \
|
||||
grep -cF "if: steps.decide.outputs.ceremony == 'yes'" "$RY"
|
||||
# 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"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# changelog-armed.sh (#108) — the changelog has a heading for the NEXT entry.
|
||||
#
|
||||
# The drift this catches produces no conflict and no error: the ceremony
|
||||
# stamps '## Unreleased' away, and a PR authored before the release merges its
|
||||
# entry cleanly into the section that just shipped. box has no top-section
|
||||
# guard at all today — the two checks above pin only that the 0.6.0 and 0.5.0
|
||||
# sections still extract, which a disarmed main passes happily.
|
||||
#
|
||||
# BOTH states are constructed as real trees and the real script is run against
|
||||
# them, because the failure mode of the naive fix is precisely a state
|
||||
# mismatch: an unconditional '## Unreleased' requirement is green on main and
|
||||
# false on the ceremony PR's own tree, which is why rig#44 and
|
||||
# heavy-duty/cast#108 both had to revert one. A test that only drives the
|
||||
# -dev state would have shipped that bug again.
|
||||
# ---------------------------------------------------------------------------
|
||||
ARMED="$ROOT/.github/scripts/changelog-armed.sh"
|
||||
check "changelog-armed: runnable bash" 0 "" bash -n "$ARMED"
|
||||
|
||||
# tree <dir> <version> <changelog-body...> — a two-file tree to run against
|
||||
tree() {
|
||||
local d="$WORK/$1" v="$2"; shift 2
|
||||
mkdir -p "$d"
|
||||
printf '%s\n' "$v" > "$d/VERSION"
|
||||
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
|
||||
echo "$d"
|
||||
}
|
||||
armed() { bash "$ARMED" "$1/CHANGELOG.md" "$1/VERSION"; }
|
||||
|
||||
# --- the -dev steady state: armed is the only legal shape ------------------
|
||||
T="$(tree dev-armed 0.7.1-dev '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "armed: a -dev tree with '## Unreleased' on top passes" 0 "agrees" armed "$T"
|
||||
T="$(tree dev-disarmed 0.7.1-dev '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "armed: a -dev tree WITHOUT it fails — the #108 drift, caught" 1 "MUST carry" armed "$T"
|
||||
check "armed: ...and the failure says how to fix it (re-arm)" 1 "re-arm" armed "$T"
|
||||
check "armed: ...naming the issue and its origin" 1 "heavy-duty/rig#66" armed "$T"
|
||||
|
||||
# --- the ceremony PR: bare VERSION, BOTH arrangements legal ----------------
|
||||
# This is the pair that the reverted guards got wrong. Neither may fail, or
|
||||
# the release PR cannot go green and the ceremony is unshippable.
|
||||
T="$(tree rel-stamped 0.7.1 '## 0.7.1 — 2026-07-19' '' '- **This release**')"
|
||||
check "armed: a bare VERSION with its OWN stamped section on top passes" 0 "agrees" armed "$T"
|
||||
T="$(tree rel-rearmed 0.7.1 '## Unreleased' '' '## 0.7.1 — 2026-07-19' '' '- **This release**')"
|
||||
check "armed: ...and so does the RE-ARMED ceremony tree (the shape #108 asks for)" \
|
||||
0 "agrees" armed "$T"
|
||||
# The one bare-VERSION arrangement that is wrong: a stamp naming another
|
||||
# version. release.yml would publish a body that is not this release's.
|
||||
T="$(tree rel-wrong 0.7.1 '## 0.7.0 — 2026-07-19' '' '- **Some other release**')"
|
||||
check "armed: a bare VERSION under someone ELSE's stamped section fails" 1 "wrong number" armed "$T"
|
||||
|
||||
# --- the HALF-ceremony: the gap the two bare-VERSION clauses leave ---------
|
||||
# VERSION bumped to the release, '## Unreleased' still populated on top, and
|
||||
# the section for that version never stamped at all. The wrong-number test
|
||||
# above is false on its FIRST clause here and short-circuits, so before
|
||||
# heavy-duty/rig#67's rule this tree passed the guard and was refused instead
|
||||
# by release.yml — at publish time, after the merge, on main, with the release
|
||||
# already half-shipped. Caught here one step earlier, by running the same
|
||||
# extraction release.yml runs.
|
||||
T="$(tree rel-half 0.8.0 '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "armed: a bare VERSION whose section was never stamped fails (half-ceremony)" \
|
||||
1 "no non-empty section" armed "$T"
|
||||
check "armed: ...and names the stamp as MISSING, not misnumbered" \
|
||||
1 "MISSING, not misnumbered" armed "$T"
|
||||
# The wording is the whole point of the separate branch: an operator sent to
|
||||
# fix a version number that is already correct will not find the real problem.
|
||||
not_wrong_number() { ! armed "$1" 2>&1 | grep -qF 'wrong number'; }
|
||||
check "armed: ...and not as the wrong-number case, which has a different fix" \
|
||||
0 "" not_wrong_number "$T"
|
||||
# A section that exists but carries no prose is the same failure: release.yml
|
||||
# would publish an empty body, which is what release-notes.sh already refuses.
|
||||
T="$(tree rel-empty 0.7.1 '## 0.7.1 — 2026-07-19' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "armed: a bare VERSION whose section is stamped but EMPTY fails" \
|
||||
1 "no non-empty section" armed "$T"
|
||||
|
||||
# --- degenerate trees refuse rather than pass by accident ------------------
|
||||
T="$(tree no-sections 0.7.1-dev 'Prose and no headings at all.')"
|
||||
check "armed: a changelog with no '## ' section at all fails" 1 "no '## ' section at all" armed "$T"
|
||||
check "armed: a missing changelog refuses by path" 1 "no such file" \
|
||||
bash "$ARMED" "$WORK/nope.md" "$ROOT/VERSION"
|
||||
check "armed: a missing VERSION refuses by path" 1 "no such file" \
|
||||
bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/nope-version"
|
||||
mkdir -p "$WORK/empty-ver"; : > "$WORK/empty-ver/VERSION"
|
||||
check "armed: an empty VERSION refuses" 1 "is empty" \
|
||||
bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/empty-ver/VERSION"
|
||||
|
||||
# --- and the tree under test, which is the assertion that actually fires ---
|
||||
check "armed: THIS tree's VERSION and CHANGELOG.md agree" 0 "agrees" \
|
||||
bash "$ARMED" "$ROOT/CHANGELOG.md" "$ROOT/VERSION"
|
||||
|
||||
# The guard is only a guard if CI runs it, and the ceremony is only re-armed
|
||||
# if the ceremony step says so. Fail-closed pins on both, since a guard nobody
|
||||
# invokes and a step nobody wrote are the two ways this reverts silently.
|
||||
check "ci.yml: runs the changelog-armed guard" 0 "" \
|
||||
grep -qF 'changelog-armed.sh' "$ROOT/.github/workflows/ci.yml"
|
||||
check "CONTRIBUTING: the ceremony re-arms '## Unreleased' after stamping" 0 "" \
|
||||
grep -qF 'Stamping is two edits, not one' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING: ...and names the guard that enforces it" 0 "" \
|
||||
grep -qF 'changelog-armed.sh' "$ROOT/CONTRIBUTING.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# drill-recorded.sh — a RELEASE tree has a drill record at drills/<ver>.md.
|
||||
#
|
||||
# The gap this closes is not a drift or a race; it is a rule that was only
|
||||
# ever a sentence. CONTRIBUTING.md has said since #96 that the release PR is
|
||||
# where the full real-hardware drill hangs — and #95, #114 and #148 all
|
||||
# shipped without one, because nothing but a reviewer's memory stood on it.
|
||||
#
|
||||
# Both states are constructed as real trees, same discipline as the armed
|
||||
# block above, and for a sharper reason here: the -dev pass is not a
|
||||
# convenience, it is what keeps the guard installable. A version of this rule
|
||||
# that fired on every tree would be red on every ordinary PR in the repo and
|
||||
# would be switched off inside a day.
|
||||
#
|
||||
# Every fixture carries its OWN VERSION and its OWN drills/ dir. Reaching for
|
||||
# $ROOT/VERSION instead is exactly the coupling #146 had to fix: the suite
|
||||
# then passes or fails on whichever state the repo happens to be in, so it
|
||||
# goes red on the ceremony tree — the one tree where the release suite most
|
||||
# needs to be trustworthy.
|
||||
#
|
||||
# Records are now ONE FILE PER VERSION, which is why this block is shorter
|
||||
# than the one it replaces. The heading-parsing cases are gone because there
|
||||
# is no heading to parse: em-dash fields, the optional ' — DATE' tail, and
|
||||
# "an empty section must not borrow a neighbour's prose" were all artifacts
|
||||
# of records sharing drill/RUNS.md. Deleting a test is only safe when the
|
||||
# failure it described became unrepresentable, and that is the case here —
|
||||
# except for the whitespace rule, which was never about headings and is
|
||||
# pinned harder below.
|
||||
# ---------------------------------------------------------------------------
|
||||
DRILLED="$ROOT/.github/scripts/drill-recorded.sh"
|
||||
check "drill-recorded: runnable bash" 0 "" bash -n "$DRILLED"
|
||||
check "drill-recorded: executable" 0 "" test -x "$DRILLED"
|
||||
|
||||
# dtree <dir> <version> [<record-version> <record-body...>] — a self-contained
|
||||
# tree: its own VERSION, and its own drills/ dir created only when asked for.
|
||||
# With 2 args there is NO drills/ dir at all, which is a case in its own right.
|
||||
dtree() {
|
||||
local d="$WORK/$1" v="$2"; shift 2
|
||||
mkdir -p "$d"
|
||||
printf '%s\n' "$v" > "$d/VERSION"
|
||||
if [ "$#" -gt 0 ]; then
|
||||
local rec="$1"; shift
|
||||
mkdir -p "$d/drills"
|
||||
printf '%s\n' "$@" > "$d/drills/$rec.md"
|
||||
fi
|
||||
echo "$d"
|
||||
}
|
||||
drilled() { bash "$DRILLED" "$1/drills" "$1/VERSION"; }
|
||||
|
||||
# --- the -dev steady state: nothing to assert ------------------------------
|
||||
# No record at all, and that must PASS. This is the case that makes the guard
|
||||
# survivable on an ordinary PR.
|
||||
T="$(dtree drill-dev 0.8.1-dev)"
|
||||
check "drill-recorded: a -dev tree with NO drills dir at all passes" 0 "nothing to assert" drilled "$T"
|
||||
check "drill-recorded: ...and says why it declined to judge" 0 "development tree" drilled "$T"
|
||||
|
||||
# --- the ceremony tree: a record is required and must not be blank ---------
|
||||
T="$(dtree drill-ok 0.9.0 0.9.0 '# Release drill — 0.9.0' '' '- 47/47 on real hardware')"
|
||||
check "drill-recorded: a bare VERSION with a non-empty record for it passes" \
|
||||
0 "has a drill record" drilled "$T"
|
||||
|
||||
# No drills/ dir AT ALL is the commonest way to fail this: the release branch
|
||||
# was cut and nobody ran anything. It must fail loudly, not error out on a
|
||||
# missing directory in some way that reads like a broken guard.
|
||||
T="$(dtree drill-nodir 0.9.0)"
|
||||
check "drill-recorded: a bare VERSION with NO drills dir fails" 1 "no drill record" drilled "$T"
|
||||
check "drill-recorded: ...and the failure names the version" 1 "VERSION is '0.9.0'" drilled "$T"
|
||||
|
||||
# The dir exists — some other release left its record here — but this version
|
||||
# has none. A directory listing is not evidence about a particular version.
|
||||
T="$(dtree drill-otherver 0.9.0 0.8.0 '# Release drill — 0.8.0' '' '- the previous release')"
|
||||
check "drill-recorded: a drills dir with no file for THIS version fails" \
|
||||
1 "no drill record" drilled "$T"
|
||||
|
||||
check "drill-recorded: ...and names the unblock — run the drill and record it" \
|
||||
1 "RUN THE DRILL" drilled "$T"
|
||||
check "drill-recorded: ...and names the path it wanted" 1 "drills/0.9.0.md" drilled "$T"
|
||||
check "drill-recorded: ...and offers the recorded maintainer waiver as the other way out" \
|
||||
1 "WAIVED" drilled "$T"
|
||||
check "drill-recorded: ...and names the three releases that shipped through the gap" \
|
||||
1 "#95, #114 and #148" drilled "$T"
|
||||
|
||||
# A file that exists and says nothing is the same failure as no file: the
|
||||
# ceremony created the shape and never wrote the evidence. `touch` must not
|
||||
# be able to satisfy a gate whose entire job is to demand evidence.
|
||||
mkdir -p "$WORK/drill-emptyfile/drills"
|
||||
printf '0.9.0\n' > "$WORK/drill-emptyfile/VERSION"
|
||||
: > "$WORK/drill-emptyfile/drills/0.9.0.md"
|
||||
check "drill-recorded: a record file that is present but EMPTY fails" \
|
||||
1 "no drill record" drilled "$WORK/drill-emptyfile"
|
||||
|
||||
# ...and WHITESPACE is not evidence either. This is the ONE rule carried over
|
||||
# from the heading-parsing guard, because it was never a heading problem. The
|
||||
# first cut extracted with `sed '/./,$!d'`, where `.` matches a space — so a
|
||||
# record whose body was one tab passed a guard that promised "at least one
|
||||
# non-blank line". An evidence-free release for the price of an invisible
|
||||
# character. All three reviewers on #149 found it independently, which is the
|
||||
# level of scrutiny it deserved and not a level it should ever need again.
|
||||
# Splitting the file per version removed the parsing; it did not remove this.
|
||||
mkdir -p "$WORK/drill-blank/drills"
|
||||
printf '0.9.0\n' > "$WORK/drill-blank/VERSION"
|
||||
printf ' \n\t\n\n' > "$WORK/drill-blank/drills/0.9.0.md"
|
||||
check "drill-recorded: a record of only spaces, tabs and newlines fails (#149)" \
|
||||
1 "no drill record" drilled "$WORK/drill-blank"
|
||||
|
||||
# --- the version is matched WHOLE, both directions -------------------------
|
||||
# Under the old scheme this took deliberate field-matching against a heading,
|
||||
# and getting it wrong would have let a release-candidate drill stand in for
|
||||
# the release it was a candidate for — the one thing an rc drill by
|
||||
# definition did not measure. One file per version makes it free: these are
|
||||
# simply different paths. Pinned anyway, in both directions, so that a future
|
||||
# rewrite reaching for a prefix or glob match cannot quietly reintroduce it.
|
||||
T="$(dtree drill-rc-only 0.9.0 0.9.0-rc1 '# Release drill — 0.9.0-rc1' '' '- the rc drill')"
|
||||
check "drill-recorded: a 0.9.0-rc1.md record does NOT satisfy 0.9.0" 1 "no drill record" drilled "$T"
|
||||
T="$(dtree drill-rel-only 0.9.0-rc1 0.9.0 '# Release drill — 0.9.0' '' '- the release drill')"
|
||||
check "drill-recorded: ...and a 0.9.0.md record does NOT satisfy 0.9.0-rc1" 1 "no drill record" drilled "$T"
|
||||
|
||||
# --- degenerate trees refuse rather than pass by accident ------------------
|
||||
# A guard that cannot read the version cannot know whether this tree is its
|
||||
# business, and "could not tell" must never resolve to "allowed".
|
||||
check "drill-recorded: a missing VERSION refuses by path" 1 "no such file" \
|
||||
bash "$DRILLED" "$WORK/drill-ok/drills" "$WORK/nope-version"
|
||||
mkdir -p "$WORK/drill-empty-ver"; : > "$WORK/drill-empty-ver/VERSION"
|
||||
check "drill-recorded: an empty VERSION refuses" 1 "is empty" \
|
||||
bash "$DRILLED" "$WORK/drill-ok/drills" "$WORK/drill-empty-ver/VERSION"
|
||||
# The drills dir is only consulted once the tree is a release — a -dev tree
|
||||
# must not be refused for a directory it has no business reading.
|
||||
check "drill-recorded: a -dev tree does not even look for the drills dir" 0 "nothing to assert" \
|
||||
bash "$DRILLED" "$WORK/nope-drills" "$WORK/drill-dev/VERSION"
|
||||
|
||||
# --- and the tree under test ----------------------------------------------
|
||||
# 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, and demanding success
|
||||
# is wrong there in a way that took a red release PR to notice.
|
||||
#
|
||||
# A -dev tree is vacuous, so it must pass. 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 therefore made test/release.sh
|
||||
# UN-GREENABLE on every release branch before its drill, and reported it as a
|
||||
# `release-flow tests` failure rather than as the gate doing its job: the same
|
||||
# noise-and-misattribution shape as #146, where a fixture read the repo's real
|
||||
# VERSION and only misbehaved on the ceremony tree.
|
||||
#
|
||||
# So: assert the verdict that this tree's own state entails. Green in all three
|
||||
# states — dev, ceremony-without-record, ceremony-with-record — and still red if
|
||||
# the guard ever disagrees with the tree in front of it.
|
||||
THIS_VER="$(tr -d '[:space:]' < "$ROOT/VERSION")"
|
||||
case "$THIS_VER" in
|
||||
*-dev)
|
||||
check "drill-recorded: THIS tree is -dev, and the guard is vacuous on it" 0 "nothing to assert" \
|
||||
bash "$DRILLED" "$ROOT/drills" "$ROOT/VERSION" ;;
|
||||
*)
|
||||
if [ -s "$ROOT/drills/$THIS_VER.md" ]; then
|
||||
check "drill-recorded: THIS ceremony tree HAS its record, and the guard accepts it" 0 "" \
|
||||
bash "$DRILLED" "$ROOT/drills" "$ROOT/VERSION"
|
||||
else
|
||||
check "drill-recorded: THIS ceremony tree has NO record yet, and the guard refuses it" 1 "no drill record" \
|
||||
bash "$DRILLED" "$ROOT/drills" "$ROOT/VERSION"
|
||||
fi ;;
|
||||
esac
|
||||
|
||||
# drills/ is release evidence and drill/RUNS.md is the harness's own log. The
|
||||
# rewrite is only coherent if both keep existing and the docs say which is
|
||||
# which; a reader who appends to the wrong one gets a red gate and no clue.
|
||||
check "drills/: the directory documents itself" 0 "" test -f "$ROOT/drills/README.md"
|
||||
check "drills/: ...and is plain, not a dot-directory invisible to globs (#116)" 1 "" \
|
||||
test -d "$ROOT/.drills"
|
||||
check "drills/: ...and distinguishes itself from the harness log" 0 "" \
|
||||
grep -qF 'drill/RUNS.md' "$ROOT/drills/README.md"
|
||||
check "drills/: ...and its worked example uses a version that can never ship" 0 "" \
|
||||
grep -qF '9.9.9' "$ROOT/drills/README.md"
|
||||
check "drill/RUNS.md: the harness log is untouched and still present" 0 "" \
|
||||
test -f "$ROOT/drill/RUNS.md"
|
||||
# No real record may be invented to make the suite green. The repo is
|
||||
# 0.8.1-dev; fabricating drills/0.8.1.md would defeat the whole gate.
|
||||
check "drills/: no fabricated record for an unshipped version" 1 "" \
|
||||
test -f "$ROOT/drills/0.8.1.md"
|
||||
|
||||
# The guard is only a guard if CI runs it, and only a rule if the document
|
||||
# that used to carry the rule now points at it.
|
||||
check "ci.yml: runs the drill-recorded guard" 0 "" \
|
||||
grep -qF 'drill-recorded.sh' "$ROOT/.github/workflows/ci.yml"
|
||||
check "CONTRIBUTING: names the guard that enforces the drill" 0 "" \
|
||||
grep -qF 'drill-recorded.sh' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING: documents the drill record's path" 0 "" \
|
||||
grep -qF 'drills/X.Y.Z.md' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING: ...and distinguishes it from the harness's own log" 0 "" \
|
||||
grep -qF "harness's own log" "$ROOT/CONTRIBUTING.md"
|
||||
# The three drills are INDEPENDENT. An earlier draft of this section said the
|
||||
# drill was "ONE orchestrated run over the whole stack", which over-constrains
|
||||
# it into a fixed sequence the repos cannot satisfy. Pinning the corrected
|
||||
# claim, and negatively pinning the fixed-order language, because "surely you
|
||||
# drill rig before box" is exactly the simplification a future editor makes.
|
||||
check "CONTRIBUTING: the three drills are independent, in any order" 0 "" \
|
||||
grep -qF 'The three drills are INDEPENDENT' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING: ...and no fixed-order framing survives" 1 "" \
|
||||
grep -qF 'ONE orchestrated run' "$ROOT/CONTRIBUTING.md"
|
||||
# The load-bearing correction: box and rig are mutually recursive, so a
|
||||
# "release A before you can drill B" rule is not merely bureaucratic, it is
|
||||
# unsatisfiable in principle. What dissolves it is that every drill pins the
|
||||
# same fixed candidate refs — static identifiers that exist as soon as the
|
||||
# release branches do — turning a runtime cycle into two independent tests.
|
||||
check "CONTRIBUTING: ...drilling candidate refs, not released artifacts" 0 "" \
|
||||
grep -qF 'candidate refs, not released artifacts' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING: ...and the same fixed refs are what dissolve the recursion" 0 "" \
|
||||
grep -qF 'pins the same fixed set of candidate refs' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING: ...and each repo asserts a different thing" 0 "" \
|
||||
grep -qF 'isolation contract' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING: ...and a combination defect means patch, re-drill, re-record" 0 "" \
|
||||
grep -qF 're-drill' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING: ...and the RIG_REF gap is stated as outstanding (#81)" 0 "" \
|
||||
grep -qF 'RIG_REF' "$ROOT/CONTRIBUTING.md"
|
||||
check "LABELS: documents blocker:drill-pending" 0 "" \
|
||||
grep -qF 'blocker:drill-pending' "$ROOT/LABELS.md"
|
||||
check "LABELS: ...and points at the per-version record, not the harness log" 0 "" \
|
||||
grep -qF 'drills/X.Y.Z.md' "$ROOT/LABELS.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# changelog-monotonic.sh (#122) — no SHIPPED release heading may be DELETED.
|
||||
#
|
||||
# The complement of the guard above, and the reason it is a separate script:
|
||||
# changelog-armed.sh asks about ONE tree ("does the top section agree with
|
||||
# VERSION?"), which is exactly why it was green on #118's broken branch — the
|
||||
# top section was still '## Unreleased'. "A heading disappeared" is not a
|
||||
# property of a tree at all; it is a property of a DIFF. So these cases are
|
||||
# driven against real, constructed GIT REPOS with a base commit and a branch
|
||||
# commit, not the two-file trees above — a fixture without history cannot
|
||||
# express the failure being guarded.
|
||||
#
|
||||
# The #118 shape is reconstructed verbatim as the first case: the line
|
||||
# '## 0.8.0 — 2026-07-19' replaced by an entry written under '## Unreleased'.
|
||||
# The ceremony's own stamp is driven right beside it, because a rule that
|
||||
# fires on the stamp is unshippable for the same reason rig#44 and cast#108
|
||||
# were — that pair, not the failing case alone, is what makes this a design.
|
||||
# ---------------------------------------------------------------------------
|
||||
MONO="$ROOT/.github/scripts/changelog-monotonic.sh"
|
||||
check "changelog-monotonic: runnable bash" 0 "" bash -n "$MONO"
|
||||
|
||||
# grepo <name> <base-changelog-lines...> — a git repo whose `main` carries the
|
||||
# given changelog, left checked out on a branch `pr` off it. Prints the dir.
|
||||
#
|
||||
# It carries its own VERSION, like tree() above, so the checks that run
|
||||
# changelog-armed.sh against these fixtures read a version that belongs to the
|
||||
# FIXTURE. Reaching for the repo's real VERSION instead makes those checks pass
|
||||
# or fail on what box happens to be versioned at today: bare (the release
|
||||
# ceremony's own tree) sends changelog-armed.sh down its bare branch, where it
|
||||
# demands a section for THAT version in a fixture changelog that has never
|
||||
# heard of it. `-dev` here because every one of these fixtures tops out at
|
||||
# '## Unreleased', which is what a development tree is required to carry.
|
||||
grepo() {
|
||||
local d="$WORK/$1"; shift
|
||||
mkdir -p "$d"
|
||||
git -C "$d" init -q -b main
|
||||
git -C "$d" config user.email test@example.invalid
|
||||
git -C "$d" config user.name test
|
||||
printf '%s\n' '0.8.1-dev' > "$d/VERSION"
|
||||
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
|
||||
git -C "$d" add CHANGELOG.md VERSION
|
||||
git -C "$d" commit -qm base
|
||||
git -C "$d" checkout -q -b pr
|
||||
echo "$d"
|
||||
}
|
||||
# head_changelog <dir> <lines...> — the PR branch's version of the file
|
||||
head_changelog() {
|
||||
local d="$1"; shift
|
||||
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
|
||||
git -C "$d" commit -qam head
|
||||
}
|
||||
mono() { local d="$1"; shift; ( cd "$d" && bash "$MONO" "$@" ); }
|
||||
mono_strict() { local d="$1"; shift; ( cd "$d" && CHANGELOG_MONOTONIC_STRICT=1 bash "$MONO" "$@" ); }
|
||||
|
||||
# --- the #118 incident, reconstructed --------------------------------------
|
||||
G="$(grepo mono-118 '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**')"
|
||||
head_changelog "$G" '## Unreleased' '' '### Fixed' '' '- **An entry**' '' '### Added' '' '- **Shipped prose**'
|
||||
check "monotonic: a DELETED release heading fails (the #118 near-miss)" 1 "DELETES release heading" mono "$G" main
|
||||
check "monotonic: ...and names the heading that vanished" 1 "## 0.8.0" mono "$G" main
|
||||
check "monotonic: ...and the shape of the mistake (replaced, not inserted)" 1 "instead of being" mono "$G" main
|
||||
check "monotonic: ...and why nothing else says so (git merges it cleanly)" 1 "git merges that edit cleanly" mono "$G" main
|
||||
# The whole point of the issue: the OTHER guard is green on this same tree.
|
||||
# Pinned here so a future 'just widen changelog-armed.sh' cannot quietly
|
||||
# delete the reason this script exists.
|
||||
check "monotonic: ...on a tree changelog-armed.sh calls FINE (the #122 gap)" 0 "agrees" \
|
||||
bash "$ARMED" "$G/CHANGELOG.md" "$G/VERSION"
|
||||
|
||||
# --- the #118 incident as it ACTUALLY happened: a DUPLICATED heading -------
|
||||
# The deletion case above is the near-miss. What the bad rebase really produced
|
||||
# was two '## 0.8.0 — 2026-07-19' headings with the incoming entry stranded
|
||||
# between them. Containment cannot see this: 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`, and multiset comparison does not close it for
|
||||
# the same reason. Uniqueness on HEAD is the assert that does.
|
||||
G="$(grepo mono-dup '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**')"
|
||||
head_changelog "$G" '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Fixed' '' '- **An entry**' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**'
|
||||
check "monotonic: a DUPLICATED release heading fails (the #118 shape)" 1 "DUPLICATE release heading" mono "$G" main
|
||||
check "monotonic: ...and names the repeated heading" 1 "## 0.8.0" mono "$G" main
|
||||
check "monotonic: ...and says what a repeat does to release-notes extraction" 1 "re-arms its extraction" mono "$G" main
|
||||
# Containment alone is green on this exact tree — nothing was deleted. Pinned
|
||||
# so a future simplification cannot collapse the two asserts into one.
|
||||
# shellcheck disable=SC2016 # $1/$2 are the inner shell's positionals, not ours
|
||||
check "monotonic: ...on a tree where NOTHING was deleted (containment is blind)" 1 "" \
|
||||
bash -c 'cd "$1" && bash "$2" main 2>&1 | grep -q "DELETES release heading"' _ "$G" "$MONO"
|
||||
# And, as with the deletion case, the other guard calls this tree fine.
|
||||
check "monotonic: ...on a tree changelog-armed.sh calls FINE" 0 "agrees" \
|
||||
bash "$ARMED" "$G/CHANGELOG.md" "$G/VERSION"
|
||||
|
||||
# --- the release ceremony's stamp: an ADD, never a removal -----------------
|
||||
# '## Unreleased' -> '## 0.8.1 — DATE' adds 0.8.1 and removes no X.Y.Z
|
||||
# heading, because 'Unreleased' is not one. A false positive here would make
|
||||
# every release unshippable — the rig#44 / cast#108 failure, one guard over.
|
||||
G="$(grepo mono-stamp '## Unreleased' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
head_changelog "$G" '## Unreleased' '' '## 0.8.1 — 2026-07-20' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**'
|
||||
check "monotonic: the ceremony stamp passes (adds a heading, removes none)" 0 "still present" mono "$G" main
|
||||
|
||||
# --- the ordinary entry, done right ----------------------------------------
|
||||
G="$(grepo mono-ok '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
head_changelog "$G" '## Unreleased' '' '### Fixed' '' '- **An entry**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**'
|
||||
check "monotonic: an entry INSERTED above the top section passes" 0 "still present" mono "$G" main
|
||||
check "monotonic: ...and counts what it actually checked" 0 "all 1 release heading" mono "$G" main
|
||||
|
||||
# --- deletion is caught anywhere in the file, not just at the top ----------
|
||||
G="$(grepo mono-mid '## 0.8.0 — 2026-07-19' '' '- **a**' '' '## 0.7.0 — 2026-07-18' '' '- **b**' '' '## 0.6.0 — 2026-07-17' '' '- **c**')"
|
||||
head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **a**' '' '- **b**' '' '## 0.6.0 — 2026-07-17' '' '- **c**'
|
||||
check "monotonic: a heading deleted MID-FILE is caught too" 1 "## 0.7.0" mono "$G" main
|
||||
# ...and only the deleted one is named, so the message points at the edit.
|
||||
notes_only_070() { ! mono "$1" main 2>&1 | grep -qE '^ ## 0\.(6|8)\.0$'; }
|
||||
check "monotonic: ...naming only the heading that went missing" 0 "" notes_only_070 "$G"
|
||||
|
||||
# --- a rewritten Unreleased is NOT a violation (changelog-armed owns it) ---
|
||||
G="$(grepo mono-unrel '## Unreleased' '' '- **Pending**' '' '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **Shipped**'
|
||||
check "monotonic: a deleted '## Unreleased' is NOT this guard's business" 0 "still present" mono "$G" main
|
||||
|
||||
# --- a changelog that did not exist at the base ----------------------------
|
||||
G="$(grepo mono-new 'No sections yet.')"
|
||||
head_changelog "$G" '## 0.1.0 — 2026-07-20' '' '- **First**'
|
||||
check "monotonic: a base with no release headings passes (nothing to delete)" 0 "all 0 release heading" mono "$G" main
|
||||
|
||||
# --- degradation: no base to compare against -------------------------------
|
||||
# A local run may genuinely have no base ref. That must SKIP loudly, not fail
|
||||
# spuriously (which would make the script un-runnable off CI) and not pass
|
||||
# silently (which is the failure shape this repo keeps refusing). CI closes
|
||||
# the hole from the other side with STRICT.
|
||||
G="$(grepo mono-nobase '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "monotonic: an unresolvable base ref SKIPS containment" 0 "containment SKIPPED" mono "$G" no-such-ref
|
||||
check "monotonic: ...and says uniqueness already ran, not that nothing did" 0 "already ran" mono "$G" no-such-ref
|
||||
check "monotonic: ...naming the base ref it could not resolve" 0 "no-such-ref" mono "$G" no-such-ref
|
||||
check "monotonic: ...and warning that CI treats it as a failure" 0 "hard failure" mono "$G" no-such-ref
|
||||
check "monotonic: STRICT turns that skip into a red run" 1 "is a FAILURE, not a skip" mono_strict "$G" no-such-ref
|
||||
check "monotonic: ...and points at the checkout, not the script" 1 "fetch-depth: 0" mono_strict "$G" no-such-ref
|
||||
# Outside a work tree at all (a tarball, an unpacked release).
|
||||
mkdir -p "$WORK/mono-nogit"
|
||||
printf '%s\n' '# Changelog' '' '## 0.8.0 — 2026-07-19' > "$WORK/mono-nogit/CHANGELOG.md"
|
||||
check "monotonic: outside a git work tree it skips containment, not everything" 0 "containment SKIPPED" \
|
||||
mono "$WORK/mono-nogit" main
|
||||
check "monotonic: a missing changelog refuses by path (never a skip)" 1 "no such file" \
|
||||
bash "$MONO" main "$WORK/nope.md"
|
||||
|
||||
# --- #143: 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. Before #143 the duplicate check sat downstream of the base-ref, merge-base
|
||||
# and base-blob conditions, so each of the three degradation paths below exited
|
||||
# 0 on a tree with a duplicate in plain sight — the base-blob one not even via
|
||||
# skip(), but a bare `exit 0` that STRICT could not reach. These cases pin the
|
||||
# ORDER, which is the actual invariant; asserting the exit code alone is what
|
||||
# let the original ship (the base-absent case below was green before and after).
|
||||
grepo_nocl() { # a repo whose main has NO changelog at all
|
||||
local d="$WORK/$1"
|
||||
mkdir -p "$d"
|
||||
git -C "$d" init -q -b main
|
||||
git -C "$d" config user.email test@example.invalid
|
||||
git -C "$d" config user.name test
|
||||
echo seed > "$d/README.md"
|
||||
git -C "$d" add README.md
|
||||
git -C "$d" commit -qm base
|
||||
git -C "$d" checkout -q -b pr
|
||||
echo "$d"
|
||||
}
|
||||
add_changelog() { # <dir> <lines...> — the PR introduces the file
|
||||
local d="$1"; shift
|
||||
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
|
||||
git -C "$d" add CHANGELOG.md
|
||||
git -C "$d" commit -qm head
|
||||
}
|
||||
|
||||
# The changelog is absent at the merge base AND the PR introduces a duplicate.
|
||||
G="$(grepo_nocl mono-143-newdup)"
|
||||
add_changelog "$G" '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '- **a**' '' '## 0.8.0 — 2026-07-19' '' '- **stranded**'
|
||||
check "monotonic: a duplicate introduced where the base had no changelog is CAUGHT (#143)" 1 "DUPLICATE release heading" mono "$G" main
|
||||
check "monotonic: ...and STRICT does not change that (it was never a skip)" 1 "DUPLICATE release heading" mono_strict "$G" main
|
||||
# ...and the clean counterpart still exits 0, now saying uniqueness did run.
|
||||
G="$(grepo_nocl mono-143-newok)"
|
||||
add_changelog "$G" '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '- **a**'
|
||||
check "monotonic: ...while a CLEAN introduced changelog still passes" 0 "nothing could have been deleted" mono "$G" main
|
||||
check "monotonic: ...saying uniqueness was checked, not that nothing was" 0 "uniqueness on HEAD already passed" mono "$G" main
|
||||
|
||||
# No git at all: uniqueness still has everything it needs.
|
||||
mkdir -p "$WORK/mono-143-nogit"
|
||||
printf '%s\n' '# Changelog' '' '## 0.8.0 — 2026-07-19' '' '## 0.8.0 — 2026-07-19' > "$WORK/mono-143-nogit/CHANGELOG.md"
|
||||
check "monotonic: a duplicate OUTSIDE a git work tree is caught (#143)" 1 "DUPLICATE release heading" \
|
||||
mono "$WORK/mono-143-nogit" main
|
||||
|
||||
# Unresolvable base ref: same — the skip is containment's, not the script's.
|
||||
G="$(grepo mono-143-nobase '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **a**' '' '## 0.8.0 — 2026-07-19' '' '- **b**'
|
||||
check "monotonic: a duplicate is caught even when the base ref will not resolve (#143)" 1 "DUPLICATE release heading" mono "$G" no-such-ref
|
||||
|
||||
# --- the push-to-main shape: containment vacuous, uniqueness real ----------
|
||||
# With the pull_request gate gone (#143), merge_base == HEAD is a ROUTINE path,
|
||||
# not a degradation. Containment compares the file against itself and asserts
|
||||
# nothing, so a line reading "all N still present" would claim a check that did
|
||||
# no work — the same dishonesty the skip messages were fixed for. The success
|
||||
# line therefore has two forms, and this pins which one each event gets.
|
||||
G="$(grepo mono-vacuous '## 0.8.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "monotonic: HEAD as its own base reports containment VACUOUS, not verified" 0 "containment vacuous" mono "$G" HEAD
|
||||
check "monotonic: ...and names uniqueness as the half that actually ran" 0 "uniqueness on HEAD checked" mono "$G" HEAD
|
||||
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
|
||||
check "monotonic: ...and does NOT claim headings were still present" 1 "" \
|
||||
bash -c 'cd "$1" && bash "$2" HEAD | grep -q "are still present"' _ "$G" "$MONO"
|
||||
# The PR shape keeps the containment wording — the two must not collapse.
|
||||
head_changelog "$G" '## 0.8.0 — 2026-07-19' '' '- **Shipped**' '' '## 0.9.0 — 2026-07-20' '' '- **New**'
|
||||
check "monotonic: a real base still reports containment, naming the count" 0 "still present" mono "$G" main
|
||||
|
||||
# --- and the real tree, through the real script ----------------------------
|
||||
# HEAD as its own base: the merge base is HEAD, so the sets are identical by
|
||||
# construction. Proves the script runs against the actual CHANGELOG.md and
|
||||
# parses its real headings, without depending on an `origin/main` that a
|
||||
# fresh clone or a detached CI checkout may not have.
|
||||
# HEAD as its own base is now the VACUOUS-containment path (#143), so the
|
||||
# assertion moved to uniqueness's count — which is the stronger proof of the
|
||||
# original intent anyway: it says the parser read the REAL CHANGELOG.md and
|
||||
# found real headings in it, rather than that a self-comparison came out equal.
|
||||
check "monotonic: THIS tree passes against itself (the parser meets reality)" 0 "uniqueness on HEAD checked" \
|
||||
mono "$ROOT" HEAD
|
||||
|
||||
# The guard is only a guard if CI runs it — and only if CI runs it with the
|
||||
# history it needs. Fail-closed pins on all three, since a depth-1 checkout
|
||||
# would silently downgrade every run to the SKIP path.
|
||||
check "ci.yml: runs the changelog-monotonic guard" 0 "" \
|
||||
grep -qF 'changelog-monotonic.sh' "$ROOT/.github/workflows/ci.yml"
|
||||
check "ci.yml: ...with full history, or the merge base is unreachable" 0 "" \
|
||||
grep -qF 'fetch-depth: 0' "$ROOT/.github/workflows/ci.yml"
|
||||
check "ci.yml: ...and STRICT, so a skip is a red run and not a green one" 0 "" \
|
||||
grep -qF 'CHANGELOG_MONOTONIC_STRICT' "$ROOT/.github/workflows/ci.yml"
|
||||
# #143: the step must NOT be pull-request-only — duplication is vacuous on no
|
||||
# tree — and dropping that gate is only safe with the base-ref fallback, since
|
||||
# `github.base_ref` is empty on a push and a bare `origin/` under STRICT is a
|
||||
# hard failure on every push to main.
|
||||
# Scoped to the step's OWN block, deliberately. A file-wide negative would
|
||||
# forbid any FUTURE step in ci.yml from being pull_request-gated and would fail
|
||||
# citing #143 when one legitimately is — #143 constrains this step, not the file.
|
||||
# Terminates on a new STEP or a new JOB. The job boundary is not optional: the
|
||||
# monotonic step is the LAST step of `check`, so stopping only at the next
|
||||
# `- name:` runs the block into the `rehearsal` job below and swallows its
|
||||
# job-level `if:`. That reintroduces the very bug the scoping fixed — an
|
||||
# unrelated edit failing while citing #143 — just moved from "any step in the
|
||||
# file" to "this step plus the head of the next job".
|
||||
mono_step_block() {
|
||||
awk '/^ - name: no shipped changelog heading/ {f=1; print; next}
|
||||
f && (/^ - / || /^ [^ ]/) {exit}
|
||||
f {print}' "$ROOT/.github/workflows/ci.yml"
|
||||
}
|
||||
# Anchored: an `if:` appearing 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 (#143)" 1 "" mono_step_gated
|
||||
check "ci.yml: ...and the block was actually found (guards the awk above)" 0 "changelog-monotonic" mono_step_block
|
||||
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' "$ROOT/.github/workflows/ci.yml"
|
||||
check "CONTRIBUTING: names the append-only rule for release headings" 0 "" \
|
||||
grep -qF 'changelog-monotonic.sh' "$ROOT/CONTRIBUTING.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# latest_release_tag — extracted from install.sh (the source-the-pure-function
|
||||
# trick) and driven against a shim curl. The shim serves the ONE seam the
|
||||
# function uses: -w '%{redirect_url}' on the releases/latest probe.
|
||||
# ---------------------------------------------------------------------------
|
||||
SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR"
|
||||
cat > "$SHIMDIR/curl" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
# Fake curl for the release drills: answers the releases/latest probe with
|
||||
# $FAKE_REDIRECT on stdout (the -w '%{redirect_url}' seam) — or fails with
|
||||
# $FAKE_CURL_RC (network down) — and serves downloads (-o <file>) by copying
|
||||
# $FAKE_TARBALL when the URL is $FAKE_SERVE_URL, else exit 22 (curl's own
|
||||
# 404-under--f code). Every URL is appended to $FAKE_CURL_LOG so a test can
|
||||
# assert exactly what was asked for, and in what order.
|
||||
url="" out=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
|
@ -787,105 +51,77 @@ chmod +x "$SHIMDIR/curl"
|
|||
|
||||
TAGFN="$(mktemp)"
|
||||
awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN"
|
||||
check "latest_release_tag: extracted from install.sh (guards the awk)" 0 "releases/latest" cat "$TAGFN"
|
||||
check "latest_release_tag: the extracted function is valid bash" 0 "" bash -n "$TAGFN"
|
||||
check "latest_release_tag: extracted from install.sh" 0 "releases/latest" cat "$TAGFN"
|
||||
check "latest_release_tag: extracted function is valid bash" 0 "" bash -n "$TAGFN"
|
||||
|
||||
ltag() { # ltag <redirect_url> [curl_rc]
|
||||
ltag() {
|
||||
FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \
|
||||
PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag"
|
||||
}
|
||||
check "resolve: reads the tag off the redirect" 0 "0.6.0" \
|
||||
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0"
|
||||
check "resolve: a -dev-style tag survives verbatim" 0 "0.7.0-rc1" \
|
||||
check "resolve: a pre-release tag survives verbatim" 0 "0.7.0-rc1" \
|
||||
ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1"
|
||||
check "resolve: a repo with NO releases (redirect to /releases) fails" 1 "" \
|
||||
check "resolve: a repo with no releases fails" 1 "" \
|
||||
ltag "https://github.com/heavy-duty/box/releases"
|
||||
check "resolve: no redirect at all fails" 1 "" ltag ""
|
||||
check "resolve: a curl failure (network down) fails, never hangs on prose" 1 "" \
|
||||
check "resolve: no redirect fails" 1 "" ltag ""
|
||||
check "resolve: a curl failure fails" 1 "" \
|
||||
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6
|
||||
rm -f "$TAGFN"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The three channels, driven through REAL install.sh runs (#83): default =
|
||||
# latest release, BOX_REF=<tag> = pinned, BOX_REF=<branch> = dev. The shim
|
||||
# curl serves a fabricated release tarball shaped exactly like GitHub's (one
|
||||
# top-level directory), and its log proves WHICH URLs the installer asked
|
||||
# for. FAKE_TARBALL carries VERSION 9.9.9 so nothing collides with the tree
|
||||
# under test.
|
||||
# ---------------------------------------------------------------------------
|
||||
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
||||
SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin"
|
||||
cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box"
|
||||
echo "9.9.9" > "$SRC/VERSION"
|
||||
printf '9.9.9\n' > "$SRC/VERSION"
|
||||
tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9
|
||||
|
||||
ninst() { # ninst <box_home> <box_bin> [VAR=val ...] — install.sh, shim network
|
||||
ninst() {
|
||||
local h="$1" b="$2"; shift 2
|
||||
env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \
|
||||
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
|
||||
FAKE_TARBALL="$WORK/gh.tar.gz" "$@" bash "$ROOT/install.sh"
|
||||
}
|
||||
|
||||
# --- channel 1: the default is the latest RELEASE ---------------------------
|
||||
H1="$WORK/h1"; B1="$WORK/b1"; L1="$WORK/c1.log"
|
||||
check "default channel: resolves and installs the latest release" 0 "latest release: 9.9.9" \
|
||||
check "default channel: installs the latest release" 0 "latest release: 9.9.9" \
|
||||
ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \
|
||||
FAKE_REDIRECT="https://github.com/heavy-duty/box/releases/tag/9.9.9" \
|
||||
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
|
||||
check "default channel: the download is the TAG tarball" 0 "" \
|
||||
check "default channel: downloads the tag tarball" 0 "" \
|
||||
grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1"
|
||||
check "default channel: it never asked for a branch" 1 "" \
|
||||
grep -q "refs/heads" "$L1"
|
||||
check "default channel: INSTALLED_FROM records the RESOLVED tag" 0 "heavy-duty/box@9.9.9" \
|
||||
check "default channel: never asks for a branch" 1 "" grep -q "refs/heads" "$L1"
|
||||
check "default channel: records the resolved tag" 0 "heavy-duty/box@9.9.9" \
|
||||
cat "$H1/versions/9.9.9/INSTALLED_FROM"
|
||||
check "default channel: the install answers through the chain" 0 "box 9.9.9" \
|
||||
check "default channel: installed binary answers" 0 "box 9.9.9" \
|
||||
env HOME="$FAKEHOME" "$B1/box" --version
|
||||
|
||||
# --- channel 2: BOX_REF=<tag> pins a release --------------------------------
|
||||
H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log"
|
||||
check "pinned channel: BOX_REF=<tag> installs that tag" 0 "done" \
|
||||
check "pinned channel: installs the requested tag" 0 "done" \
|
||||
ninst "$H2" "$B2" BOX_REF=9.9.9 FAKE_CURL_LOG="$L2" \
|
||||
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
|
||||
check "pinned channel: no releases/latest probe (a pin resolves nothing)" 1 "" \
|
||||
check "pinned channel: skips latest-release resolution" 1 "" \
|
||||
grep -q "releases/latest" "$L2"
|
||||
|
||||
# --- channel 3: BOX_REF=<branch> is the dev channel -------------------------
|
||||
H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log"
|
||||
check "dev channel: BOX_REF=main falls back tag -> branch" 0 "trying it as a branch" \
|
||||
check "dev channel: falls back from tag to branch" 0 "trying it as a branch" \
|
||||
ninst "$H3" "$B3" BOX_REF=main FAKE_CURL_LOG="$L3" \
|
||||
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/heads/main.tar.gz"
|
||||
check "dev channel: the tag was tried FIRST" 0 "refs/tags/main.tar.gz" \
|
||||
head -1 "$L3"
|
||||
check "dev channel: then the branch" 0 "" \
|
||||
check "dev channel: tries the tag first" 0 "refs/tags/main.tar.gz" head -1 "$L3"
|
||||
check "dev channel: then downloads the branch" 0 "" \
|
||||
grep -qF "archive/refs/heads/main.tar.gz" "$L3"
|
||||
|
||||
# --- the failure is LOUD, never a silent fall-through to main ---------------
|
||||
H4="$WORK/h4"; B4="$WORK/b4"; L4="$WORK/c4.log"
|
||||
check "resolution failure: REFUSES, naming the probe URL" 1 "could not resolve the latest release" \
|
||||
check "resolution failure: names the latest-release probe" 1 "could not resolve the latest release" \
|
||||
ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4"
|
||||
check "resolution failure: ...and the way out (BOX_REF)" 1 "BOX_REF" \
|
||||
check "resolution failure: names BOX_REF as the override" 1 "BOX_REF" \
|
||||
ninst "$H4" "$B4" FAKE_CURL_RC=6
|
||||
check "resolution failure: downloaded NOTHING (no silent main)" 1 "" \
|
||||
grep -q "archive/" "$L4"
|
||||
check "resolution failure: nothing was installed" 1 "" test -e "$H4/versions"
|
||||
check "a ref that is neither tag nor branch dies naming both" 1 "neither a tag nor a branch" \
|
||||
check "resolution failure: downloads nothing" 1 "" grep -q "archive/" "$L4"
|
||||
check "resolution failure: installs nothing" 1 "" test -e "$H4/versions"
|
||||
check "unknown ref names both attempted channels" 1 "neither a tag nor a branch" \
|
||||
ninst "$H4" "$B4" BOX_REF=no-such-ref
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The -dev convention (#83): main's VERSION carries -dev between releases, so
|
||||
# a dev install lands beside releases in versions/ instead of impersonating
|
||||
# one — and the docs keep the promises this PR makes.
|
||||
# ---------------------------------------------------------------------------
|
||||
check "CONTRIBUTING documents the post-release -dev bump" 0 "" \
|
||||
grep -q -- '-dev' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING documents the release ritual (tag == VERSION)" 0 "" \
|
||||
grep -qi 'release' "$ROOT/CONTRIBUTING.md"
|
||||
check "README documents the default (latest release) channel" 0 "" \
|
||||
grep -qF 'latest release' "$ROOT/README.md"
|
||||
check "README documents the pinned channel" 0 "" \
|
||||
grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
|
||||
check "README documents the dev channel" 0 "" \
|
||||
grep -qF 'BOX_REF=main' "$ROOT/README.md"
|
||||
check "README documents the latest-release channel" 0 "" grep -qF 'latest release' "$ROOT/README.md"
|
||||
check "README documents the pinned channel" 0 "" grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
|
||||
check "README documents the dev channel" 0 "" grep -qF 'BOX_REF=main' "$ROOT/README.md"
|
||||
|
||||
echo "---"
|
||||
echo "$PASS passed, $FAIL failed"
|
||||
|
|
|
|||
Loading…
Reference in a new issue