Compare commits
No commits in common. "main" and "0.2.0" have entirely different histories.
25 changed files with 3101 additions and 658 deletions
|
|
@ -1,49 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# .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).
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
# 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.
|
|
||||||
39
.github/actions/release-artifact/action.yml
vendored
39
.github/actions/release-artifact/action.yml
vendored
|
|
@ -1,39 +0,0 @@
|
||||||
name: release-artifact
|
|
||||||
description: >-
|
|
||||||
Build cast's prebuilt release asset — the ceremony's artifact hook
|
|
||||||
(ceremony#9's contract; ceremony#15 is this conversion). Where cast
|
|
||||||
differs from its siblings: box and rig are pure bash, so GitHub's source
|
|
||||||
tarball for the tag IS their package; cast's source tarball is not
|
|
||||||
runnable — it needs npm ci and tsc first. So the build happens ONCE,
|
|
||||||
here, and the asset is the runnable tree — bin/, dist/, production
|
|
||||||
node_modules/, package.json — staged as cast-<version>/ inside
|
|
||||||
cast-<version>.tgz. That name and layout are the install contract: the
|
|
||||||
installer's release channels download this exact asset and never run npm
|
|
||||||
or tsc (test/install-sh.test.ts pins it). The hook owns its own
|
|
||||||
toolchain (the shared workflow is node-free).
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: The release version the asset is named for
|
|
||||||
required: true
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22"
|
|
||||||
cache: npm
|
|
||||||
- name: build once, stage the runnable tree, drop the tgz
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
VERSION: ${{ inputs.version }}
|
|
||||||
run: |
|
|
||||||
# Deliberately no tests/checks here: ci.yml already gated the merge
|
|
||||||
# commit this release names, and cast's suite needs `age`, which
|
|
||||||
# the release runner does not install. The staged tree is exactly
|
|
||||||
# what an install needs to run.
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
npm prune --omit=dev
|
|
||||||
mkdir -p "$RUNNER_TEMP/stage/cast-$VERSION"
|
|
||||||
cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$VERSION/"
|
|
||||||
tar -C "$RUNNER_TEMP/stage" -czf "$RELEASE_ASSETS_DIR/cast-$VERSION.tgz" "cast-$VERSION"
|
|
||||||
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
# path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's
|
# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope
|
||||||
# scope story (issues are hand-scoped at triage; paths only exist on PRs). Additive
|
# 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.
|
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
|
||||||
"scope:capture":
|
"scope:capture":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
|
|
|
||||||
7
.github/labels.conf
vendored
7
.github/labels.conf
vendored
|
|
@ -1,7 +0,0 @@
|
||||||
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
|
|
||||||
scope:capture|C5DEF5|draft/capture — reading the live world into a manifest
|
|
||||||
scope:apply|C5DEF5|apply/diff/destroy — reconciling onto Coolify
|
|
||||||
scope:secrets|C5DEF5|secrets, age, the encrypted state repo
|
|
||||||
scope:fleet|C5DEF5|fleet/inventory/server — placement
|
|
||||||
scope:manifest|C5DEF5|manifest/resolve/envtemplate — the manifest language
|
|
||||||
scope:coolify-api|C5DEF5|coolify.ts + OpenAPI reference — the client
|
|
||||||
250
.github/scripts/changelog-monotonic.sh
vendored
Executable file
250
.github/scripts/changelog-monotonic.sh
vendored
Executable file
|
|
@ -0,0 +1,250 @@
|
||||||
|
#!/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.
|
||||||
|
#
|
||||||
|
# Ported from box (heavy-duty/box#122, caught in review of box#118) for #133,
|
||||||
|
# because cast's release-notes.sh carries the exact awk shape that made box#118
|
||||||
|
# dangerous. The failure it exists to catch leaves no trace either. An author
|
||||||
|
# adding an entry under '## Unreleased' REPLACES the line below it instead of
|
||||||
|
# inserting above it:
|
||||||
|
#
|
||||||
|
# -## 0.1.1 — 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.1.1 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.1.1's prose as if it were new.
|
||||||
|
#
|
||||||
|
# The ARMING rule (test/release.test.ts, "the changelog is armed for the next
|
||||||
|
# entry (rig#66)") is green on exactly that tree, correctly: it asks only
|
||||||
|
# whether the TOP section agrees with package.json's version, and deleting
|
||||||
|
# '## 0.1.1' 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 (#111) adds one and never removes one; nothing else in the
|
||||||
|
# documented flow (CONTRIBUTING.md, "Releasing") touches them. So SUPERSET is
|
||||||
|
# exact — it has no legitimate violation to carve an exception for. The stamp
|
||||||
|
# is covered for free: rewriting '## Unreleased' -> '## X.Y.Z — DATE' ADDS
|
||||||
|
# X.Y.Z and removes no X.Y.Z heading, because 'Unreleased' is not one.
|
||||||
|
# '## Unreleased' is deliberately NOT in the set this guards — the arming rule
|
||||||
|
# owns that heading, keyed on package.json's version, and the ceremony
|
||||||
|
# legitimately consumes it.
|
||||||
|
#
|
||||||
|
# A file of its own, NOT a clause inside the arming assertions, for three
|
||||||
|
# reasons. Its input is different (a git history, not two files). Its
|
||||||
|
# degradation is different (no base ref is a SKIP, not a failure). And the
|
||||||
|
# arming assertions run against constructed in-memory changelog strings that
|
||||||
|
# are not git repos at all — folding a git-dependent assert into them would
|
||||||
|
# make every one of those cases either skip or lie. Same discipline as
|
||||||
|
# release-notes.sh: its own file so test/release.test.ts 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 the arming rule 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 box#118 class) ----------------------------------
|
||||||
|
# Containment catches a DELETED heading. It cannot catch a DUPLICATED one: the
|
||||||
|
# duplicate is head-side SURPLUS, and `comm -23` (base minus head) is blind to
|
||||||
|
# extras on the head side — with or without `sort -u`, base {0.1.1} minus head
|
||||||
|
# {0.1.1, 0.1.1} 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.
|
||||||
|
#
|
||||||
|
# cast is the MORE exposed of the two repos here (#133). release-notes.sh
|
||||||
|
# extracts with:
|
||||||
|
#
|
||||||
|
# /^## / { grab = ($2 == ver); next }
|
||||||
|
# grab { print }
|
||||||
|
#
|
||||||
|
# There is no `exit`. `grab` re-arms on every matching '## ' line, so two
|
||||||
|
# '## 0.1.1' headings make the published body ABSORB whatever sits between the
|
||||||
|
# copies — and an entry stranded there is dropped from the NEXT release's notes
|
||||||
|
# as well. (rig's extractor has `if (found) exit`, so it truncates instead of
|
||||||
|
# absorbing — same class, milder symptom. cast has the absorbing one.)
|
||||||
|
#
|
||||||
|
# This is the shape box#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 — the arming rule happy (the top section was still
|
||||||
|
# right), tests and `bash -n` clean — while release-notes.sh re-armed its grab
|
||||||
|
# on the second heading and folded post-cut prose into the shipped release
|
||||||
|
# body. Note the arming rule's "double re-arm" case counts duplicate
|
||||||
|
# '## Unreleased' headings only; duplicate VERSION headings, the ones that
|
||||||
|
# reach release-notes.sh, are this script's.
|
||||||
|
#
|
||||||
|
# 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 box#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 (#133, box#143). It asks nothing of the history, and gating it
|
||||||
|
# behind these conditions let a duplicate exit 0 on a message about deletion.
|
||||||
|
#
|
||||||
|
# That ordering mattered MORE here than anywhere. cast's release-notes.sh has
|
||||||
|
# no `exit`, so `grab` re-arms on every matching '## ' line and a duplicate
|
||||||
|
# makes the published body ABSORB whatever sits between the copies — the live
|
||||||
|
# extraction bug this guard exists for. The half with that bug behind it was
|
||||||
|
# the half with the most ways to silently not run.
|
||||||
|
|
||||||
|
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 (#111); nothing ever
|
||||||
|
legitimately removes one. So this is not a judgement call — it is a defect,
|
||||||
|
and almost always the same one (#133, box#122): 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 the arming rule 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 (#133) —
|
||||||
|
# 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
|
||||||
152
.github/scripts/drill-recorded.sh
vendored
Executable file
152
.github/scripts/drill-recorded.sh
vendored
Executable file
|
|
@ -0,0 +1,152 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# drill-recorded.sh [<drills-dir>] [<version-file>] — assert that the version
|
||||||
|
# this tree claims to ship has a DRILL RECORD at <drills-dir>/<version>.md.
|
||||||
|
#
|
||||||
|
# CONTRIBUTING says a release carries the full real-hardware drill. Nothing
|
||||||
|
# checked that, so no release in this family ever carried one: the step lived
|
||||||
|
# only in a reviewer's memory, and a step that lives in a reviewer's memory is
|
||||||
|
# performed exactly as often as the reviewer remembers it. A bot finally
|
||||||
|
# blocked on it, which is the first time the omission was visible at all. So
|
||||||
|
# the gate moves into CI, where it is asserted on every release PR rather than
|
||||||
|
# recalled.
|
||||||
|
#
|
||||||
|
# ONE FILE PER VERSION — WHY THE PARSER IS GONE
|
||||||
|
#
|
||||||
|
# The first cut kept every record in one drill/RUNS.md and asked awk which
|
||||||
|
# section belonged to this version. That bought a heading grammar: em-dash
|
||||||
|
# field matching, an optional ' — DATE' tail, a whole-version comparison so
|
||||||
|
# 0.2.0-rc1 could not satisfy 0.2.0, a '(NF == 5 || $6 == dash)' tail
|
||||||
|
# constraint to stay in step with box's twin, and a non-blank body rule.
|
||||||
|
#
|
||||||
|
# All of it existed ONLY because records shared a file — and in review this
|
||||||
|
# repo shipped two defects out of that complexity: a `sed '/./,$!d'`
|
||||||
|
# extraction where `.` matches a space, so a heading followed by one tab
|
||||||
|
# satisfied the gate; and heading-grammar drift from box's stricter form.
|
||||||
|
# Two defects, on the one check whose entire job is to demand evidence.
|
||||||
|
#
|
||||||
|
# One file per version makes nearly all of it UNREPRESENTABLE. `0.2.0.md` and
|
||||||
|
# `0.2.0-rc1.md` are simply different files — the whole-version rule is the
|
||||||
|
# filesystem's, not a comparison anyone can get wrong. There is no heading to
|
||||||
|
# parse, so there is no grammar to drift from box's. What is left is a
|
||||||
|
# question a shell can ask directly: does the file exist, and does it say
|
||||||
|
# anything.
|
||||||
|
#
|
||||||
|
# The directory is plain `drills/`, NOT `.drills/`. Dot-prefixed directories
|
||||||
|
# are invisible to globs without `dotglob`, which is the exact blind spot that
|
||||||
|
# produced #118, #121 here and box#116 — a sweep that looks green because it
|
||||||
|
# never descended into the directory holding the thing it was meant to check.
|
||||||
|
#
|
||||||
|
# WHAT IT ASSERTS, AND WHAT IT DELIBERATELY DOES NOT
|
||||||
|
#
|
||||||
|
# It asserts a RECORD EXISTS — not that the drill passed. That is the whole
|
||||||
|
# design. A maintainer may ship on a failed or partial drill; what they may not
|
||||||
|
# do is ship on silence. Requiring a record makes a waiver a deliberate,
|
||||||
|
# reviewable commit (a file saying who waived it and what is untested) instead
|
||||||
|
# of the default outcome of forgetting. A guard that demanded a PASS would be
|
||||||
|
# argued with and eventually bypassed; one that demands EVIDENCE has nothing to
|
||||||
|
# argue about.
|
||||||
|
#
|
||||||
|
# PER-REPO, ON PURPOSE
|
||||||
|
#
|
||||||
|
# This reads cast's OWN drills/. It does not reach into box or rig to ask
|
||||||
|
# whether the family drilled. A cross-repo lookup has a failure mode this repo
|
||||||
|
# keeps refusing: when the fetch fails — no network, moved file, renamed repo,
|
||||||
|
# a token without read on the other repo — the honest answers are "unknown" and
|
||||||
|
# "blocked", but the shape such code actually takes degrades to "pass". Same
|
||||||
|
# class as the unreadable check rollup that read as "nothing is failing".
|
||||||
|
#
|
||||||
|
# There is also nothing to look up. The three repos' drills are INDEPENDENT
|
||||||
|
# (CONTRIBUTING.md, "Releasing") — run in any order, on any schedule, in
|
||||||
|
# separate sittings. What makes that safe is that every drill pins the SAME
|
||||||
|
# FIXED SET OF CANDIDATE REFS (RIG_REPO/RIG_REF at mint time), so each one
|
||||||
|
# exercises the combination that will ship rather than whatever main happens
|
||||||
|
# to be that afternoon.
|
||||||
|
#
|
||||||
|
# That pinning, not sequencing, is what dissolves the box<->rig recursion. box
|
||||||
|
# and rig ARE mutually recursive — rig builds the host that runs box, box's
|
||||||
|
# seed calls rig back to converge the guest — but candidate refs are static
|
||||||
|
# identifiers that exist as soon as the release branches do, long before any
|
||||||
|
# drill runs. A cycle at runtime becomes independent tests against one fixed
|
||||||
|
# pair, and no repo must ship before another can be drilled. The three
|
||||||
|
# releases are NOT published in a fixed sequence.
|
||||||
|
#
|
||||||
|
# Each repo also drills a DIFFERENT thing: box asserts the isolation contract,
|
||||||
|
# rig asserts convergence, cast asserts promotion. Three different exercises
|
||||||
|
# over a shared substrate — which is exactly why the records are per-repo.
|
||||||
|
# Each cites the shared run ID naming the pinned set, plus the other repos'
|
||||||
|
# SHAs, so three records still reassemble into one picture without any repo
|
||||||
|
# reading another's file.
|
||||||
|
#
|
||||||
|
# A file of its own, not a clause inlined in ci.yml, for the same reason as
|
||||||
|
# release-notes.sh and changelog-monotonic.sh: test/release.test.ts drives the
|
||||||
|
# REAL script against fixtures, so what the tests prove is what CI runs.
|
||||||
|
|
||||||
|
drills="${1:-drills}"
|
||||||
|
version_file="${2:-package.json}"
|
||||||
|
|
||||||
|
[ "$#" -le 2 ] || { echo "usage: drill-recorded.sh [<drills-dir>] [<version-file>]" >&2; exit 2; }
|
||||||
|
[ -f "$version_file" ] || { echo "drill-recorded: no such file: $version_file" >&2; exit 1; }
|
||||||
|
|
||||||
|
# cast's version lives in package.json (there is no VERSION file), so this
|
||||||
|
# reads JSON — with sed, not node. release-notes.sh takes the version as an
|
||||||
|
# ARGUMENT and so never had to; this one is invoked by CI with no arguments and
|
||||||
|
# has to find it itself. sed keeps the script runnable by `bash -n`, shellcheck
|
||||||
|
# and a bare shell alike, with no dependency on a toolchain being installed
|
||||||
|
# before the guard can speak. The first "version" key in package.json is the
|
||||||
|
# package's own by npm's schema; dependency entries are "<name>": "<range>"
|
||||||
|
# pairs and carry no "version" key to be confused with it.
|
||||||
|
ver="$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$version_file" | head -1)"
|
||||||
|
[ -n "$ver" ] || { echo "drill-recorded: no \"version\" key in $version_file" >&2; exit 1; }
|
||||||
|
|
||||||
|
# A -dev tree is main between releases. Nothing ships from it, so there is no
|
||||||
|
# claim to evidence — and demanding a record here would make every ordinary
|
||||||
|
# feature PR red until somebody drilled for a version that will never be cut.
|
||||||
|
# The gate is about the SHIP CLAIM, and `-dev` is the absence of one.
|
||||||
|
case "$ver" in
|
||||||
|
*-dev)
|
||||||
|
echo "drill-recorded: version $ver is a development tree — nothing ships from it, so there is nothing to assert."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# A bare version is a release ceremony tree: this is the tree whose merge IS
|
||||||
|
# the release, so this is where the evidence has to exist.
|
||||||
|
#
|
||||||
|
# The record is <drills-dir>/<version>.md, and it must contain at least one
|
||||||
|
# NON-WHITESPACE character. That second clause is the one surviving piece of
|
||||||
|
# the whitespace defect found in review (#138): a file of only spaces, tabs
|
||||||
|
# and newlines is a file, and `[ -f ]` is happy with it, but it is not a
|
||||||
|
# record — an evidence-free release for the price of an invisible character.
|
||||||
|
# `grep -q '[^[:space:]]'` asks the question the old `sed '/./,$!d'` only
|
||||||
|
# claimed to: `.` matches a space, a POSIX class does not.
|
||||||
|
record="$drills/$ver.md"
|
||||||
|
|
||||||
|
if [ ! -f "$record" ] || ! grep -q '[^[:space:]]' "$record"; then
|
||||||
|
{
|
||||||
|
echo "drill-recorded: version $ver is a release, but there is no drill record at $record."
|
||||||
|
echo
|
||||||
|
echo " A release PR's version must have a NON-EMPTY file named for it:"
|
||||||
|
echo
|
||||||
|
echo " $drills/$ver.md"
|
||||||
|
echo
|
||||||
|
echo " (A file that exists but holds only whitespace counts as no record."
|
||||||
|
echo " One file per version, so '$ver-rc1.md' is a different record and"
|
||||||
|
echo " does not satisfy '$ver', or the other way round.)"
|
||||||
|
echo
|
||||||
|
echo " To unblock, either:"
|
||||||
|
echo " * run the drill and record it — the legs (team, apply, idempotent"
|
||||||
|
echo " diff, smoke, inventory, emit-draft, fleet, destroy, read-only"
|
||||||
|
echo " guard), the numbers, and what failed; or"
|
||||||
|
echo " * record an explicit maintainer WAIVER for this version in that"
|
||||||
|
echo " file, saying who waived it and what is untested."
|
||||||
|
echo
|
||||||
|
echo " The waiver is allowed on purpose: this gate requires a RECORD, not a"
|
||||||
|
echo " passing result, so shipping without a drill stays possible — and"
|
||||||
|
echo " stays a deliberate, reviewable commit instead of an oversight."
|
||||||
|
} >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "drill-recorded: $record carries a drill record for $ver ($(grep -c '' "$record") line(s))"
|
||||||
486
.github/scripts/labels-reconcile.sh
vendored
Normal file
486
.github/scripts/labels-reconcile.sh
vendored
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
#!/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:capture|C5DEF5|draft/capture — reading the live world into a manifest
|
||||||
|
scope:apply|C5DEF5|apply/diff/destroy — reconciling onto Coolify
|
||||||
|
scope:secrets|C5DEF5|secrets, age, the encrypted state repo
|
||||||
|
scope:fleet|C5DEF5|fleet/inventory/server — placement
|
||||||
|
scope:manifest|C5DEF5|manifest/resolve/envtemplate — the manifest language
|
||||||
|
scope:coolify-api|C5DEF5|coolify.ts + OpenAPI reference — the client
|
||||||
|
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
|
||||||
31
.github/scripts/release-notes.sh
vendored
Normal file
31
.github/scripts/release-notes.sh
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/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 (#96; box#83's extraction). 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.test.ts
|
||||||
|
# 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.1.0 — 2026-07-18') is the bare version —
|
||||||
|
# compared WHOLE, so 0.1.0 can never match a 0.1.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 (#96)" >&2; exit 1; }
|
||||||
|
printf '%s\n' "$notes"
|
||||||
88
.github/workflows/ci.yml
vendored
88
.github/workflows/ci.yml
vendored
|
|
@ -11,10 +11,16 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# changelog-monotonic compares HEAD against the merge base; a
|
# fetch-depth: 0, for the changelog-monotonic step below and only
|
||||||
# checkout that cannot resolve it is a hard failure in CI, not
|
# for it. That check is about a DIFF — which release headings the
|
||||||
# a skip (a guard that can quietly stop guarding is the failure
|
# merge base had — so it needs the base branch's history present,
|
||||||
# shape this repo keeps refusing).
|
# and the default depth-1 checkout has none of it. An explicit
|
||||||
|
# `git fetch origin <base>` would be narrower, but it has to be
|
||||||
|
# right on both event types and on fork PRs, and getting it subtly
|
||||||
|
# wrong degrades to a SKIP (a guard that silently stops guarding —
|
||||||
|
# the exact failure this repo keeps refusing). Full history on a
|
||||||
|
# tree this size costs a second; the STRICT flag below turns any
|
||||||
|
# remaining skip red rather than green.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -34,35 +40,55 @@ jobs:
|
||||||
# tracked *.sh, so a new script cannot go unlinted quietly (#118).
|
# tracked *.sh, so a new script cannot go unlinted quietly (#118).
|
||||||
- name: shellcheck — every tracked shell script
|
- name: shellcheck — every tracked shell script
|
||||||
run: bash .github/scripts/shellcheck-all.sh
|
run: bash .github/scripts/shellcheck-all.sh
|
||||||
|
- name: labels state-machine tests
|
||||||
|
run: bash test/labels-reconcile.sh
|
||||||
|
|
||||||
# The release guards, doctrine in heavy-duty/ceremony's README
|
# ...and no SHIPPED release heading was deleted or DUPLICATED (#133;
|
||||||
# (ceremony#15 is this conversion). Each guard's war story — why it
|
# box#122's guard, box#143's ordering fix). Its own step so that when it
|
||||||
# exists, what it refuses — lives with its implementation upstream;
|
# goes red the log names the invariant that broke — and a DIFFERENT
|
||||||
# the four pins below and the two workflow callers must always name
|
# invariant from the arming rule npm test carries: arming is a fact
|
||||||
# the same ceremony tag.
|
# about this tree, monotonicity is a fact about this tree versus its
|
||||||
|
# merge base. STRICT=1 so a checkout that cannot reach the base ref
|
||||||
|
# fails here instead of skipping quietly forever.
|
||||||
#
|
#
|
||||||
# changelog-armed: the version-keyed arming rule (rig#66 is the
|
# NOT pull-request-only, and that is the #133 fix at the workflow level.
|
||||||
# incident; the unconditional form cast#108 reverted — this is its
|
# The two halves have different vacuity: DELETION is vacuous on a push
|
||||||
# correct return).
|
# to main (the merge base IS HEAD), but DUPLICATION is vacuous on no
|
||||||
- uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0
|
# tree at all, so gating the whole script on `pull_request` left a
|
||||||
with:
|
# duplicate that reached main by any other route unasserted forever.
|
||||||
version-source: package-json
|
#
|
||||||
# changelog-monotonic: no shipped heading deleted or duplicated
|
# The `|| github.ref_name` fallback is load-bearing, not defensive. On a
|
||||||
# (#133; box#122's guard, box#143's ordering fix). Strict by default:
|
# push event `github.base_ref` is EMPTY, so the argument would collapse
|
||||||
# an unresolvable base ref is red, never a quiet skip — hence the
|
# to a bare `origin/`, which does not resolve — and STRICT=1 correctly
|
||||||
# fetch-depth: 0 above.
|
# promotes that to a hard failure, turning every push to main red. With
|
||||||
- uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0
|
# the fallback it resolves to the pushed branch, whose merge base with
|
||||||
# drill-recorded: a release version carries drills/<version>.md
|
# HEAD is HEAD or its parent: containment passes vacuously, exactly as
|
||||||
# (cast's drill meaning: drills/README.md). Vacuous on -dev trees; it
|
# the old `if` intended, while uniqueness now runs on every push.
|
||||||
# requires a RECORD, not a pass — a maintainer waiver is legal, and
|
- name: no shipped changelog heading was deleted or duplicated
|
||||||
# is itself the content of the file.
|
env:
|
||||||
- uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0
|
CHANGELOG_MONOTONIC_STRICT: "1"
|
||||||
with:
|
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}"
|
||||||
version-source: package-json
|
|
||||||
# docs-sync: the .ceremony/ doctrine mirror is byte-identical to the
|
# ...and a release carries its DRILL RECORD. CONTRIBUTING has always
|
||||||
# pin read from release.yml (ceremony#19) — a hand edit or a
|
# asked for the real-hardware drill; nothing asserted it, so it was
|
||||||
# half-done pin bump goes red here.
|
# performed exactly as often as a reviewer remembered to ask — which is
|
||||||
- uses: heavy-duty/ceremony/actions/docs-sync@0.1.0
|
# never, across every release in the family, until a bot blocked on it.
|
||||||
|
# Here it is a fact about the tree instead of a fact about somebody's
|
||||||
|
# memory.
|
||||||
|
#
|
||||||
|
# No `if:` guard on the event or the label. The script keys off
|
||||||
|
# package.json itself: a `-dev` tree has no ship claim and passes
|
||||||
|
# trivially, a bare version is a release ceremony tree and must have a
|
||||||
|
# record. Gating this step on the `release` label instead would put the
|
||||||
|
# assert behind a hand-applied label — the guard would be absent from
|
||||||
|
# exactly the PR that mislabels itself, and unasserted PRs are how the
|
||||||
|
# drill went missing in the first place.
|
||||||
|
#
|
||||||
|
# It requires a RECORD, not a PASS: a maintainer waiver is legal, and is
|
||||||
|
# itself the content of drills/<version>.md. Skipping stays possible and
|
||||||
|
# stays visible.
|
||||||
|
- name: a release version has a drill record
|
||||||
|
run: bash .github/scripts/drill-recorded.sh
|
||||||
|
|
||||||
# The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at
|
# The installer, proven by RUNNING it — CAST_INSTALL_SOURCE points it at
|
||||||
# this checkout, so CI proves the installer under review (the versioned
|
# this checkout, so CI proves the installer under review (the versioned
|
||||||
|
|
|
||||||
81
.github/workflows/labels.yml
vendored
81
.github/workflows/labels.yml
vendored
|
|
@ -1,19 +1,78 @@
|
||||||
name: labels
|
name: labels
|
||||||
# The automation LABELS.md promises, now implemented upstream
|
# The automation LABELS.md promises. Two halves:
|
||||||
# (heavy-duty/ceremony — ceremony#15 is this conversion): scope labeling and
|
# scope — path-derived scope:* labels on PRs (actions/labeler)
|
||||||
# the state reconciler live in the reusable workflow this caller pins. Cast
|
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh)
|
||||||
# keeps the triggers and permissions (a called workflow cannot define them),
|
#
|
||||||
# its path map in .github/labeler.yml, and its panel + scope taxonomy in
|
# pull_request_target, not pull_request: every PR here arrives from a fork,
|
||||||
# .github/labels.conf.
|
# 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:
|
on:
|
||||||
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
schedule:
|
||||||
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
- cron: "*/15 * * * *"
|
||||||
|
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
|
||||||
pull_request_target:
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
labels:
|
scope:
|
||||||
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0
|
# 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
|
||||||
|
|
|
||||||
260
.github/workflows/release.yml
vendored
260
.github/workflows/release.yml
vendored
|
|
@ -1,24 +1,250 @@
|
||||||
name: release
|
name: release
|
||||||
# The ceremony moved upstream (heavy-duty/ceremony — the doctrine is its
|
# The release publisher (#96; box#83's design) — two ways in, one act (#111;
|
||||||
# README; ceremony#15 is this conversion). Both doors — merge and tag push —
|
# box#96's design):
|
||||||
# live in the reusable workflow this caller pins; cast keeps only what a
|
#
|
||||||
# called workflow cannot define (triggers and permissions) plus its one
|
# - Merging a `release`-labeled PR into main IS the release. The ceremony
|
||||||
# genuinely local piece: the prebuilt-asset build, now the artifact hook at
|
# PR carries the bumped version and the stamped changelog; the
|
||||||
# .github/actions/release-artifact/, which both doors invoke.
|
# maintainer's merge is the ship decision, and tagging after it is
|
||||||
# Triggers and permissions MUST live here (a called workflow cannot define them):
|
# transcription — exactly where humans err silently and machines fail
|
||||||
|
# loudly. This path asserts four facts (each fail-loud, creating
|
||||||
|
# nothing), then tags the merge commit and publishes.
|
||||||
|
# - A bare X.Y.Z tag push (no 'v' prefix — box's and rig's tag scheme)
|
||||||
|
# stays as the documented manual fallback and backfill.
|
||||||
|
#
|
||||||
|
# Both paths converge on the SAME steps below — one notes extraction, one
|
||||||
|
# build, one asset name, one create — so they cannot drift.
|
||||||
|
#
|
||||||
|
# Where cast differs from its siblings: the release carries a PREBUILT
|
||||||
|
# asset. box and rig are pure bash, so GitHub's source tarball for the tag
|
||||||
|
# IS their package; cast's source tarball is not runnable — it needs npm ci
|
||||||
|
# and tsc first. So the build happens ONCE, here, and the asset is the
|
||||||
|
# runnable tree: bin/, dist/, production node_modules/, package.json.
|
||||||
on:
|
on:
|
||||||
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
|
# ONE push key, both filters — YAML maps are last-key-wins, so a second
|
||||||
# `push:` silently replaces the first and kills a door (rig's review catch).
|
# sibling `push:` would silently REPLACE the first and kill a door
|
||||||
|
# (grok's round-2 catch: the tag fallback had stopped triggering).
|
||||||
push:
|
push:
|
||||||
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
|
# Every tag, not a shape filter (box's and rig's precedent): a tag that
|
||||||
# never be skipped by a shape filter that didn't match
|
# mismatches package.json — a habitual v0.1.0, a typo — must fail the
|
||||||
|
# assert LOUDLY below, not be silently skipped by a pattern that didn't
|
||||||
|
# match.
|
||||||
|
tags: ["**"]
|
||||||
|
# The merge-is-the-release path (#111) rides pushes to MAIN, not
|
||||||
|
# pull_request events: a pull_request run from a public FORK gets a
|
||||||
|
# READ-ONLY GITHUB_TOKEN — `permissions:` cannot raise that ceiling —
|
||||||
|
# and every ceremony PR this org merges is cross-repo from the bot
|
||||||
|
# fork; the tag create would 403 after green asserts. A push to main
|
||||||
|
# is an in-repo event with the full write token, whoever authored the
|
||||||
|
# PR. The steps split on the pushed ref.
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # tag ref create + release create + the bump push
|
contents: write # tag create via the API + gh release create + the bump push
|
||||||
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
|
# Two consumers (labels.yml precedent — a declared permissions: block
|
||||||
issues: write # --label on that fallback PR rides the issues API
|
# 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
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
|
# Tag pushes and main pushes both enter (the asserts below are the
|
||||||
with:
|
# filter); the steps split on the ref. The hand-set `release` label
|
||||||
version-source: package-json
|
# (LABELS.md: `release` is the operator's — automation never guesses
|
||||||
|
# intent) is read via the API off the merge commit's PR, inside the
|
||||||
|
# decide step — a push event carries no PR payload, and the PR itself
|
||||||
|
# lives on a fork (the trigger comment).
|
||||||
|
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Either door: the pushed ref — a tag, or main's new head (the
|
||||||
|
# merge commit the maintainer shipped, which the tag created
|
||||||
|
# below will name).
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
# Depth 2: the pushed head's first parent must be resolvable for
|
||||||
|
# the decide step's all-zeros fallback (event.before on a
|
||||||
|
# branch-creation push).
|
||||||
|
fetch-depth: 2
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
- name: "tag push: the tag must name package.json's version"
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
run: |
|
||||||
|
ver="$(node -p 'require("./package.json").version')"
|
||||||
|
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
||||||
|
echo "tag '$GITHUB_REF_NAME' does not match package.json version '$ver' — creating nothing." >&2
|
||||||
|
echo "A release is a PR, then a tag (#96): the release PR bumps package.json (and package-lock.json) and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
|
||||||
|
# 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
|
||||||
|
# trigger 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 — and cast's ENTIRE
|
||||||
|
# pre-0.1.1 era, since 0.1.0 never
|
||||||
|
# carried -dev): green NOTICE no-op
|
||||||
|
# bare, unchanged, UNreleased→ the label says ship but this PR did
|
||||||
|
# not mint the version: refuse to
|
||||||
|
# guess. This is also the known
|
||||||
|
# first-release edge (#111): the 0.1.0
|
||||||
|
# ceremony (#110) ships by manual tag,
|
||||||
|
# the fallback path; the automation
|
||||||
|
# applies from 0.1.1 on.
|
||||||
|
# bare, changed → the ceremony: proceed
|
||||||
|
- name: 'decide: ceremony, or release-flow work under the label?'
|
||||||
|
id: decide
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.before }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
# Versions read via node, never regex (the pkg_version discipline).
|
||||||
|
ver="$(node -p 'require("./package.json").version')"
|
||||||
|
# event.before is all-zeros on a branch-create push; the pushed
|
||||||
|
# head's first parent is main the instant before, either way.
|
||||||
|
case "$BASE_SHA" in *[!0]*) ;; *) BASE_SHA="$(git rev-parse "$GITHUB_SHA^1")" ;; esac
|
||||||
|
git fetch --depth=1 origin "$BASE_SHA" || true
|
||||||
|
git show "$BASE_SHA:package.json" > "$RUNNER_TEMP/base-package.json"
|
||||||
|
base="$(node -p 'require(process.env.RUNNER_TEMP + "/base-package.json").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" > /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
|
||||||
|
echo "(If this PR was mislabeled, drop the label; if it was meant to release, it forgot the bump. The 0.1.0 first-release edge ships by manual tag — #111.)" >&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 (#111), not a bare push — creating nothing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "RELEASE_VERSION=$ver" >> "$GITHUB_ENV"
|
||||||
|
- name: release notes — the version's own CHANGELOG.md section
|
||||||
|
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
|
||||||
|
# Assert 3 on the merge path, the same fact on the tag path:
|
||||||
|
# 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 "$RELEASE_VERSION" > "$RUNNER_TEMP/notes.md"
|
||||||
|
cat "$RUNNER_TEMP/notes.md"
|
||||||
|
- name: "merged release PR: nothing exists yet, then tag the merge commit"
|
||||||
|
if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
MERGE_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
# Assert 4 — no tag and no release exist for this version. Re-runs
|
||||||
|
# of a completed ceremony REFUSE LOUDLY (red, creating nothing —
|
||||||
|
# the correct direction), and a manual race (an operator who
|
||||||
|
# tagged by hand between merge and here) fails the same way
|
||||||
|
# instead of double-publishing.
|
||||||
|
if git ls-remote --exit-code origin "refs/tags/$RELEASE_VERSION" > /dev/null; then
|
||||||
|
echo "tag '$RELEASE_VERSION' already exists — creating nothing (already released, or a manual tag won the race)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if gh release view "$RELEASE_VERSION" > /dev/null 2>&1; then
|
||||||
|
echo "release '$RELEASE_VERSION' already exists — creating nothing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# The act begins: tag the merge commit via the API. A tag created
|
||||||
|
# with GITHUB_TOKEN does not trigger other workflows, so the
|
||||||
|
# tag-push trigger above CANNOT fire on this tag and
|
||||||
|
# double-publish — which is also why the publish must happen in
|
||||||
|
# THIS job.
|
||||||
|
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
|
||||||
|
-f "ref=refs/tags/$RELEASE_VERSION" -f "sha=$MERGE_SHA"
|
||||||
|
- name: build the prebuilt dist asset
|
||||||
|
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
|
||||||
|
# Build ONCE, in CI — the whole point of the asset (#96): the
|
||||||
|
# installer's release channels never run npm or tsc. Deliberately no
|
||||||
|
# check/tests here: ci.yml already gated the merge commit this
|
||||||
|
# release names, and the test suite needs `age`, which this runner
|
||||||
|
# does not install. The staged tree is exactly what an install needs
|
||||||
|
# to run.
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
npm prune --omit=dev
|
||||||
|
mkdir -p "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION"
|
||||||
|
cp -R bin dist node_modules package.json "$RUNNER_TEMP/stage/cast-$RELEASE_VERSION/"
|
||||||
|
tar -C "$RUNNER_TEMP/stage" -czf "$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz" "cast-$RELEASE_VERSION"
|
||||||
|
- name: create the release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/') || steps.decide.outputs.ceremony == 'yes'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
gh release create "$RELEASE_VERSION" --verify-tag \
|
||||||
|
--title "$RELEASE_VERSION" --notes-file "$RUNNER_TEMP/notes.md" \
|
||||||
|
"$RUNNER_TEMP/cast-$RELEASE_VERSION.tgz"
|
||||||
|
# The post-release bump, folded into the release act (#111 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 the
|
||||||
|
# merge path nor a red run; should branch protection ever refuse the
|
||||||
|
# direct push, the step opens the bump PR itself and says so, loudly.
|
||||||
|
# Merge-door only (the decide gate): the manual tag path stays a
|
||||||
|
# fallback and does not rewrite main.
|
||||||
|
- name: bump main to the next -dev — the release re-arms main itself
|
||||||
|
if: github.ref == 'refs/heads/main' && steps.decide.outputs.ceremony == 'yes'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
# next is computed from the RELEASE tree (the checkout), then
|
||||||
|
# applied to whatever main is by the time of the push — if main
|
||||||
|
# moved in the window, release+1 still lands on the newer head,
|
||||||
|
# which is the intended arithmetic either way.
|
||||||
|
next="$(node -p 'const v = require("./package.json").version.split("."); v[2] = String(Number(v[2]) + 1) + "-dev"; v.join(".")')"
|
||||||
|
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
|
||||||
|
npm pkg set version="$next"
|
||||||
|
npm install --package-lock-only --ignore-scripts
|
||||||
|
git add package.json package-lock.json
|
||||||
|
git commit -m "chore: bump main to $next — a dev install must not impersonate $RELEASE_VERSION"
|
||||||
|
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." \
|
||||||
|
--label release
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -7,12 +7,6 @@ actually cutting it, and this file starts there.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- The release flow and labels automation now run heavy-duty/ceremony's shared
|
|
||||||
machinery at 0.1.0; the prebuilt-asset build moves to the release-artifact
|
|
||||||
hook (heavy-duty/ceremony#15)
|
|
||||||
|
|
||||||
## 0.2.0 — 2026-07-21
|
## 0.2.0 — 2026-07-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
236
CONTRIBUTING.md
236
CONTRIBUTING.md
|
|
@ -1,33 +1,59 @@
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
This repo is governed by
|
How change lands in this repo. The short version: PRs are born as drafts,
|
||||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). **Agents:
|
three reviewer bots take the first rounds, a human takes the last word — and
|
||||||
read [`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first** — it routes you to
|
labels tell you where everything is without opening anything.
|
||||||
your role file (builder, reviewer, triage), vendored beside it,
|
|
||||||
byte-identical to ceremony at the pin named in
|
|
||||||
[`.github/workflows/release.yml`](.github/workflows/release.yml) and
|
|
||||||
guarded by the `docs-sync` step in CI. The review-round doctrine — drafts,
|
|
||||||
whole-round replies, verdicts, the handoff — lives there and in
|
|
||||||
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); this file keeps only what is
|
|
||||||
genuinely cast's.
|
|
||||||
|
|
||||||
## The PR loop, cast specifics
|
## The PR loop
|
||||||
|
|
||||||
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
||||||
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
|
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
|
||||||
2. **The review panel** (`.github/labels.conf`'s `panel=` line):
|
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
|
||||||
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`,
|
bots on purpose.
|
||||||
`kimi-bot-andresmgsl` —
|
3. **When it's ready**: mark ready-for-review and request all three bots —
|
||||||
the required verdicts for a PR are the panel minus its author. The
|
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
|
||||||
maintainer (`danmt`) takes the last word and merges.
|
They poll roughly every 15 minutes.
|
||||||
3. **Checks must be green**: `npm run check`, `npm run build`, and
|
4. **Rounds are answered whole.** Wait until all three have reviewed, then
|
||||||
`npm test` locally mirror what CI runs, plus the shellcheck sweep
|
answer the entire round in a **single reply**, push the fixes, and
|
||||||
(`npm run check:shell`). The release guards (`changelog-armed`,
|
re-request the bots that didn't approve. Prefer verification over
|
||||||
`changelog-monotonic`, `drill-recorded`, `docs-sync`) run as ceremony's
|
argument: a test settles what a comment thread can't.
|
||||||
pinned actions.
|
5. **Reviews end in a verdict.** A reviewer — bot or human — either
|
||||||
4. **Feature PRs land their changelog entry as part of the PR**: add it
|
**approves** or **requests changes**, never a bare comment. A
|
||||||
under `CHANGELOG.md`'s `## Unreleased` heading — that section becomes
|
comment-only review is a non-verdict: it doesn't say whether the round
|
||||||
the release notes verbatim when a release is cut.
|
passed, and the state machine (and anyone scanning the board) has to
|
||||||
|
guess. The verdict carries *blockingness only*, the body carries the
|
||||||
|
feedback: non-blocking nits ride an **approval** and the author addresses
|
||||||
|
them at their discretion; anything blocking — including a question that
|
||||||
|
gates the verdict — is **request changes**, saying what unblocks it. The
|
||||||
|
reconciler treats a comment-only review as not-approved, so commenting
|
||||||
|
without a verdict only stalls the PR. The machine never reads review
|
||||||
|
bodies: when a comment-only reviewer's line is really an agreement, that
|
||||||
|
judgment belongs to the **author** — escalate by requesting the
|
||||||
|
maintainer's review (step 6), and the reconciler flips the label on that
|
||||||
|
request, because an explicit request is a fact it can trust.
|
||||||
|
6. **When the round passes, the author hands the PR to the maintainer** in
|
||||||
|
three acts, in this order: post the tagged round summary, request the
|
||||||
|
maintainer's review, then set `state:needs-human` yourself — removing the
|
||||||
|
state label it replaces. The review request is what *earns* the label,
|
||||||
|
provided the PR carries **no `blocker:*` label**. A blocker means the work
|
||||||
|
is still yours whatever the round said, so on a conflicted or red PR
|
||||||
|
neither the request nor your own label write will stick — the sweep takes
|
||||||
|
it straight back off. With three formal head-current approvals the labels
|
||||||
|
workflow requests the maintainer automatically; when part of the panel is
|
||||||
|
comment-only, reading their agreement is the author's judgment, so the
|
||||||
|
author makes the request.
|
||||||
|
|
||||||
|
Writing the label by hand is an **optimistic write, not a transfer of
|
||||||
|
ownership**. The machine stays the authority — but because the workflow
|
||||||
|
wakes on `labeled`, the author's own write fires the sweep that validates
|
||||||
|
it, and a handoff that had not earned the label is corrected seconds later.
|
||||||
|
Forgetting the write is not a failure either; it only means the label waits
|
||||||
|
for the cron, which is the lag this replaced.
|
||||||
|
7. **Checks must be green**: `npm run check`, `npm run build`, and
|
||||||
|
`npm test` locally mirror what CI runs.
|
||||||
|
8. **Feature PRs land their changelog entry as part of the PR** (box's
|
||||||
|
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
|
||||||
|
that section becomes the release notes verbatim when a release is cut.
|
||||||
|
|
||||||
## Changelog entries
|
## Changelog entries
|
||||||
|
|
||||||
|
|
@ -63,46 +89,144 @@ Not an entry — that is a PR body:
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
A release is a PR, and merging it is the release. The ceremony — the two
|
A release is a PR, and merging it IS the release
|
||||||
doors, the decide table, the stamps, the post-release re-arm — is
|
([#111](https://github.com/heavy-duty/cast/issues/111); box#96's design,
|
||||||
heavy-duty/ceremony's machinery, consumed by reference:
|
on box#83's shape):
|
||||||
[its README](https://github.com/heavy-duty/ceremony/blob/main/README.md)
|
|
||||||
is the doctrine, `.github/workflows/release.yml` here is the ≤20-line
|
|
||||||
caller pinning it (`version-source: package-json` — the version lives in
|
|
||||||
`package.json`, and the post-release bump keeps `package-lock.json` in
|
|
||||||
step), and the guards run in `ci.yml` from the same pin. Bare `X.Y.Z`
|
|
||||||
tags, no `v`.
|
|
||||||
|
|
||||||
What stays cast's beyond that pin:
|
1. A small PR — `release: X.Y.Z`, labeled `release` — bumps `package.json`'s
|
||||||
|
`version` (and `package-lock.json`; `npm install --package-lock-only`
|
||||||
|
keeps them in step) and stamps `CHANGELOG.md`'s Unreleased section as
|
||||||
|
`## X.Y.Z — YYYY-MM-DD`. **Then re-arm: add a fresh, empty
|
||||||
|
`## Unreleased` immediately above the section you just stamped.** The
|
||||||
|
same PR, the same diff — stamping without re-arming leaves main with no
|
||||||
|
`## Unreleased`, and the next PR that was authored before the release
|
||||||
|
and merged after has its entry land *inside the shipped section*, which
|
||||||
|
git does cleanly, with no conflict to warn anyone
|
||||||
|
(heavy-duty/rig#66 — it happened there). `test/release.test.ts` keys
|
||||||
|
this to the version, and checks both halves of the stamp:
|
||||||
|
- while `package.json` is bare, the top section may be the stamp or the
|
||||||
|
re-armed `## Unreleased`, but a `## X.Y.Z` section for the version you
|
||||||
|
are shipping **must exist and extract non-empty** — a bump without a
|
||||||
|
stamp is red here rather than after the merge, in release.yml;
|
||||||
|
- the moment step 4's `-dev` bump lands, the top section must be
|
||||||
|
`## Unreleased` or CI is red.
|
||||||
|
|
||||||
- **The prebuilt asset** —
|
The empty `## Unreleased` this step adds is deliberately tolerated: what
|
||||||
[`.github/actions/release-artifact/`](.github/actions/release-artifact/action.yml),
|
must extract non-empty is the section that SHIPS, not the top one. CI
|
||||||
the artifact hook both doors invoke: the build happens ONCE, in CI, and
|
green on it, same loop as any PR.
|
||||||
`cast-X.Y.Z.tgz` is the runnable tree (`bin/`, `dist/`, production
|
2. **Drill, and record it.** Before the PR can be handed over, run the full
|
||||||
`node_modules/`, `package.json`). That asset is what the installer's
|
real-hardware drill — two live Coolify instances, the whole A→B promotion:
|
||||||
release channels download — never a source tarball, never an
|
team, apply, an idempotent diff, smoke, inventory, emit-draft, fleet,
|
||||||
operator-machine build.
|
destroy, and the read-only guard — and record it in a file named for the
|
||||||
- **The drill** — the real-hardware gate before the handoff of a release
|
version, one record per version:
|
||||||
PR. Cast's drill asserts **promotion**: two live Coolify instances, the
|
|
||||||
full A→B run (team, apply, an idempotent diff, smoke, inventory,
|
drills/X.Y.Z.md
|
||||||
emit-draft, fleet, destroy, the read-only guard) — A→B reproduces, and
|
|
||||||
the diff is idempotent. The full meaning — the fixed candidate-ref
|
The name matches `package.json`'s `version` exactly, and the file must hold
|
||||||
pinning that dissolves the box↔rig recursion, the per-version record
|
at least one non-whitespace character. See
|
||||||
files, the waiver rule — is [`drills/README.md`](drills/README.md); the
|
[drills/README.md](drills/README.md) for what a record contains.
|
||||||
`drill-recorded` guard enforces the record on every release tree.
|
|
||||||
|
[.github/scripts/drill-recorded.sh](.github/scripts/drill-recorded.sh)
|
||||||
|
enforces this on every release PR (a `-dev` tree has no ship claim and
|
||||||
|
passes trivially). It is **not a thing a reviewer has to remember** — that
|
||||||
|
is precisely how every release in this family shipped without one until a
|
||||||
|
bot blocked on it.
|
||||||
|
|
||||||
|
So the release flow is: **draft → ready → bot round → drill →
|
||||||
|
`state:needs-human` → maintainer merge (which IS the release).**
|
||||||
|
|
||||||
|
**The three repos' drills are independent.** Run them in any order, on any
|
||||||
|
schedule, in separate sittings. They are not three phases of one script.
|
||||||
|
|
||||||
|
What makes that safe is that every drill **pins the same fixed set of
|
||||||
|
candidate refs**, so each one exercises exactly the combination that will
|
||||||
|
ship rather than whatever `main` happens to be that afternoon. The run
|
||||||
|
drills **candidate refs, not released artifacts**: `RIG_REPO` and `RIG_REF`
|
||||||
|
are mint-time environment variables (default `heavy-duty/rig@main`), so a
|
||||||
|
run pins the exact commits under test.
|
||||||
|
|
||||||
|
That pinning — **not sequencing** — is what dissolves the box↔rig
|
||||||
|
recursion. box and rig *are* mutually recursive: rig builds the host that
|
||||||
|
runs box, and box's seed calls rig back to converge the guest. But
|
||||||
|
candidate refs are static identifiers that exist as soon as the release
|
||||||
|
branches do, long before any drill runs, so a cycle at runtime becomes
|
||||||
|
independent tests against one fixed pair. No repo has to be released
|
||||||
|
before another can be drilled, and there is **no fixed order in which the
|
||||||
|
three releases must be published.**
|
||||||
|
|
||||||
|
Each repo also drills a **different thing**: box asserts the isolation
|
||||||
|
contract (the VM trust boundary), rig asserts convergence (a machine
|
||||||
|
reaches its role, idempotently), cast asserts promotion (A→B reproduces,
|
||||||
|
and the diff is idempotent). Three different exercises sharing a
|
||||||
|
substrate — which is exactly why the records are per-repo.
|
||||||
|
|
||||||
|
cast's legs are the **least coupled** of the three: two Coolify instances
|
||||||
|
can be stood up by hand, as the July drill did for instance B via a
|
||||||
|
parameterised compose file. Within a single drill you of course bring the
|
||||||
|
substrate up before probing it — a host before a guest before Coolify —
|
||||||
|
but that is how you run *a* drill, not an ordering rule *between repos*.
|
||||||
|
|
||||||
|
Drilling the candidate **is** drilling the release. A release PR's diff is
|
||||||
|
the version file and `CHANGELOG.md` — nothing executable differs between
|
||||||
|
the tree that was drilled and the tree that ships, so the evidence carries
|
||||||
|
across the ceremony commit.
|
||||||
|
|
||||||
|
Each repo records ITS OWN legs in its own `drills/X.Y.Z.md`, citing the
|
||||||
|
shared **run ID** that names the pinned set and the other two repos' commit
|
||||||
|
SHAs — which is what lets separate records be reassembled into one picture.
|
||||||
|
The guard still reads only this repo's files: cast never queries box's or
|
||||||
|
rig's drill records to decide whether cast may ship, because a cross-repo
|
||||||
|
lookup degrades to "pass" the moment it fails to resolve — the
|
||||||
|
unreadable-rollup bug wearing a different hat.
|
||||||
|
|
||||||
|
If a defect shows up only in the combination: patch, re-drill, re-record.
|
||||||
|
The three releases converge on a set that holds together; they are not
|
||||||
|
required to be right in one pass.
|
||||||
|
|
||||||
|
A maintainer **waiver** is possible — but it must be RECORDED in
|
||||||
|
`drills/X.Y.Z.md` for that version, saying who waived it and what is
|
||||||
|
untested. The guard requires a *record*, not a passing result, so skipping
|
||||||
|
the drill stays possible and stays visible and deliberate.
|
||||||
|
3. **Merge. That's the ship decision — nothing else to do.**
|
||||||
|
[release.yml](.github/workflows/release.yml) fires on the merged,
|
||||||
|
`release`-labeled PR and asserts, in order, each fail-loud and creating
|
||||||
|
nothing: the merged version is non-`-dev`; the version *changed in this
|
||||||
|
PR* (the `-dev` transition is the interlock — a mislabeled ordinary PR
|
||||||
|
fails here); that version's changelog section extracts non-empty
|
||||||
|
([.github/scripts/release-notes.sh](.github/scripts/release-notes.sh));
|
||||||
|
and no tag or release exists for it yet. Then, in the same job, it tags
|
||||||
|
the merge commit bare `X.Y.Z` (no `v` prefix — box's tag scheme), builds
|
||||||
|
the package once (`npm ci && npm run build && npm prune --omit=dev`), and
|
||||||
|
publishes the release with the runnable tree — `bin/`, `dist/`,
|
||||||
|
production `node_modules/`, `package.json` — attached as
|
||||||
|
`cast-X.Y.Z.tgz`. That asset is what the installer's release channels
|
||||||
|
download: the build happens once, in CI, never on an operator's machine.
|
||||||
|
*Manual fallback and backfill:* push a bare `X.Y.Z` tag on the merge
|
||||||
|
commit yourself — the same workflow runs the same asserts, build, and
|
||||||
|
publish from the tag.
|
||||||
|
4. **The release re-arms main itself**: the same workflow run bumps
|
||||||
|
`package.json` (and `package-lock.json`) to `X.Y.(Z+1)-dev` and pushes
|
||||||
|
the commit straight to main — no follow-up PR (it opens one only if
|
||||||
|
branch protection refuses the direct push, and says so loudly).
|
||||||
|
Installs are versioned by the tree's `package.json` version, so a
|
||||||
|
`CAST_REF=main` install between releases must land as
|
||||||
|
`versions/X.Y.(Z+1)-dev`, never as `versions/X.Y.Z` — main's tree must
|
||||||
|
not impersonate the release it merely descends from. On the *manual*
|
||||||
|
tag path the bump stays yours: open the one-line PR after publishing.
|
||||||
|
This step re-arms the **version** only — the `## Unreleased` heading is
|
||||||
|
step 1's, in the ceremony PR's own diff, because no workflow ever writes
|
||||||
|
`CHANGELOG.md`. The two halves meet in `test/release.test.ts`: once this
|
||||||
|
bump makes the version `-dev`, a missing `## Unreleased` is CI-red.
|
||||||
|
|
||||||
## Labels — who sets what
|
## Labels — who sets what
|
||||||
|
|
||||||
The taxonomy and state machine are
|
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is
|
||||||
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); cast's `scope:*` rows live in
|
who sets each kind — most of it is machinery, and hand-moving a
|
||||||
`.github/labels.conf` (reconciled by the labels caller) and their path map
|
machine-owned label just gets corrected on the next pass:
|
||||||
in `.github/labeler.yml`. What matters day to day is who sets each kind —
|
|
||||||
most of it is machinery, and hand-moving a machine-owned label just gets
|
|
||||||
corrected on the next pass:
|
|
||||||
|
|
||||||
| Labels | Set by |
|
| 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 and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* |
|
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff (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. |
|
| `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. |
|
| `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 PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
|
||||||
|
|
|
||||||
182
LABELS.md
Normal file
182
LABELS.md
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
# Labels
|
||||||
|
|
||||||
|
How this repo uses GitHub labels. The taxonomy is shared across the
|
||||||
|
heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo,
|
||||||
|
because it names this repo's actual surfaces.
|
||||||
|
|
||||||
|
## State — who is the ball with? (PRs, exactly one)
|
||||||
|
|
||||||
|
Every open PR carries exactly one `state:` label, and it answers the only
|
||||||
|
question a board scan actually asks: *who is this PR waiting on?* The states
|
||||||
|
mirror the review loop this repo runs — PRs open as drafts, three reviewer
|
||||||
|
bots pick up ready PRs with reviews requested, each round is answered in a
|
||||||
|
single reply, and a human takes the final review.
|
||||||
|
|
||||||
|
| Label | Color | Waiting on | Enters when | Leaves when |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
|
||||||
|
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
|
||||||
|
| `state:addressing` | `#D93F0B` | the coding agent to reply, fix, or ask | all bots reviewed and not all approved; or nobody was asked; or a blocker is up | the round-reply is posted and fixes pushed — and any blocker named alongside is cleared |
|
||||||
|
| `state:needs-human` | `#8250DF` | the human reviewer | the PR **could be merged right now**: no blockers, three formal head-current approvals — and the human review is requested | merged — or changes requested, which cycles back to `state:addressing` |
|
||||||
|
|
||||||
|
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
|
||||||
|
first means *poke the bots*, staleness in the second means *the agent dropped
|
||||||
|
the ball*. Collapsing them loses exactly the information a sweep needs.
|
||||||
|
`bots-reviewing` therefore means strictly *a request is live and an answer is
|
||||||
|
coming* — a PR nobody was asked to review is the agent's ball, not the bots'.
|
||||||
|
|
||||||
|
## The second axis: `blocker:*`
|
||||||
|
|
||||||
|
State answers *whose ball is it*. Blockers answer *what is in the way*, and
|
||||||
|
unlike states they are *facts about the branch* — mutually independent, so a
|
||||||
|
PR carries as many as apply.
|
||||||
|
|
||||||
|
| Label | Color | Means | Clears when |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `blocker:conflict` | `#B60205` | GitHub says `CONFLICTING` — the agent owes a **rebase** | it merges cleanly |
|
||||||
|
| `blocker:ci-red` | `#B60205` | a check failed — the agent owes a **fix**, which a rebase will not provide | checks are green |
|
||||||
|
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody — never reviewed, or staled by a push — and **nobody was asked** for one | reviews are requested |
|
||||||
|
| `blocker:drill-pending` | `#E99695` | a `release` PR whose version has **no drill record** at [`drills/<version>.md`](drills/README.md) — the ceremony is correct but *unevidenced* | the record (or a recorded maintainer waiver) lands for that version |
|
||||||
|
|
||||||
|
`blocker:drill-pending` is the only blocker that says nothing is *wrong* with
|
||||||
|
the code. The version bump, the stamp, the re-arm can all be perfect; what is
|
||||||
|
missing is the evidence that anyone ran the real-hardware drill against the
|
||||||
|
tree about to ship. It clears on a RECORD, not on a pass — a recorded waiver
|
||||||
|
clears it too, which is the point: skipping the drill stays possible and stays
|
||||||
|
visible. `.github/scripts/drill-recorded.sh` is what turns CI red meanwhile.
|
||||||
|
|
||||||
|
**The label does not exist in the repo yet.** Creating a label needs push
|
||||||
|
access and the bot account gets a 403, so a maintainer account has to run the
|
||||||
|
`gh label create` line below (or the workflow's manual dispatch) once. Until
|
||||||
|
then the reconciler cannot apply it, and plain `blocked` stands in — coarser,
|
||||||
|
but it keeps an unevidenced release off the merge path, which is the job.
|
||||||
|
|
||||||
|
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:capture` | `draft.ts`, `capture.ts` — reading the live world into a manifest |
|
||||||
|
| `scope:apply` | `apply.ts`, `diff.ts`, `destroy.ts` — reconciling the manifest onto Coolify |
|
||||||
|
| `scope:secrets` | `secrets.ts`, age handling, the encrypted state repo |
|
||||||
|
| `scope:fleet` | `fleet.ts`, `inventory.ts`, `server.ts` — placement and the server side |
|
||||||
|
| `scope:manifest` | `manifest.ts`, `resolve.ts`, `envtemplate.ts` — the manifest language itself |
|
||||||
|
| `scope:coolify-api` | `coolify.ts`, the OpenAPI reference — the client surface |
|
||||||
|
|
||||||
|
## Issue types
|
||||||
|
|
||||||
|
`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in
|
||||||
|
the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label
|
||||||
|
would just say the same thing twice, drifting apart eventually.
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
State labels are machine-owned, with exactly one exception. Every state above
|
||||||
|
is derivable from GitHub's own facts — the draft flag, requested reviewers,
|
||||||
|
review states, push timestamps — so the labels workflow
|
||||||
|
([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
|
||||||
|
state and reconciles labels statelessly, on PR events (label changes included)
|
||||||
|
plus a 15-minute cron. A hand-moved label is a lie waiting to happen; the
|
||||||
|
workflow asserts the effective state instead.
|
||||||
|
|
||||||
|
The exception is `state:needs-human`, which the author sets at handoff
|
||||||
|
([CONTRIBUTING.md](CONTRIBUTING.md), step 6). That is an optimistic write, not
|
||||||
|
a transfer of ownership: because `pull_request_target: labeled` wakes the
|
||||||
|
workflow, the author's own label write fires the sweep that validates it, and
|
||||||
|
a handoff that had not earned the label is corrected within seconds.
|
||||||
|
|
||||||
|
It exists because the wake signal was missing. There is no
|
||||||
|
`pull_request_review_target` — on fork PRs, which is all of them here,
|
||||||
|
`pull_request_review` runs read-only and cannot label anything — so the moment
|
||||||
|
the label becomes true, the third approval landing, fired nothing at all. What
|
||||||
|
was left was the `*/15` cron, and GitHub deprioritises short intervals hard
|
||||||
|
enough that the delivered rate is closer to hourly. The label could therefore
|
||||||
|
lag the round it described by hours, worst on the quietest repo: every sweep
|
||||||
|
reconciles the whole board, so a busy repo stays fresh by piggybacking on
|
||||||
|
unrelated PR events, while a quiet one depends on the cron most and receives
|
||||||
|
it least. `scope:` labels on PRs are applied from the changed
|
||||||
|
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
|
||||||
|
|
||||||
|
The same workflow bootstraps the taxonomy: a manual dispatch creates any
|
||||||
|
missing label idempotently. To create them by hand (needs push access):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force
|
||||||
|
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
|
||||||
|
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
|
||||||
|
gh label create "blocker:conflict" --color B60205 --description "Does not merge — the branch conflicts and the agent owes a rebase" --force
|
||||||
|
gh label create "blocker:ci-red" --color B60205 --description "A check is failing — the agent owes a fix (not a rebase)" --force
|
||||||
|
gh label create "blocker:unrequested" --color E99695 --description "Somebody still owes a verdict and nobody was asked for one" --force
|
||||||
|
# needs a MAINTAINER account — the bot 403s on label creation, and until this
|
||||||
|
# runs the reconciler cannot apply it and `blocked` stands in.
|
||||||
|
gh label create "blocker:drill-pending" --color E99695 --description "Release PR with no drill record at drills/<version>.md — ceremony correct, unevidenced" --force
|
||||||
|
# retired — the reconciler strips it; delete it once no PR carries it
|
||||||
|
# gh label delete "state:needs-rebase"
|
||||||
|
gh label create "state:needs-human" --color 8250DF --description "No blockers, all bots approve — waiting on the human reviewer" --force
|
||||||
|
gh label create "merge-next" --color 0E8A16 --description "Head of the merge queue — merge this one next (set by hand/agent, cleared here)" --force
|
||||||
|
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
|
||||||
|
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
|
||||||
|
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
|
||||||
|
gh label create "scope:capture" --color C5DEF5 --description "draft/capture — reading the live world into a manifest" --force
|
||||||
|
gh label create "scope:apply" --color C5DEF5 --description "apply/diff/destroy — reconciling onto Coolify" --force
|
||||||
|
gh label create "scope:secrets" --color C5DEF5 --description "secrets, age, the encrypted state repo" --force
|
||||||
|
gh label create "scope:fleet" --color C5DEF5 --description "fleet/inventory/server — placement" --force
|
||||||
|
gh label create "scope:manifest" --color C5DEF5 --description "manifest/resolve/envtemplate — the manifest language" --force
|
||||||
|
gh label create "scope:coolify-api" --color C5DEF5 --description "coolify.ts + OpenAPI reference — the client" --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
|
||||||
|
```
|
||||||
|
|
@ -5,8 +5,8 @@ Per-release evidence: **one file per version**, named `<version>.md`, where
|
||||||
`0.2.0.md`, `0.2.0-rc1` in `0.2.0-rc1.md`.
|
`0.2.0.md`, `0.2.0-rc1` in `0.2.0-rc1.md`.
|
||||||
|
|
||||||
A release PR's version must have its file here, holding at least one
|
A release PR's version must have its file here, holding at least one
|
||||||
non-whitespace character, before CI will let it merge (the
|
non-whitespace character, before CI will let it merge
|
||||||
`heavy-duty/ceremony/actions/drill-recorded` guard, pinned in ci.yml).
|
(`.github/scripts/drill-recorded.sh`, wired into ci.yml).
|
||||||
|
|
||||||
## One file per version, and why the parser went away
|
## One file per version, and why the parser went away
|
||||||
|
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "cast",
|
"name": "cast",
|
||||||
"version": "0.2.1-dev",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cast",
|
"name": "cast",
|
||||||
"version": "0.2.1-dev",
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"yaml": "^2.5.0",
|
"yaml": "^2.5.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "cast",
|
"name": "cast",
|
||||||
"version": "0.2.1-dev",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
416
test/labels-reconcile.sh
Normal file
416
test/labels-reconcile.sh
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
#!/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 ]
|
||||||
1102
test/release.test.ts
1102
test/release.test.ts
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue