Compare commits

..

No commits in common. "84bb1a424dfe467358e99d4c6ab4e76ac89cc328" and "d9c5b92dd1616fc66bf0c6c2dbc14fefc6b0001d" have entirely different histories.

21 changed files with 280 additions and 682 deletions

View file

@ -16,5 +16,5 @@ to the issue for triage to amend, not silently unshipped. -->
## Round log ## Round log
<!-- The engine appends each whole-round reply here, newest last: what <!-- Append each round's summary here: what changed, what was verified.
changed and what was verified. Builders write the reply, not this section. --> Rounds are answered whole — one reply covering every point. -->

View file

@ -10,14 +10,11 @@ name: labels
# and reconcile checks out the BASE branch only. Keep it that way. # 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 # There is no pull_request_review_target, so a review landing cannot wake this
# workflow directly — which is why the caller's cron is load-bearing, not a # workflow directly — and the */15 cron is advisory: GitHub deprioritises
# safety net (#199 relaxed it from */15 to hourly, but did NOT drop it). The # short intervals hard enough that a quiet repo goes hours between ticks. The
# cron is the sweep's only discovery path for every transition no subscribed # handoff wakes the sweep itself: the author sets state:needs-human, and the
# event carries: a verdict landing, blocker:ci-red set/cleared, a # caller's `labeled` event confirms or corrects that optimistic write within
# blocker:conflict when another PR merges under this one, and the time-based # seconds. The cron stays as the last resort for a forgotten handoff.
# stale / 48h claim-reclaim. Where an event IS subscribed the wake is direct —
# the handoff sets state:needs-human and the caller's `labeled` event confirms
# or corrects that optimistic write within seconds.
# #
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not # This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a # create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a
@ -28,7 +25,7 @@ on:
env: env:
# A called workflow arrives without its repository. Keep this literal pin # A called workflow arrives without its repository. Keep this literal pin
# aligned with the ceremony release consumed by callers (issue #9 D3). # aligned with the ceremony release consumed by callers (issue #9 D3).
CEREMONY_SELF_REF: "0.4.0" CEREMONY_SELF_REF: "0.3.0"
jobs: jobs:
scope: scope:

View file

@ -129,7 +129,7 @@ env:
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the # `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
# shared logic arrives as script files via checkout, not as inner `uses:` # shared logic arrives as script files via checkout, not as inner `uses:`
# references. # references.
CEREMONY_SELF_REF: "0.4.0" CEREMONY_SELF_REF: "0.3.0"
VERSION_SOURCE: ${{ inputs.version-source }} VERSION_SOURCE: ${{ inputs.version-source }}
jobs: jobs:

View file

@ -5,40 +5,11 @@ name: labels
# unpinned — correct only for the repo that IS the source). Consumers write: # unpinned — correct only for the repo that IS the source). Consumers write:
# uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag> # uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
on: on:
# The consumer owns this cadence (#203). Hourly is the recommended default schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
# when no other engine drives board state: the cron is then the sweep's ONLY workflow_dispatch: # bootstraps missing labels on a fresh repo
# wake for four transition classes — a review verdict landing (there is no
# pull_request_review trigger here), blocker:ci-red set or cleared (no
# check_suite/check_run/workflow_run), a blocker:conflict when ANOTHER PR
# merges under this one, and the time-based stale / 48h claim-reclaim. The
# events below carry the rest in seconds. Hourly trades ≤1h of latency on
# those four while cutting nominal scheduled sweeps from four an hour to one
# at GitHub's 1-minute billing floor. Do not delete the cron: it is their
# discovery path. If another engine writes some of those transitions, only
# the classes with no other writer bound the cadence; relax it only as that
# list shrinks.
schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep, including taxonomy bootstrap on a fresh repo.
workflow_dispatch:
# Narrowed (#199) to the actions that carry a queue-state change the hourly
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
# unassigned, which feed validation and the 48h claim clock (caught within one
# cadence) and were the dominant issues-churn source. Kept: `opened` (the
# mint→needs-triage check, issueflow's opened-only path), `closed` (the
# blocker-closes→ready self-heal, crew#96/#98), `edited` (a body rewrite of the
# `Blocked by #N` declaration the sweep parses — issueflow-reconcile.sh:179),
# `reopened` (a closed issue re-entering the queue wearing labels derived when
# it closed). The must-fail in #199 is exactly "a queue-state transition waits
# on the schedule when an event could have carried it", so edited/reopened stay
# on events. The PR handoff wake is pull_request_target:labeled, NOT issues, so
# this does not touch the handoff.
issues: issues:
types: [opened, closed, edited, reopened] types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
pull_request_target: pull_request_target:
# Every PR arrives from a fork, so these carry the head/draft/review facts
# the sweep derives state:* from. labeled/unlabeled are the handoff wake —
# the author's optimistic state:needs-human write, confirmed or corrected
# here in seconds (#11); synchronize re-derives on every push;
# review_requested/review_request_removed wake the sweep that clears (or # review_requested/review_request_removed wake the sweep that clears (or
# restores) blocker:unrequested — without them the one event that makes # restores) blocker:unrequested — without them the one event that makes
# the label false could not clear it, and a quiet repo wore the red flag # the label false could not clear it, and a quiet repo wore the red flag

View file

@ -11,27 +11,6 @@ triage bug, and the move is to say so on the issue, not to guess.
- Respect dependency order: inside an epic, take the earliest unblocked - Respect dependency order: inside an epic, take the earliest unblocked
unclaimed child. Between epics and strays, prefer the issue that unblocks unclaimed child. Between epics and strays, prefer the issue that unblocks
the most other work. the most other work.
- **Your own red head outranks a new claim.** A failing check at the head
of a PR you authored is picked up **before claiming another issue**
repairing your own red PR comes ahead of new work, which is why the
engine's duty order evaluates ci-red between resume and build (crew#17:
ceremony#163 sat with full-panel approvals at its head, mergeable, and
stranded on an HTTP 429 in a job that never ran the PR's code, because no
wake covered a red head that owed no round and had no conflict). Red and
green here are the ruled terms of the review round below: a cancelled or
stale check is not a green head; a skipped or neutral one is. The
recovery path (crew#17): inspect the check at the head and record the
failing check and its failure class; rerun a clearly retryable
infrastructure failure without changing code; when the failure belongs to
the branch, return to the normal fix-round and worklog discipline; leave
visible evidence when a rerun cannot be started or the cause is
uncertain; never repeatedly rerun a deterministic branch failure without
a corrective commit; and proceed to handoff once the check is green and
current-head approvals stand. A PR of yours with a red head is **not
parked** — the next move is yours, whatever the round's verdict state
says (shape 2 below carves this out explicitly). How the engine detects a red
head — its ledger, its quiet rules, the rollup's node shapes — is crew's
to describe, not this file's.
- **One build at a time.** You hold at most one issue on which you are - **One build at a time.** You hold at most one issue on which you are
writing or revising a deliverable — finish or release that work before writing or revising a deliverable — finish or release that work before
starting new work. The rule counts build work in flight, not claims: a starting new work. The rule counts build work in flight, not claims: a
@ -41,16 +20,9 @@ triage bug, and the move is to say so on the issue, not to guess.
its `Blocked:` line stops the remaining work; its `Blocked:` line stops the remaining work;
2. the deliverable is in a review round where every outstanding verdict 2. the deliverable is in a review round where every outstanding verdict
belongs to someone else — either the round is awaiting its first belongs to someone else — either the round is awaiting its first
verdicts, or it was answered whole and the owed re-requests posted — verdicts, or it was answered whole and the non-approvers re-requested
by head, not by verdict: every panelist after a push, the (the review round, steps 12). This is the *live* round; shape 4 is
non-approvers alone at an unchanged head (the review round, steps the *passed* one — they are sequential and do not overlap;
12). This is the *live* round; shape 4 is
the *passed* one — they are sequential and do not overlap. A red
check at the current head takes the deliverable **out of this
shape**: mid-round CI going red is exactly the state that reads as
"waiting on the panel" and is not — the next move is yours (the
red-head rule above), and reading it as parked is what strands the
PR;
3. every remaining acceptance criterion is operator-owned, stated as such 3. every remaining acceptance criterion is operator-owned, stated as such
by triage on the issue; by triage on the issue;
4. the deliverable is **handed off** — the round passed, no `blocker:*` 4. the deliverable is **handed off** — the round passed, no `blocker:*`
@ -81,8 +53,7 @@ triage bug, and the move is to say so on the issue, not to guess.
that, or, if the events genuinely do not resolve it, say so on the that, or, if the events genuinely do not resolve it, say so on the
issue and pick the next `ready` issue rather than idling on this one. issue and pick the next `ready` issue rather than idling on this one.
Not parked — these are what the rule defends against: waiting on Not parked — these are what the rule defends against: waiting on
yourself, waiting on CI (a red head is your own work, above; a pending yourself, waiting on CI, or waiting for a good moment. An issue you have
one resolves without you), or waiting for a good moment. An issue you have
simply stopped working on is not parked either — that is abandonment, simply stopped working on is not parked either — that is abandonment,
and its move is unchanged: unassign and restore `ready` (Claiming, and its move is unchanged: unassign and restore `ready` (Claiming,
below). below).
@ -107,12 +78,12 @@ triage bug, and the move is to say so on the issue, not to guess.
([#52](https://github.com/heavy-duty/ceremony/issues/52)) and `offsite` ([#52](https://github.com/heavy-duty/ceremony/issues/52)) and `offsite`
([#68](https://github.com/heavy-duty/ceremony/issues/68)) exemptions ([#68](https://github.com/heavy-duty/ceremony/issues/68)) exemptions
already guard — a parked claim nobody can name is an abandoned one. already guard — a parked claim nobody can name is an abandoned one.
Shape 4 alone is exempt from the separate comment: the factual handoff Shape 4 alone is exempt from the separate comment: the handoff round
comment plus the `state:needs-human` write *is* its declaration — both summary plus the `state:needs-human` write *is* its declaration — both
halves are already there, what the claim waits on (the merge) and who halves are already there, what the claim waits on (the merge) and who
owns the next move (the human), and both are visible to any scan as a owns the next move (the human), and both are visible to any scan as a
`labeled` event with the comment beside it. No second comment is owed on `labeled` event with the summary beside it. No second comment is owed
the issue. Every other shape still declares as above. on the issue. Every other shape still declares as above.
Declared once, the declaration **stands** until the park's facts change: Declared once, the declaration **stands** until the park's facts change:
a resumption that finds nothing changed posts nothing — the standing a resumption that finds nothing changed posts nothing — the standing
declaration is the record, and silence while parked is compliant, not declaration is the record, and silence while parked is compliant, not
@ -208,9 +179,9 @@ triage bug, and the move is to say so on the issue, not to guess.
## The review round ## The review round
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: (If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
repo-specific facts such as the panel roster live in that repo's own panel roster and any repo-specific flow notes live in that repo's own
CONTRIBUTING; the shared flow lives here and is not restated there.) CONTRIBUTING; everything below is the shared flow.)
1. Mark ready-for-review; request **the whole panel**. The panel is the roster 1. Mark ready-for-review; request **the whole panel**. The panel is the roster
of the repo the **PR** is in, minus you — never the roster of the repo the of the repo the **PR** is in, minus you — never the roster of the repo the
@ -223,56 +194,9 @@ CONTRIBUTING; the shared flow lives here and is not restated there.)
does not become required. On rig#112 this distinction mattered: requesting does not become required. On rig#112 this distinction mattered: requesting
codex and grok was correct for rig's panel even though ceremony's bench was codex and grok was correct for rig's panel even though ceremony's bench was
larger, and the doctrine had not said which roster governed. larger, and the doctrine had not said which roster governed.
**A review request requires a green check at the head.** A red check is
the author's own signal, not the panel's work: if the check is red, that
is your next task, not the panel's — fix it and push, then request. This
binds *you*, whether or not any engine enforces it. "My local suite
passed" is evidence about your machine; the check at the head is the
shared artifact the panel actually reads, and a reviewer's first act is
to read it. The one exception is a failure genuinely outside the PR — a
runner outage, a flaky dependency, a failure already present on the
default branch — and it is an exception only if the request says so
explicitly and names the evidence (e.g. "the same job fails identically
on `origin/main` at `<sha>`"). Silence about a red check is what is
prohibited; an argued exception shifts the burden to the author.
*Green* is a ruled term (operator, 2026-07-27): a **cancelled or
stale** check is not a green head — the rollup is scoped to the current
head, so what survives there is same-head cancellation, not
supersession by a newer push — while a **skipped or neutral** one *is*
green: those are deliberate "passed / not applicable" conclusions, and
reddening them would red every conditional job the fleet skips on
purpose. The costs behind the line are asymmetric: a false green spends
a three-reviewer round; a false red spends one author session.
2. **Wait for every verdict, then answer the round whole** — one reply 2. **Wait for every verdict, then answer the round whole** — one reply
covering every point and stating what changed and what was verified. covering every point, then push the fixes, then re-request exactly the
That reply is the written round record: the engine mirrors it under the reviewers who did not approve. Prefer verification over argument: when a
PR body's **Round log**, newest last, so the builder owes the reply and
no separate body edit. At re-request time the engine takes the author's
comments posted after the newest verdict in the round and appends them
with `<!-- round:<head-sha> -->`; an existing marker makes a retry a
no-op. If the builder posted no reply, the engine records that the round
passed without one and never blocks handoff on the omission. Then push
the fixes, then re-request **by head, not by verdict**: if answering the
round pushed any commit, every
panelist's approval is now stale — an approval is of a specific tree,
and the handoff predicate counts only approvals at the current head —
so **every panelist is re-requested, the approvers included**; a
panelist left un-re-requested after a push can never approve the tree
you shipped, and the PR sits looking finished with a full set of
verdicts and nothing owed by anyone, the same silent-stall shape as
[#26](https://github.com/heavy-duty/ceremony/issues/26)/[#39](https://github.com/heavy-duty/ceremony/issues/39).
Only when the head did not move — the round was answered with argument
or evidence and nothing was pushed — do you re-request just the
non-approvers: a standing approval already covers this exact head, and
the engine absorbs a re-request at an unchanged head (the re-request
rule, [#94](https://github.com/heavy-duty/ceremony/issues/94); its
mechanism is crew's to describe). **The re-request carries the same
green-check-at-head precondition as the first request**, argued
exception included. This is where the measured cost landed: crew#40
burned two consecutive heads and four reviewer-rounds, every one
relaying a CI failure already visible in the job log (crew#45). A fix
push whose check comes up red is not ready to go back to the panel; it
is your next fix. Prefer verification over argument: when a
reviewer doubts behavior, add the test that settles it. reviewer doubts behavior, add the test that settles it.
3. Never dismiss a review, never merge, never mark your own work as passed. 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 A blocking point you disagree with is answered with evidence or escalated
@ -339,19 +263,14 @@ item to its flow in the same comment ([LABELS.md](LABELS.md)).
When the round passes — every panel verdict approves the **current head**, When the round passes — every panel verdict approves the **current head**,
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
this is a release PR) — the engine performs these mechanical steps on the this is a release PR) — hand it to the human, in order:
builder's behalf, in order:
1. request the human's review; 1. post the round summary (what changed per round, what was verified);
2. set `state:needs-human`; 2. request the human's review;
3. post the engine-rendered handoff comment: approvals at the current head, 3. set `state:needs-human` yourself.
the head SHA, and a pointer to the PR body's **Round log**.
The builder composes no new summary at handoff: the authored record already The label write is optimistic — the reconciler validates it, and takes it
lives in the Round log, mirrored mechanically from each whole-round reply as back if the PR is not actually mergeable-right-now. Then stop: the PR is the
specified above. The label write is optimistic — the reconciler validates human's. The claim is now parked as shape 4 (Picking, above) — the handoff
it, and takes it back if the PR is not actually mergeable-right-now. Then you just posted is its declaration, and your build slot is free. Address
stop: the PR is the human's. The claim is now parked as shape 4 (Picking, what comes back (`state:addressing`) and re-hand-off the same way.
above) — the handoff you just posted is its declaration, and your build slot
is free. Address what comes back (`state:addressing`) and re-hand-off the
same way.

View file

@ -9,102 +9,6 @@ Entries arrive as fragments — one `changelog.d/<issue>.md` per PR, never
an edit to this file — and the release PR assembles them into the next an edit to this file — and the release PR assembles them into the next
section here (`bin/changelog-assemble`, #112). section here (`bin/changelog-assemble`, #112).
## 0.4.0 — 2026-07-29
### Added
- `changelog.d/shape` — an optional one-line sentinel, `flat` or `grouped`,
that pins the fragment set's shape and outranks the newest-published-section
inference; absent, the inference binds unchanged (#182).
- Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification.
- `changelog_fragment_problem` bounds every entry at 300 normalized
characters, red on the PR that writes the fragment; the armed guard and
the assembler inherit the one definition (#167).
- BUILDER.md and CHANGELOG.md state the bound and the split rule: a long
change ships several short entries, never one long one (#167).
### Changed
- Labels automation docs now make sweep cadence a consumer-owned tradeoff,
retain hourly as the engine-less default, and document manual dispatch as
the operator's immediate full-board sweep (#203).
- `labels` — the reconcile cron relaxes from `*/15` to hourly (#199), cutting a
private consumer's schedule-triggered full-board sweeps ~4× at GitHub's
1-minute billing floor.
- `labels` — the hourly cron is the sweep's only wake for transitions no
subscribed event carries — a verdict landing, blocker:ci-red, a
blocker:conflict when another PR merges, the time-based stale/reclaim — so it
bounds their latency to ≤1h, delaying no event-carried transition (#199).
- `labels` — the caller's `issues:` trigger narrows to
`[opened, closed, edited, reopened]` (#199), the actions that carry a
queue-state change the cron cannot wait a cadence for. The churn/validation
actions — labeled/unlabeled/assigned/unassigned — come off; the PR handoff
wake is unaffected.
- `labels` — each caller trigger now carries a comment saying why it is
subscribed, and reconcile keeps `cancel-in-progress: false` (#199) —
cancelling a sweep mid-board is the race that guard exists to prevent.
- `CONTRIBUTING.md` now points to `BUILDER.md` for the shared PR flow instead
of restating doctrine that can drift, while retaining ceremony's roster and
other repo-specific facts (#198).
- Builder doctrine makes each whole-round reply the durable Round log record
mirrored by the engine, leaving handoff as a mechanical facts-only step
instead of a newly composed summary (#196).
- `FLEET.md` removes its duplicate bench roster, records crew as a general
operator-configured tool, and advances its whole-file audit stamp to
`crew@eaeb302` with every surviving crew link re-pinned (#193).
- `FLEET.md` keeps the registry's authorization rule and its crew#16/crew#66
provenance, while replacing duplicated mechanism and path claims with a
pinned pointer to crew's registry header (#192).
- `BUILDER.md` gates both review-request points on a green check at the
head, carries crew#45's argued exception for failures outside the PR,
and states the ruled classification: cancelled and stale are not a
green head; skipped and neutral are (#189).
- `BUILDER.md` documents CI-red recovery in pickup precedence: a red head
of your own PR is picked up before claiming another issue, is never a
parked claim, and follows crew#17's recovery path (#189).
- `FLEET.md` writes the ci-red wake into the duty order between resume
and build, now as deployed engine rather than on paper: the
reconciliation stamp advances to the crew SHA carrying crew#64 (#189).
- `FLEET.md` describes the build wake's check gate as the engine
implements it: a green head, or one with no checks configured, opens a
round; a red head and an unfinished one are held and reported
separately (#189).
- `FLEET.md` corrects the attention wake to the crew#66 ruling: the query
is cross-repo, the action is registry-bounded, and an out-of-scope
demand is reported and escalated to the operator rather than worked. It
no longer claims attention is exempt from the registry (#189).
- `FLEET.md` distinguishes an attention session that dies before acking,
which relaunches, from one that completes without acking, which is a
decline a ledger keeps from re-firing (#189).
- `BUILDER.md` re-requests by head, not by verdict: a push while
answering a round stales every approval, so every panelist is
re-requested; only an unchanged head re-requests the non-approvers
alone (#190).
- FLEET.md's duty-loop mechanism is a pointer to crew's shared engine; the
wake lists follow the engine's duty order, the roster keeps the as-built
bench beside `fleet.roster`'s target, and the reconciliation stamp names
crew@`01fb49c` (#187).
- Ceremony's changelog is grouped from this release forward: the pending
fragments carry `### ` headings under a `grouped` sentinel (#182).
- BUILDER.md: a park declaration stands until its facts change — a
nothing-changed resumption posts nothing; only a no-open-PR park owes a
refresh, inside the 48-hour reclaim window (#178).
- Private-repository label callers document `actions: read` alongside checks and statuses for workflow-run check-rollup nodes (#173).
### Fixed
- FLEET.md no longer says a review request outside the registry is
authorization: `repos.txt` is the scope for the review queue, out-of-scope
requests are logged and never acted on, and the attention wake is stated
as the one registry-independent exception, by design (#187).
- `blocked_reference_records` unions every `Blocked by` clause in the body
instead of binding to the first marker occurrence — a repeated declaration
no longer promotes on its first sentence alone, and earlier prose that
merely mentions being blocked no longer hijacks the parse (#184).
- `decide_state()` refuses `state:needs-human` while the hand-set `blocked`
label stands — the PR falls to `state:addressing`, exactly parallel to the
`needs-ruling` exclusion; never emitted by `blockers()` (#180).
## 0.3.0 — 2026-07-24 ## 0.3.0 — 2026-07-24
- Make `changelog-armed` reject fragment shape drift on the PR that introduces it. - Make `changelog-armed` reject fragment shape drift on the PR that introduces it.

View file

@ -35,9 +35,43 @@ Who may set which label is [LABELS.md](LABELS.md)'s contract.
## The PR flow ## The PR flow
PRs move through review rounds that builders answer whole, and only a human The same flow the sibling repos run, and the part of this pipeline that is
merges. [BUILDER.md](BUILDER.md) is the shared flow contract; this file names already proven:
only ceremony-specific facts such as the roster and code conventions.
1. **One issue, one PR**, opened as a **draft** while building, with
`Closes #N` in the body — its exceptions (cross-repo work, a post-merge
criterion) live in [BUILDER.md](BUILDER.md). Drafts are invisible to the
reviewer panel on purpose. Every behavior change writes one fragment,
`changelog.d/<issue>.md` — the exact prose to publish, nothing else
(cross-repo work names it `<repo>-<issue>.md`; a grouped repo puts its
`### Added` / `### Changed` / `### Fixed` headings inside the fragment).
Never edit `CHANGELOG.md` for an entry — the release PR assembles the
section from the fragments (#112).
The sole exception is the release PR: it writes no fragment. It consumes
the directory and stamps the section, so a fragment it created would be
absent from
[`changelog-assembled`](https://github.com/heavy-duty/ceremony/blob/a602fd0/actions/changelog-assembled/changelog-assembled.sh)'s
merge-base replay if consumed, or refused by
[`changelog-armed`](https://github.com/heavy-duty/ceremony/blob/a602fd0/actions/changelog-armed/changelog-armed.sh)
if left to survive into the next release. A change that must ship inside
the release PR therefore ships without an entry. If it can wait and wants
an entry, land it as an ordinary PR before the release PR, then rebase and
re-assemble the release.
2. **When it's ready**: mark ready-for-review and request the whole panel.
3. **Rounds are answered whole.** Wait until every reviewer has a verdict in,
then answer the entire round in a **single reply**, push the fixes, and
re-request the reviewers that didn't approve. Prefer verification over
argument: a test settles what a comment thread can't.
4. **Reviews end in a verdict** — approve or request-changes, never a bare
comment. The verdict carries blockingness only; the body carries the
feedback. ([REVIEWER.md](REVIEWER.md) for why a comment-only review stalls
the machine.)
5. **Handoff**: when the round passes — every panel verdict is an approval of
the current head and no `blocker:*` label stands — the author posts the
round summary, requests the human's review, and sets `state:needs-human`.
The label write is optimistic; the reconciler validates it within seconds.
6. **A human merges.** Nothing else merges.
### Roster ### Roster

312
FLEET.md
View file

@ -1,67 +1,54 @@
# FLEET.md — the fleet shape, and how it actually runs # FLEET.md — the roster, and how it actually runs
> **Status:** descriptive snapshot, not doctrine. This file records how the > **Status:** descriptive snapshot, not doctrine. This file records how the
> heavy-duty operator fleet is wired *today*. It is **not** part of the > agent fleet that builds this repo is wired *today*, so the setup can later be
> vendored doctrine set (`.ceremony/`) and is never mirrored to consumer > solidified into a replicable fleet-management solution. It is **not** part of
> repos. [Crew](https://github.com/heavy-duty/crew) is a general tool: its > the vendored doctrine set (`.ceremony/`) and is never mirrored to consumer
> repository ships the engine, while the fleet definition belongs to the > repos. The doctrine files (AGENTS.md, TRIAGE.md, BUILDER.md, REVIEWER.md,
> operator; heavy-duty is one operator of it. Membership, repository scope, > LABELS.md, CONTRIBUTING.md) say what roles *must* do; this file says how the
> agent-profile overrides and doctrine paths belong to that definition; > current bench *physically* does it. Last reconciled against the deployed
> membership itself lives outside every checkout. Crew's shipped defaults > duty scripts at
> name heavy-duty's AGENTS.md, TRIAGE.md, BUILDER.md and REVIEWER.md, but > [`heavy-duty/crew@b2fd864`](https://github.com/heavy-duty/crew/tree/b2fd8642e7f7aa8dc9de6b44edadbe1dc557b140)
> those are compatibility defaults, not vocabulary compiled into the engine > (private to the org; the fleet can read it), 2026-07-24 — a descriptive
> — operator `doctrine.conf` values can replace them. > file with no reconciliation stamp gives the next reader nothing to diff,
> The *mechanism* lives with crew and this file points at it. Last reconciled > which is exactly how the #149 drift went unnoticed.
> against the merged engine at
> [`heavy-duty/crew@eaeb302`](https://github.com/heavy-duty/crew/tree/eaeb3022aa47d90e797f2b9e007b831df7ca8406),
> 2026-07-28 — a descriptive file with no reconciliation stamp gives the next
> reader nothing to diff, which is exactly how the #149 drift went unnoticed.
## Fleet shape ## The roster
One box (an isolated, disposable VM) per GitHub identity. Boxes are credential One box (an isolated, disposable VM) per GitHub identity. Boxes are credential
boundaries; sessions inside a box are role boundaries. No box has an inbound boundaries; sessions inside a box are role boundaries. No box has an inbound
network path — GitHub is the only queue. Fleet membership is the operator's network path — GitHub is the only queue.
definition and lives outside every checkout; this file deliberately carries
no second roster.
Review panel per PR = the governed repo's `.github/labels.conf` `panel=` line | Identity | Box | CLI | Roles |
minus the PR's author, as [REVIEWER.md](REVIEWER.md) specifies (recusal by |---|---|---|---|
| `dan-claude-bot` | triage-box | Claude Code | **triage** — the only issue-minter |
| `claude-bot-andresmgsl` | claude-box | Claude Code | builder (hard machinery) + reviewer |
| `codex-bot-andresmgsl` | codex-box | Codex CLI | builder (mechanical) + reviewer |
| `grok-bot-andresmgsl` | grok-box | Grok CLI | reviewer |
| `kimi-bot-andresmgsl` | kimi-box | Kimi CLI | reviewer |
Review panel per PR = the reviewer bench minus the PR's author (recusal by
construction). Only humans merge — enforced as permissions (the agents team construction). Only humans merge — enforced as permissions (the agents team
holds the triage role, not write), not as convention. holds the triage role, not write), not as convention.
## Anatomy of a duty loop ## Anatomy of a duty loop
The mechanism is no longer described here. The five hand-rolled duty scripts Every box runs the same skeleton, adapted to its CLI:
converged into crew's shared engine, and a prose mirror of running code in a
second repo is a second thing to keep true — this one drifted (it said cron
ran `duty.sh` directly and gave the hygiene sweep its own cron line; crew's
`duty.sh` records that separate line as the bug it fixed, sharing
`~/duty/work` unlocked). How a tick actually works — cron fires
[`bin/tick.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/tick.sh),
the only cron target, which wraps
[`bin/duty.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/duty.sh)
in a non-blocking `flock` with one evidence line per boundary; the boot gate
and crash recovery; the session runner; backlog hygiene self-scheduling
inside the duty tick under the same lock — lives with the code:
[`shared/README.md`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/README.md)
is the map, provenance table included. Sessions stay disposable: durable work
state lives on the board (issues, PRs, labels) and in git branches, while the
engine keeps only operational evidence and deduplication state under
`~/duty`; detection is the engine's, judgment is the session's.
What belongs here is what a wake *means*: - **Tick:** cron `*/5` runs `~/duty/duty.sh` under a non-blocking `flock`; the
triage box adds an hourly hygiene sweep under its own lock. Holding the lock
- **The registry is the scope.** A box acts only on repos its operator is load-bearing: a tick that acquires it *knows* nothing else is running on
listed. Work it finds outside that scope is reported and never acted on; this identity.
the report is part of the boundary, because a bounded wake that goes quiet - **Poll:** the script reads `~/duty/repos.txt` and queries GitHub with `gh`
is indistinguishable from a broken one. Adding a repo is an **operator for work matching the box's role. Whose registry that file is depends on
decision**, never something a sweep makes by writing where nobody listed. the role: the triage box's `repos.txt` **is** its registry — adding a repo
The 2026-07-25 scope ruling (crew#16) closed the org-wide review and is adding a line — while a reviewer's registry is the org itself, and its
author-side write surface; the crew#66 attention ruling closed the last `repos.txt` is a backstop that cannot scope it (grok's copy says so in its
exemption. Crew's own first line).
[`examples/repos.txt` header](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/examples/repos.txt) - **Act:** when there is work, the script launches the box's CLI as a one-shot
is the pinned source for how that rule is implemented and reported. session with a role prompt; the session does the work via `gh` as the box's
own identity, then exits. Sessions are stateless and disposable — all state
lives on the board (issues, PRs, labels) and in git branches.
### Wake conditions ### Wake conditions
@ -75,27 +62,9 @@ sibling agent outranks self-directed continuation, and it is frequently the
very thing that unparks the work resume would otherwise pick up. The query is very thing that unparks the work resume would otherwise pick up. The query is
the authenticated-user endpoint — the authenticated-user endpoint —
`gh api "/issues?filter=assigned&state=open&labels=attention"` — one call, no `gh api "/issues?filter=assigned&state=open&labels=attention"` — one call, no
search index (the review queue below already records that the index lags) — search index (the reviewer trigger below already records that the index
and it **sees** repos outside the operator's registry, because that endpoint lags), and like the review-request trigger it reaches repos `~/duty/repos.txt`
takes no repo filter. does not name.
**Seeing is not acting, and that is a ruling** (crew#66, danmt, 2026-07-27).
The wake used to work every row it saw, which for a builder meant a clone and
the full worktree and round rule set against a repo no operator had listed —
write authority outside the registry, and the one hole left in the
containment story. Rows are now partitioned against the registry: inside it,
a session as before; outside it, reported and never acted on, exactly like an
out-of-scope review request or authored PR.
[`lib/duty-attention.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/lib/duty-attention.sh)
implements the partition and states the ruling in its header.
The cost was argued before the ruling rather than discovered after it: an
assignment plus a label **is** a targeted authorization, so a cross-repo
handoff now waits on an operator adding the repo, and the box most likely to
be handed work outside its beat is the one that goes quiet. That is why an
out-of-scope demand does not only reach `duty.log` — it pings the operator
over the same channel the boot gate uses. A bounded wake that failed silently
would trade an unbounded write surface for a broken channel to the human.
Each demand gets **exactly one session, and the ack bounds it**: the Each demand gets **exactly one session, and the ack bounds it**: the
session's first act, before any of the demanded work, is the pickup comment session's first act, before any of the demanded work, is the pickup comment
@ -103,13 +72,9 @@ plus removing the label — [the `attention`
contract's](https://github.com/heavy-duty/ceremony/blob/bce09aa7648dbd74b8e91b1d4fbc2fa8d145f705/LABELS.md#L143-L149) contract's](https://github.com/heavy-duty/ceremony/blob/bce09aa7648dbd74b8e91b1d4fbc2fa8d145f705/LABELS.md#L143-L149)
ack (#85), which here becomes the session's ack-then-act ordering. ack (#85), which here becomes the session's ack-then-act ordering.
Then it acts on the thread and exits — short by construction. Until the label Then it acts on the thread and exits — short by construction. Until the label
is removed the flag is still up, so a session that **dies** before acking is is removed the flag is still up, so a session that dies before acking is
simply relaunched at the next tick — the same crash-only shape as resume simply relaunched at the next tick; that is the whole crash-recovery story,
below. A session that **completes** without acking is a different fact: that and it is the same crash-only shape as resume below.
is a decline, and a seen-ledger stops it re-firing until the issue moves.
Dying and declining used to look identical to the engine, which meant a
demand a session had considered and correctly left alone woke a new one every
tick forever.
The design this replaces was built and rejected: polling notifications for The design this replaces was built and rejected: polling notifications for
`reason: mention` re-arms a thread on every comment, so ordinary round `reason: mention` re-arms a thread on every comment, so ordinary round
@ -120,91 +85,47 @@ wake: [#16's 16:49Z
ruling](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198) ruling](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
authorized the last open acceptance criterion on a `claimed` issue and sat authorized the last open acceptance criterion on a `claimed` issue and sat
unowned for over an hour — the box answered every state signal that day and unowned for over an hour — the box answered every state signal that day and
never saw the comment, and the eventual pickup ran on a manual bridge. The never saw the comment, and the eventual pickup ran on a manual bridge. Like
wake is no longer on paper: `duty-attention.sh` is deployed engine, and the notifier's queue below, this wake is the spec for a box-side change only
`duty.sh` runs it first on every box, whatever its roles. the operator can make; until `duty.sh` polls it, the wake exists on paper —
though one consumer already polls for the label and no-ops while it is
absent, so the wiring can be verified live the day the row lands.
The engine's duty order is fleet-standard - **Triage:** new discussions to mint from, builder questions on issues, stray
([`bin/duty.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/duty.sh)): issues to reconcile, `@`-mentions, hourly hygiene (stale claims, label
**attention → triage signals → review queue → resume → ci-red → build → invariants), and a `needs-ruling` standing **past 24h** — the ladder's last
handoff → rebase → worktree hygiene → backlog hygiene (hourly)** — attention rung makes the option triage's to pick, and this wake list is where triage
role-independent and first, then each duty family the box's roles enable. learns such an item exists (see the notifier section below).
Every position in that order is deployed engine at the stamped SHA: - **Builders**, in priority order: **resume** (an open draft PR of mine, or a
[crew#64](https://github.com/heavy-duty/crew/pull/64) merged ci-red between claimed issue with my `build/*` branch but no PR — possible only if a
resume and build, and `duty.sh`'s own header carries the same order. previous session died mid-work), a `ready` issue to claim, a completed
The earlier form of this file folded handoff and rebase into the other review round on my PR (act on whole rounds, never single verdicts), my PR
builder wakes; they are duties of their own. fully approved (write the closing summary, flip to `state:needs-human`,
request the human), my PR `CONFLICTING` (rebase; never act on `UNKNOWN`
- **Triage signals**, per registry repo: `needs-triage` issues, post-merge flap).
queue-unlabeled strays, discussions without triage's voice, unread - **Reviewers**, one candidate set from two merged sources. Source 1,
`@`-mentions (their own session), and `blocked` issues whose named blockers authoritative: every open PR in the `heavy-duty` org **plus the named bot
have all landed — a lead the session verifies, never a label the engine forks** that lists me in `requested_reviewers`, enumerated straight from
flips. Backlog hygiene (stale claims, label invariants) runs hourly, the pulls API — never `gh search`, whose index lags (cast#143,
self-scheduled inside the duty tick. A `needs-ruling` standing **past incubator#25 and box#164 each sat unreviewed behind it). A review request
24h** is still triage's to pick up — the ladder's last rung makes the is authorization, so no repo filter may gate it. Source 2, backstop: the
option triage's to choose — but, like the notifier queue below, that `repos.txt` poll for an open PR by someone else whose head I have not yet
detection row is on paper only today. reviewed — it only **adds** candidates the sweep may have missed (an
- **Review queue**: one candidate set, enumerated from the pulls pages of org-enumeration failure, say) and never concludes "nothing to do". The
every registry repo — object endpoints, never the search index for the sources are merged and deduplicated by (repo, PR) **before** acting, not
queue itself, whose lag left cast#143, incubator#25 and box#164 sitting run as sequential passes — the sequential shape double-announced on
unreviewed — filtered to PRs listing me in `requested_reviewers`, deduped ceremony#32, when the request sweep and the repo-list poll each acted on
by (repo, PR) before acting (the sequential shape double-announced on the same PR in one tick (operator protocol 2026-07-23) — and worked
ceremony#32), and worked oldest-first. One search-backed **awareness pass** oldest-first. Unchanged: one verdict per head, deduplicated against my
per tick reports requests outside the registry and never acts on them — own latest review's SHA.
the scope rule above. One verdict per head, deduplicated against my own
latest review's SHA; a re-request at an unchanged head is answered with an
auto-approve through the verdict gate rather than left as a stale blocker
(operator ruling 2026-07-23, ceremony#94).
- **Resume** (builders, checked before build): an open draft PR of mine, or
a `claimed` issue whose `build/*` branch exists on my fork with no open PR
— a session died between first push and PR creation. A branch whose PR
already **merged** is a post-merge wait, never resumed (#172,
incubator#55/#64).
- **ci-red** (builders): a non-draft PR of mine whose check at the current
head is failing. Evaluated before the build wake, so a red PR of mine
outranks a new claim — repairing my own red head comes ahead of new work
(ceremony#163: full-panel approvals at the head, mergeable, stranded on
a transient failure no wake covered). A round owed at a red head is
excluded from the build wake below but reported rather than silent, and
an unchanged red head goes quiet after one attempt, through the
`report_suppressed` path — suppressed, still said. A check that has not
finished is **not** a red head and wakes nothing here: nothing has failed
yet, so there is no investigation to launch. How a red head is detected
and kept quiet is the engine's mechanism, described in crew's
`shared/README.md`, not here.
- **Build**: a `ready` **unclaimed** issue (an assignee means mid-claim, not
pickable), or a completed review round on my PR — a changes-request with
no panel review request still outstanding; whole rounds, never single
verdicts, and never a round the check at its head does not support. The
wake admits a **green** head, and a head with **no checks configured**
terminal, not transient, so holding there would retire the round rather
than delay it. It holds a **red** head (already woken ci-red above) and a
head whose check has **not finished** (opening the round there spends the
panel on a head that may go red — crew#45's measured cost — and it admits
itself a tick later once the check settles). Both holds are reported, not
swallowed, and they are reported *differently*: only one of them is the
author's own work to do.
- **Handoff**: a round of mine that converged — every panelist's latest
opinionated review approves the current head, no panel request
outstanding, mergeable right now, `state:needs-human` not already set.
Convergence is computed from `latestOpinionatedReviews`, never
`reviewDecision`, which stays empty without branch protection and silently
stalled rounds for a day (ceremony#26, #39).
- **Rebase**: my PR `CONFLICTING` — and only `CONFLICTING`; `UNKNOWN` is
GitHub's post-merge recompute flap and waits. A conflicting draft belongs
to resume.
- **Worktree hygiene**: a `build/*` worktree is removed only when its branch
has PR history and no PR on it remains open; a branch with no PR at all is
an in-flight claim and stays.
#### The operator notifier — the `needs-ruling` queue #### The operator notifier — the `needs-ruling` queue
The operator notifier (`notify.sh`, a fleet singleton on the triage box; its The operator notifier (`notify.sh`, on the triage box) watches open PRs
mechanism is crew's too) watches open PRs carrying `state:needs-human`. That carrying `state:needs-human`. That poll never reads `needs-ruling`, which
poll never reads `needs-ruling`, which lives mostly on *issues* — so an lives mostly on *issues* — so an escalation waits invisibly on the very human
escalation waits invisibly on the very human it names. Not hypothetical: on it names. Not hypothetical: on 2026-07-23 alone, three escalations spent
2026-07-23 alone, three escalations spent their whole lives outside the their whole lives outside the operator's view — [#16's fork-PR-workflows
operator's view — [#16's fork-PR-workflows
question](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5053302689) question](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5053302689)
(raised 01:23Z, [ruled 09:24Z](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5056705884) (raised 01:23Z, [ruled 09:24Z](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5056705884)
— eight hours in which the board showed a `claimed` issue indistinguishable — eight hours in which the board showed a `claimed` issue indistinguishable
@ -214,12 +135,11 @@ and [epic #50's own 13:04Z
flag](https://github.com/heavy-duty/ceremony/issues/50#issuecomment-5058713181), flag](https://github.com/heavy-duty/ceremony/issues/50#issuecomment-5058713181),
which surfaced only because a human happened to look. This file records how which surfaced only because a human happened to look. This file records how
the fleet actually runs; that is why this wiring changed (#50 D16). The spec the fleet actually runs; that is why this wiring changed (#50 D16). The spec
for the engine-side update: for the box-side update:
- **The second query.** Alongside the `state:needs-human` PR poll, `notify.sh` - **The second query.** Alongside the `state:needs-human` PR poll, `notify.sh`
polls **open issues and PRs labelled `needs-ruling`** across every repo in polls **open issues and PRs labelled `needs-ruling`** across every repo in
`notify-repos.txt`, which is deliberately wider than the duty registry: `~/duty/repos.txt`.
a cross-repo handoff is precisely what the operator cannot discover alone.
- **One tracked message per item, edited in place** — the same - **One tracked message per item, edited in place** — the same
one-message-per-item discipline the PR poll already uses, so an aging one-message-per-item discipline the PR poll already uses, so an aging
ruling reads as a **live queue**, not a feed. The message is removed when ruling reads as a **live queue**, not a feed. The message is removed when
@ -244,46 +164,54 @@ Nothing box-side ever sets, clears, or decides `needs-ruling` (#50 D9, D15):
the notifier and triage's past-24h wake above *report and pick up* what the the notifier and triage's past-24h wake above *report and pick up* what the
board already shows; the label itself moves only by the doctrine's hands. board already shows; the label itself moves only by the doctrine's hands.
The duty engine is crew's shared tree, one source deployed to every box. `~/duty/repos.txt` and the duty scripts live inside each box and are the
Specs written in this file have a record of becoming engine: the attention operator's to change; this descriptive edit is the spec for those box-side
wake and the reviewers' request sweep both started here as paper (the sweep's updates. The reviewers' request sweep is one such spec made real — deployed
org-wide form was then retired by the 2026-07-25 scope ruling), and the on all four reviewer boxes since 2026-07-23 (the Reviewers wake above). The
builders' ci-red wake above is the latest: written here as paper while notifier's `needs-ruling` queue is still on paper only: `notify.sh`'s one
crew#64 was open, engine at the stamped SHA. Two rows are still on paper, and label filter today is `state:needs-human`.
both are `needs-ruling`: the notifier's queue — at that SHA, `notify.sh`'s
only label filter is `state:needs-human` — and triage's **past 24h** ### Resilience
detection row above, which the triage-signals bullet already marks. Earlier
counts here said "two" while silently excluding the second; naming them is - **Boot gate:** each tick compares the kernel boot id
cheaper than a number that has to be recounted every time a wake lands. (`/proc/sys/kernel/random/boot_id`) to a stored marker. First tick after any
reboot runs credential + disk probes; the marker is written only when auth
actually works, so a box with dead credentials re-checks loudly every tick
instead of silently skipping duty.
- **Crash-only resume:** there is no session state to restore. The recovery
path *is* the normal path: the resume wake condition reads the board, posts
`⟲ resuming from <sha>`, and continues from the worklog. Rebooting a box
never loses work that was pushed.
- **Checkpoint discipline (builders):** open the PR as draft at the first
commit with a `## Worklog` checkbox list; check off and push after every
step. The board and the branch are the only memory.
- **Worktree isolation:** builders build each PR in its own `git worktree`;
reviewers check out PR heads in throwaway detached worktrees and remove them
after the verdict. Main clones stay parked on the default branch, always
clean; stale worktrees are pruned by the boot gate.
### Conventions on the board ### Conventions on the board
- `🔎 reviewing head <sha>` — a reviewer announces work before starting, so - `🔎 reviewing head <sha>` — a reviewer announces work before starting, so
liveness is visible instead of hoped for. liveness is visible instead of hoped for.
- `⟲ resuming from <sha>` — a builder announces recovery after interruption; - `⟲ resuming from <sha>` — a builder announces recovery after interruption.
there is no session state to restore, so the recovery path *is* the normal
path: read the board, continue from the worklog. Rebooting a box never
loses work that was pushed.
- Checkpoint discipline (builders): open the PR as draft at the first commit
with a `## Worklog` checkbox list; check off and push after every step.
The board and the branch are the only memory.
- Claim ritual: comment on the issue + self-assign + label flip, before any - Claim ritual: comment on the issue + self-assign + label flip, before any
branch exists. branch exists.
- Handoff: the author closes an approved PR's round with a summary comment, - Handoff: the author closes an approved PR's round with a summary comment,
flips `state:needs-human`, and requests the human — merging is never the flips `state:needs-human`, and requests the human — merging is never the
fleet's job. fleet's job.
- Worktree isolation: builders build each PR in its own `git worktree`;
reviewers check out PR heads in throwaway detached worktrees and remove
them after the verdict. Main clones stay parked on the default branch,
always clean.
## Where this is going ## Where this is going
This wiring proved itself on day one (seven merged PRs, unanimous three-model This wiring proved itself on day one (seven merged PRs, unanimous three-model
review convergence on #39, and a full-fleet crash recovery), and the plan it review convergence on #39, and a full-fleet crash recovery). The plan:
carried has become **heavy-duty/crew** — a shared engine, CLI, operator
configuration model, real-host rehearsal and fixture tests — so standing up 1. Once the ceremony machinery is complete and adopted, each agent will be
a fleet is a bootstrap, not an archaeology dig. What remains is adoption: asked to write a **detailed, replicable description of its own setup**
crew#85 tracks the road to a `0.1.0` another operator can use without a fork. cron lines, duty script, prompts, probes — as durable documentation.
Membership stays in the operator definition; this file remains the map of 2. Those five descriptions get converged into a **solidified fleet-management
what a wake means, and crew is the map of how it runs. solution** (duty loops as reusable templates, likely living alongside the
rig templates registry), so standing up this roster on a new repo — or a
whole new fleet — is a bootstrap, not an archaeology dig.
Until then, this file is the map.

View file

@ -3,8 +3,7 @@
The taxonomy shared across the heavy-duty repos. Only the `scope:` set The taxonomy shared across the heavy-duty repos. Only the `scope:` set
differs per repo (each repo's `.github/labels.conf` names its actual differs per repo (each repo's `.github/labels.conf` names its actual
surfaces); everything else below is core and identical everywhere, created by surfaces); everything else below is core and identical everywhere, created by
the labels workflow's dispatch (which is also the operator's manual the labels workflow's bootstrap dispatch (issue #10).
full-board reconcile sweep; issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the box/rig/cast, reconciled by machinery) and the **issue flow** (the
@ -212,13 +211,10 @@ on a PR would say the same thing twice and drift.
## Maintenance ## Maintenance
The labels workflow (issue #10) recomputes PR state statelessly on subscribed The labels workflow (issue #10) recomputes PR state statelessly on PR events
events plus a consumer-owned scheduled discovery sweep. Hourly is the plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
recommended default when no other engine drives board state; relax it only as manual dispatch. The sweep warns when the core taxonomy declares a label the
the transition classes with no other writer shrink. Manual dispatch both repository lacks. The same workflow reconciles issue-flow labels on issue
bootstraps this taxonomy idempotently and runs the operator's on-demand
full-board reconcile. The sweep warns when the core taxonomy declares a label
the repository lacks. The same workflow reconciles issue-flow labels on issue
events and during the scheduled sweep. Default GitHub labels (`duplicate`, events and during the scheduled sweep. Default GitHub labels (`duplicate`,
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are `invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
deleted at bootstrap — a `question` is a discussion, not an issue. deleted at bootstrap — a `question` is a discussion, not an issue.

View file

@ -1 +1 @@
0.4.1-dev 0.3.1-dev

7
changelog.d/167.md Normal file
View file

@ -0,0 +1,7 @@
### Added
- `changelog_fragment_problem` bounds every entry at 300 normalized
characters, red on the PR that writes the fragment; the armed guard and
the assembler inherit the one definition (#167).
- BUILDER.md and CHANGELOG.md state the bound and the split rule: a long
change ships several short entries, never one long one (#167).

3
changelog.d/173.md Normal file
View file

@ -0,0 +1,3 @@
### Changed
- Private-repository label callers document `actions: read` alongside checks and statuses for workflow-run check-rollup nodes (#173).

3
changelog.d/175.md Normal file
View file

@ -0,0 +1,3 @@
### Added
- Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification.

5
changelog.d/178.md Normal file
View file

@ -0,0 +1,5 @@
### Changed
- BUILDER.md: a park declaration stands until its facts change — a
nothing-changed resumption posts nothing; only a no-open-PR park owes a
refresh, inside the 48-hour reclaim window (#178).

5
changelog.d/180.md Normal file
View file

@ -0,0 +1,5 @@
### Fixed
- `decide_state()` refuses `state:needs-human` while the hand-set `blocked`
label stands — the PR falls to `state:addressing`, exactly parallel to the
`needs-ruling` exclusion; never emitted by `blockers()` (#180).

10
changelog.d/182.md Normal file
View file

@ -0,0 +1,10 @@
### Added
- `changelog.d/shape` — an optional one-line sentinel, `flat` or `grouped`,
that pins the fragment set's shape and outranks the newest-published-section
inference; absent, the inference binds unchanged (#182).
### Changed
- Ceremony's changelog is grouped from this release forward: the pending
fragments carry `### ` headings under a `grouped` sentinel (#182).

6
changelog.d/184.md Normal file
View file

@ -0,0 +1,6 @@
### Fixed
- `blocked_reference_records` unions every `Blocked by` clause in the body
instead of binding to the first marker occurrence — a repeated declaration
no longer promotes on its first sentence alone, and earlier prose that
merely mentions being blocked no longer hijacks the parse (#184).

View file

@ -122,8 +122,7 @@ the machinery at all:
[Labels automation](#labels-automation), plus `.github/labels.conf` [Labels automation](#labels-automation), plus `.github/labels.conf`
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the (panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
path→scope globs). Run `workflow_dispatch` once — **this bootstraps path→scope globs). Run `workflow_dispatch` once — **this bootstraps
the taxonomy, `release` label included** — and use it again whenever an the taxonomy, `release` label included**.
operator needs a full-board sweep immediately.
7. **The artifact hook** (optional): `.github/actions/release-artifact/` 7. **The artifact hook** (optional): `.github/actions/release-artifact/`
per [The artifact hook](#the-artifact-hook). No hook → the source per [The artifact hook](#the-artifact-hook). No hook → the source
tarball is the package. tarball is the package.
@ -297,39 +296,20 @@ The complete caller is:
```yaml ```yaml
name: labels name: labels
on: on:
# The consumer owns this cadence (#203). Hourly is the recommended default schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
# when no other engine drives board state: the cron is then the sweep's only workflow_dispatch: # bootstraps missing labels on a fresh repo
# wake for four transition classes — a review verdict landing (no
# pull_request_review trigger), blocker:ci-red set/cleared, blocker:conflict
# when another PR merges under this one, and time-based stale / 48h
# claim-reclaim. Events below carry the rest in seconds. Hourly trades ≤1h of
# latency on those four while cutting nominal scheduled sweeps from four an
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their
# discovery path. If another engine writes some of those transitions, only
# the classes with no other writer bound the cadence; relax it only as that
# list shrinks.
schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep, including taxonomy bootstrap on a fresh repo.
workflow_dispatch:
pull_request_target: pull_request_target:
# Fork PRs; these carry the head/draft/review facts state:* derives from. # review_requested/review_request_removed are unreleased — not in 0.2.0;
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here); # add them with the pin bump to the first tag carrying ceremony#137. They
# synchronize re-derives on every push. review_requested/review_request_removed # wake the sweep that clears blocker:unrequested when the panel is asked.
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears
# blocker:unrequested when the panel is asked.
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed] types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a # Available at 0.2.0 and later (the first tag carrying ceremony#32); a
# consumer pinned to 0.1.0 omits this block. # consumer pinned to 0.1.0 omits this block.
issues: issues:
# Narrowed (#199) to the actions carrying a queue-state change the hourly # edited/reopened are unreleased — not in 0.2.0; add them with the pin
# cron cannot wait one cadence for: opened → the mint→needs-triage check, # bump to the first tag carrying ceremony#144, the same bump as the
# closed → the blocker-closes→ready self-heal, edited → a body rewrite of the # pull_request_target additions above.
# `Blocked by #N` declaration the sweep parses, reopened → a closed issue types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
# re-entering the queue. Dropped: labeled/unlabeled/assigned/unassigned —
# validation + the 48h claim clock, caught within one cadence, and
# labeled/unlabeled were the issues-churn source. The handoff wake is
# pull_request_target:labeled, not issues, so this leaves it intact.
types: [opened, closed, edited, reopened]
permissions: permissions:
contents: read contents: read
checks: read # mergeability/check-rollup read for PR state checks: read # mergeability/check-rollup read for PR state
@ -350,32 +330,23 @@ needs all three explicit reads above; without them the failure appears as an emp
The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the
first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt
it only by bumping every ceremony reference to `0.2.0` or later; never mix it only by bumping every ceremony reference to `0.2.0` or later; never mix
refs to adopt it early. The type list has grown then narrowed across tags: refs to adopt it early. At `0.2.0` the type list stops at `closed`: `edited`
`0.2.0` (ceremony#32) shipped `[opened, labeled, unlabeled, assigned, and `reopened` are newer, part of the pending stub edit below.
unassigned, closed]`; `0.3.0` (ceremony#144) added `edited` and `reopened`;
ceremony#199 narrows it to `[opened, closed, edited, reopened]` and relaxes the
cron to hourly, so a consumer picks up the smaller trigger surface at the pin
bump to the first tag carrying ceremony#199. The narrowing drops
`labeled`/`unlabeled`/`assigned`/`unassigned` — validation and the 48h claim
clock, which the hourly cron catches within one cadence, and `labeled`/
`unlabeled` were the issues-churn source — while **keeping** #144's `edited`/
`reopened`: those carry a queue-state change an event uniquely carries (a body
rewrite of `Blocked by #N`, and a closed issue re-entering the queue), so the
must-fail in ceremony#199 keeps them on events. `opened` drives the
mint→`needs-triage` check and `closed` the blocker-closes→`ready` self-heal;
the stub and ceremony's own caller stay byte-for-byte identical, the parity
#144 established.
`pull_request_target` is intentional: fork PRs need the base repository's `pull_request_target` is intentional: fork PRs need the base repository's
token to write labels. The reusable workflow executes no PR code. It checks token to write labels. The reusable workflow executes no PR code. It checks
out only the consumer's base branch and the pinned ceremony implementation. out only the consumer's base branch and the pinned ceremony implementation.
The #52 ruling invariants ride exactly these triggers — but the caller above The #52 ruling invariants ride exactly these triggers — but the caller above
is no longer the #18 shape, so adopting current triggers is a stub edit, not is no longer the #18 shape, so adopting current triggers is a stub edit, not
a bare pin bump. `review_requested` and `review_request_removed` on a bare pin bump. The pending edits are `review_requested` and
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that `review_request_removed` on `pull_request_target:` (#137) — the wake that
clears `blocker:unrequested` the moment the panel is asked, without which a clears `blocker:unrequested` the moment the panel is asked, without which a
quiet repo wears that flag until the backstop cron; a consumer picks them up quiet repo wears that flag until the advisory cron — and `edited` and
by pinning `0.3.0` or later, never through mixed refs. `reopened` on `issues:` (#144): the wakes for a body edit rewriting the
`Blocked by #N` declaration the reconcile sweep parses, and for a closed
issue re-entering the queue wearing labels derived when it closed. Make both
edits together, with the pin bump to the first tag carrying ceremony#137 and
ceremony#144 — never before it and never through mixed refs.
`.github/labels.conf` has one mandatory panel setting, one mandatory `.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, and then zero or more scope rows: `triage-actors` setting, and then zero or more scope rows:
@ -404,20 +375,11 @@ Core state, blocker, work-queue, and release labels come from ceremony. Scope
rows remain consumer-owned because paths and surfaces differ by repository. rows remain consumer-owned because paths and surfaces differ by repository.
After adding the caller and configuration, run `workflow_dispatch` once to After adding the caller and configuration, run `workflow_dispatch` once to
bootstrap labels on a fresh repository. It is also the operator's general bootstrap labels on a fresh repository. Scheduled and PR-triggered runs only
manual full-board sweep — the answer when the board looks wrong now rather reconcile; they do not repeatedly upsert the taxonomy. When a ceremony pin
than after the next scheduled cadence: bump adds a core label, bump the pin first and then re-dispatch
`workflow_dispatch`; the scheduled sweep warns when the pinned taxonomy
```sh declares a core label the repository lacks.
gh workflow run labels.yml -R <owner>/<repo>
```
Ceremony dogfoods the caller under the filename `self-labels.yml`, so the
equivalent command in this repository substitutes that filename. Scheduled
and PR-triggered runs only reconcile; they do not repeatedly upsert the
taxonomy. When a ceremony pin bump adds a core label, bump the pin first and
then re-dispatch `workflow_dispatch`; the scheduled sweep warns when the
pinned taxonomy declares a core label the repository lacks.
## Doctrine mirror ## Doctrine mirror

View file

@ -1,50 +0,0 @@
# 0.4.0 — drill record
Run 2026-07-29 by `codex-bot-andresmgsl` against release PR #207 head
`7c755bcd402ba7f9a38ecd406a025c149c77aa57`.
Where: disposable private repo
`codex-bot-andresmgsl/ceremony-drill-0.4.0`, carrying the
`docs/CONSUMERS.md` release caller and a fragment-mode fixture armed at
`0.4.0-dev`. The fixture had `changelog.d/README.md`, one release fragment,
and a non-blank drill record. The repository is archived, pending the
operator's delete.
## Candidate-ref deviation
The pure consumer path cannot resolve `CEREMONY_SELF_REF: "0.4.0"` before
the candidate creates that tag. No `0.4.0` branch was created in
`heavy-duty/ceremony`. The scratch caller instead used
`codex-bot-andresmgsl/ceremony@drill/0.4.0`, whose parent is PR #207 head
`7c755bcd402ba7f9a38ecd406a025c149c77aa57` and whose only additional
commit rewrites both `CEREMONY_SELF_REF` carriers to that same canonical
candidate SHA. All runtime machinery was therefore fetched from the 0.4.0
candidate tree.
## Probes
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 30445585532 (attempt 1) | ✅ one `0.4.0` release; tag equals merge commit; main re-armed to `0.4.1-dev` with only `changelog.d/README.md` |
| 2 | mislabeled ordinary PR | 30445473977 | ✅ green NOTICE no-op; no tag or release |
| 3 | bare-version PR without `release` | 30445513860 | ✅ refused at decide; no tag or release |
| 4 | re-run completed ceremony | 30445585532 (attempt 2) | ✅ refused because the tag/release already existed; release count stayed one |
| 5 | manual matching tag | 30445658952 | ✅ `0.5.0` published with its changelog section; main untouched |
| 6 | mismatched tag | 30445684268 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterward |
The merge-door `0.4.0` tag and PR #3 merge commit were both
`2b2d592ffcc5c376e55bd5fcf2dd5ffbdd692d64`. Its release body was exactly:
```text
- Fragment mode is exercised by the 0.4.0 drill.
```
## Setup
The armed fixture was committed before the caller so the first workflow run
had a real parent version to inspect. Installing the caller then produced
green baseline run 30445432039.
After the unlabeled bare-version refusal, its merge commit was reverted to
restore the armed fixture. That setup correction produced green no-op run
30445544068 before the merge-door probe began.

View file

@ -1,98 +0,0 @@
#!/usr/bin/env bash
set -u
# The labels TRIGGER SURFACE is a cost lever (#199): a full-board sweep is
# billed a 1-minute minimum every time a trigger fires, so how OFTEN it fires
# is what exhausted the fleet's shared Actions allotment. These assertions
# pin the reductions #199 made and the guard it must not trade away — none of
# them touch the reconciler's LOGIC, which its own fixtures cover.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh"
REUSABLE="$ROOT/.github/workflows/labels.yml"
SELF="$ROOT/.github/workflows/self-labels.yml"
STUB="$ROOT/docs/CONSUMERS.md" # the published caller stub, a fenced yaml block
# The `cancel-in-progress:` value of a named top-level job, read from the first
# such line inside that job's block. Job keys sit at two-space indent.
job_cancel_in_progress() { # $1 = file, $2 = job name
awk -v job="^ $2:\$" '
$0 ~ job { f = 1; next }
f && /^ [a-z]/ { exit } # next job — stop before leaking into it
f && /cancel-in-progress:/ { sub(/.*cancel-in-progress:[[:space:]]*/, ""); print; exit }
' "$1"
}
# The `types:` list of a trigger key (issues:, pull_request_target:), read from
# the first `types:` line after the bare key. The key is bare (nothing after
# the colon) so it never collides with `issues: write` in the permissions block.
trigger_types() { # $1 = file, $2 = trigger key
awk -v key="^ $2:\$" '
$0 ~ key { f = 1; next }
f && /^ types:/ { sub(/^ types:[[:space:]]*/, ""); print; exit }
f && /^ [a-z]/ { exit }
' "$1"
}
# ---- the guard the cost fix must never trade away (#199 test plan must-fail) --
# cancel-in-progress: true on reconcile kills a sweep mid-board, the exact race
# the shared concurrency group exists to prevent. It WOULD cut run count — by
# trading correctness for minutes — so it stays false, forever.
check "reconcile serializes, never cancels mid-board" 0 "false" \
job_cancel_in_progress "$REUSABLE" reconcile
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check "reconcile is never cancel-in-progress: true" 1 "" \
bash -c 'job_cancel_in_progress() {
awk -v job="^ reconcile:\$" "\$0 ~ job{f=1;next} f&&/^ [a-z]/{exit} f&&/cancel-in-progress:/{sub(/.*cancel-in-progress:[[:space:]]*/,\"\");print;exit}" "$1"
}; [ "$(job_cancel_in_progress "$1")" = true ]' _ "$REUSABLE"
# scope MAY cancel — it is per-PR and additive, so a superseded run is waste,
# not a lost sweep. This asserts the must-fail above is scoped to reconcile.
check "scope stays cancel-in-progress: true (per-PR, additive)" 0 "true" \
job_cancel_in_progress "$REUSABLE" scope
# ---- the cron is a backstop, relaxed to hourly (#199 candidate 1) -----------
# Scope the */15 assertion to the cron LINE — the prose comments cite */15 by
# name to explain the change, and must not re-red their own documentation.
check "self caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF"
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
check "self caller cron line no longer fires */15" 1 "" \
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF"
check "stub cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$STUB"
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
check "stub cron line no longer fires */15" 1 "" \
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$STUB"
# ---- issues: is narrowed to the queue-state-changing actions (#199) ----------
# Kept because each carries a queue-state change an event uniquely carries, so
# dropping it would trip #199's must-fail (a transition waiting on the schedule
# when an event could have carried it): opened → mint→needs-triage; closed →
# blocker-closes→ready self-heal; edited → a body rewrite of the `Blocked by #N`
# declaration the sweep parses; reopened → a closed issue re-entering the queue.
# (labels.test.sh owns the exact-list and caller<->stub parity assertions.)
for keep in opened closed edited reopened; do
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check "self caller issues surface keeps '$keep'" 0 "" \
bash -c 'trigger_types() {
awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1"
}; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$keep"
done
# The churn actions must not reappear on the issues surface without a fresh why.
# labeled/unlabeled were the dominant issues-churn source; assigned/unassigned
# only feed validation and the 48h claim clock, caught within one cadence.
for churn in labeled unlabeled assigned unassigned; do
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check "self caller issues surface drops '$churn'" 1 "" \
bash -c 'trigger_types() {
awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1"
}; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$churn"
done
# ---- the PR handoff wake is NOT collateral of the issues narrowing ----------
# The handoff (state:needs-human, confirmed by the caller's labeled event) rides
# pull_request_target, not issues. A future edit that strips it there re-reds.
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
trigger_types "$SELF" pull_request_target
summary

View file

@ -69,16 +69,15 @@ check "LABELS.md enumerates no repo's scope labels" 1 "0" \
# same lists. review_requested/review_request_removed are the wake that # same lists. review_requested/review_request_removed are the wake that
# clears blocker:unrequested — the label sat false for as long as a quiet # clears blocker:unrequested — the label sat false for as long as a quiet
# repo stayed quiet because the one event that falsifies it was never # repo stayed quiet because the one event that falsifies it was never
# listed (#137). The issues list narrowed to [opened, closed, edited, reopened] # listed (#137). edited/reopened are the wakes for the two events that
# (#199): each carries a queue-state change the hourly cron cannot wait one # falsify issue labels silently — an edited body rewrites the `Blocked by
# cadence for — opened drives mint→needs-triage, closed the blocker-closes→ready # #N` declaration the reconcile sweep parses, and a reopened issue
# self-heal, edited a body rewrite of the `Blocked by #N` line the sweep parses, # re-enters the queue wearing labels derived at close; PR #32 widened the
# reopened a closed issue re-entering the queue — while the churn/validation # caller by both and the stub never followed (#144). The stub is prose, so
# actions (labeled/unlabeled/assigned/unassigned) came off. The stub is prose, so nothing but these rows # nothing but these rows keeps the lists from drifting: a type in one file
# keeps the lists from drifting: a type in one file only is a wake that fires # only is a wake that fires at home and nowhere in the fleet, or the
# at home and nowhere in the fleet, or the reverse — the drift #144 caught. # reverse. The NF guard keeps `issues: write` under permissions: from
# The NF guard keeps `issues: write` under permissions: from matching the # matching the issues: trigger key.
# issues: trigger key.
event_types() { # $1 = file, $2 = trigger key → that trigger's types line, unindented event_types() { # $1 = file, $2 = trigger key → that trigger's types line, unindented
awk -v key="$2:" '$1 == key && NF == 1 {f=1; next} f && /types: /{sub(/^ */,""); print; exit}' "$1" awk -v key="$2:" '$1 == key && NF == 1 {f=1; next} f && /types: /{sub(/^ */,""); print; exit}' "$1"
} }
@ -89,20 +88,17 @@ types_in_sync() { # $1 = trigger key, $2 = caller, $3 = stub → 0 when both lis
} }
CALLER="$ROOT/.github/workflows/self-labels.yml" CALLER="$ROOT/.github/workflows/self-labels.yml"
STUB="$ROOT/docs/CONSUMERS.md" STUB="$ROOT/docs/CONSUMERS.md"
# event_types anchors on the bare trigger key (NF == 1), so it reads the real
# types line even though the #199 comments name pull_request_target: and
# issues: in prose above the keys — an inline /pull_request_target:/ scan would
# latch onto the first mention and read the wrong list.
pr_has_both_review_wakes() {
event_types "$CALLER" pull_request_target | grep -F review_requested | grep -qF review_request_removed
}
check "caller and stub pull_request_target lists are identical" 0 "" \ check "caller and stub pull_request_target lists are identical" 0 "" \
types_in_sync pull_request_target "$CALLER" "$STUB" types_in_sync pull_request_target "$CALLER" "$STUB"
check "the caller lists both review-request wakes" 0 "" pr_has_both_review_wakes # shellcheck disable=SC2016 # expansion belongs to the nested bash
check "the caller lists both review-request wakes" 0 "" bash -c \
'awk "/pull_request_target:/{f=1; next} f && /types: /{print; exit}" "$1" |
grep -F review_requested | grep -qF review_request_removed' _ "$CALLER"
check "caller and stub issues lists are identical" 0 "" \ check "caller and stub issues lists are identical" 0 "" \
types_in_sync issues "$CALLER" "$STUB" types_in_sync issues "$CALLER" "$STUB"
check "the caller lists exactly the queue-state-changing issue types" 0 \ check "the caller still lists all eight issue types" 0 \
"types: [opened, closed, edited, reopened]" event_types "$CALLER" issues "types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]" \
event_types "$CALLER" issues
# the failing cases: drop a type from either file, or reorder one list only, # the failing cases: drop a type from either file, or reorder one list only,
# and the identity rows above go red — exercised here on mutated copies # and the identity rows above go red — exercised here on mutated copies
mut_caller="$TMP/mut-caller.yml" mut_stub="$TMP/mut-stub.md" mut_caller="$TMP/mut-caller.yml" mut_stub="$TMP/mut-stub.md"
@ -116,13 +112,13 @@ sed 's/review_requested, review_request_removed/review_request_removed, review_r
"$STUB" >"$mut_stub" "$STUB" >"$mut_stub"
check "a reorder in one list only goes red" 1 "" \ check "a reorder in one list only goes red" 1 "" \
types_in_sync pull_request_target "$CALLER" "$mut_stub" types_in_sync pull_request_target "$CALLER" "$mut_stub"
sed 's/, closed//' "$CALLER" >"$mut_caller" sed 's/, edited//' "$CALLER" >"$mut_caller"
check "an issue type dropped from the caller goes red" 1 "" \ check "an issue type dropped from the caller goes red" 1 "" \
types_in_sync issues "$mut_caller" "$STUB" types_in_sync issues "$mut_caller" "$STUB"
sed 's/, closed//' "$STUB" >"$mut_stub" sed 's/, edited//' "$STUB" >"$mut_stub"
check "an issue type dropped from the stub goes red" 1 "" \ check "an issue type dropped from the stub goes red" 1 "" \
types_in_sync issues "$CALLER" "$mut_stub" types_in_sync issues "$CALLER" "$mut_stub"
sed 's/opened, closed/closed, opened/' "$STUB" >"$mut_stub" sed 's/closed, reopened/reopened, closed/' "$STUB" >"$mut_stub"
check "an issue-list reorder in one file only goes red" 1 "" \ check "an issue-list reorder in one file only goes red" 1 "" \
types_in_sync issues "$CALLER" "$mut_stub" types_in_sync issues "$CALLER" "$mut_stub"