From 8841d9711f2a45bd937d26e2cedc368c975990f1 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 1 Aug 2026 16:12:17 +0000 Subject: [PATCH 001/162] fix: checks_state never grades the label machine's own runs (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared reconcile concurrency group displaces queued sweeps as CANCELLED, and the displaced run's successor attaches to a different PR — so on the victim the newest self entry stayed CANCELLED, scored FAILURE, and the sweep set blocker:ci-red off its own corpse every cadence (crew#227). Drop rollup entries whose workflowName matches SELF_WORKFLOW (defaulting to the ambient GITHUB_WORKFLOW — the caller's name, so no workflow edit and no hardcoded consumer name) before the newest-per-context collapse; an empty name filters nothing. A self-only rollup now honestly scores NONE, and a genuine foreign failure still blocks beside a cancelled self entry — the must-fail guard against re-opening #136. Co-Authored-By: Claude Fable 5 --- actions/labels-reconcile/labels-reconcile.sh | 35 ++++++++++++++++- changelog.d/208.md | 11 ++++++ test/labels-reconcile.test.sh | 40 ++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 changelog.d/208.md diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 9e7d337..86ca3ab 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -49,6 +49,15 @@ LABELS="" # retirement heals the board instead of stranding a label nothing recomputes. RETIRED=(state:needs-rebase) STALE_AFTER=$((48 * 3600)) +# The workflow whose runs checks_state must never grade — its own (#208). +# GITHUB_WORKFLOW is ambient in every Actions step and names the CALLER (the +# consumer's PR-facing workflow, since consumers name the caller), so this +# self-serves with no workflow-file change. The explicit override exists for +# two readers: the fixtures, and #209's detached sweep caller, which will +# need to point this at the PR-facing caller's name once reconcile no longer +# runs inside it. Empty means "filter nothing" — a caller outside Actions +# (a local rehearsal, an older pin) must not silently start dropping entries. +SELF_WORKFLOW="${SELF_WORKFLOW:-${GITHUB_WORKFLOW:-}}" # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=lib/ruling.sh @@ -221,7 +230,27 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | # a context whose entries are ALL cancelled never reported at all (a killed # or timed-out required job), so it keeps CANCELLED and still blocks — # discard needs a surviving verdict, never an empty context. - jq -r ' + # + # And one exclusion that comes before every rule above: the label machine + # never grades its own runs (#208). Every reconcile sweep serializes + # through one shared concurrency group, and GitHub records a displaced + # queued run as CANCELLED — there is no "superseded" conclusion for queue + # displacement. When the displaced run was born from a pull_request_target + # event, that cancelled entry attaches to the victim PR while its + # SUCCESSOR — triggered by a different PR or an issues event — attaches + # elsewhere, so the #139 carve-out's premise (a surviving sibling on the + # same PR) fails structurally: on the victim the newest self entry stays + # CANCELLED, the deny-list scores it FAILURE, and the sweep sets + # blocker:ci-red off its own corpse — then re-affirms it every cadence. + # Proven on crew#227: every real check green, the only red rollup entry + # the sweep's own displaced run. So drop every entry belonging to + # $SELF_WORKFLOW before the newest-per-context collapse. Accepted + # consequences: a rollup of ONLY self entries scores NONE (honestly: no + # checks — never SUCCESS), and a genuine reconcile failure surfaces on the + # Actions tab instead of as blocker:ci-red, which is right because no PR + # edit can fix the label machinery. An empty $self filters nothing — the + # exclusion must never widen into dropping entries on a guess. + jq -r --arg self "$SELF_WORKFLOW" ' if (has("statusCheckRollup") | not) then "UNREADABLE" else # NEUTRAL and SKIPPED satisfy branch protection — a skipped required check @@ -262,7 +291,11 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | # 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". + # The #208 exclusion (header above): self entries leave the rollup here, + # BEFORE the group_by — a self-only context must vanish entirely, never + # survive as an all-cancelled context that still classifies FAILURE. | [ (.statusCheckRollup // [])[] + | select($self == "" or (.workflowName // "") != $self) | { ctx: [.workflowName // "", .name // .context // ""], at: ([.startedAt, .createdAt, .completedAt] | map(select(type == "string" and . != "" diff --git a/changelog.d/208.md b/changelog.d/208.md new file mode 100644 index 0000000..aa1d9b7 --- /dev/null +++ b/changelog.d/208.md @@ -0,0 +1,11 @@ +### Fixed + +- `checks_state` drops rollup entries belonging to the workflow it runs + inside — `SELF_WORKFLOW`, defaulting to the ambient `GITHUB_WORKFLOW` — + before the newest-per-context collapse: the label machine never grades + its own runs, and an empty name filters nothing (#208). +- A sweep displaced from the shared concurrency queue attaches CANCELLED to + its PR while its successor attaches elsewhere, so the sweep set + `blocker:ci-red` off its own displaced run and re-affirmed it every + cadence (crew#227). A rollup of only self entries now honestly scores + NONE (#208). diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 00fcafd..ff42d69 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -351,6 +351,13 @@ run_() { jq -n --arg n "$1" --arg o "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \ 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}'; } +# Pinned empty for every fixture below except the #208 block, which sets its +# own. The script defaults SELF_WORKFLOW from the ambient GITHUB_WORKFLOW — +# present in any CI run of this suite — and an inherited name that happened +# to match a fixture's workflowName ("ci", "labels") would silently drop +# entries these fixtures rely on. The verdicts must not flip with the runner. +SELF_WORKFLOW="" + 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" — @@ -429,6 +436,39 @@ expect "a cancelled newest over an earlier FAILURE is still that failure" FAILUR "$(rollup "[$(rec_ FAILURE 2026-07-24T12:16:17Z 2026-07-24T12:17:06Z),\ $(rec_ CANCELLED 2026-07-24T12:16:41Z 2026-07-24T12:16:41Z)]" | checks_state)" +# -- the #208 exclusion: the label machine never grades its own runs. The +# shared concurrency group displaces queued sweeps as CANCELLED, and the +# displaced run's successor was triggered by a DIFFERENT PR or an issues +# event — so on the victim PR the #139 carve-out's premise (a surviving +# sibling on the same head) fails structurally: the newest self entry +# stays CANCELLED, and the sweep set blocker:ci-red off its own corpse, +# re-affirming it every cadence. Proven on crew#227: every real check +# green, the only red rollup entry the sweep's own displaced run. rec_ +# already builds entries under workflowName "labels"; naming that as +# self must drop them whole, before the newest-per-context collapse. +SELF_WORKFLOW="labels" +expect "a displaced self CANCELLED beside green others is no verdict (crew#227)" SUCCESS \ + "$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS),\ + $(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)" +expect "a FAILED self run surfaces on the Actions tab, not as the PR's red" SUCCESS \ + "$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS),\ + $(rec_ FAILURE 2026-08-01T15:00:00Z 2026-08-01T15:01:00Z)]" | checks_state)" +expect "a rollup of ONLY self entries is honestly NONE, never SUCCESS" NONE \ + "$(rollup "[$(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)" +# must-fail: the filter keys on the self workflow ALONE. Widening it — any +# cancelled entry, any labels-shaped name — certifies a genuine foreign +# failure green, which is #136's unknown-as-green shape all over again. +expect "a genuine foreign FAILURE still blocks beside a cancelled self entry" FAILURE \ + "$(rollup "[$(run_ a FAILURE),\ + $(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)" +# ...and an empty self filters NOTHING: outside Actions no workflow name is +# ambient, and the exclusion must never drop entries on a guess — the same +# displaced-self rollup keeps blocking there, all-cancelled context intact. +SELF_WORKFLOW="" +expect "an empty SELF_WORKFLOW filters nothing — the same rollup still blocks" FAILURE \ + "$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS),\ + $(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | 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 From 45aa8062073538d28084f5a685ce60fb0cd4e158 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 1 Aug 2026 16:20:00 +0000 Subject: [PATCH 002/162] labels: detach the reconcile sweep from PR-triggered runs (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep rode the same workflow run as the PR event that woke it, so every displacement in the shared labels-reconcile queue recorded a CANCELLED reconcile check on some PR — fake red CI that held review requests. The reconcile + issueflow jobs move, unchanged, to a new reusable labels-sweep.yml behind their own caller; labels.yml gains a trigger job that dispatches the consumer's sweep caller with the plain GITHUB_TOKEN (workflow_dispatch is a documented no-retrigger exemption) on every event that used to run reconcile. A displaced sweep now cancels on the Actions tab, attached to no PR; PR checks show scope + trigger. Because every trigger-driven wake arrives as workflow_dispatch, the event name alone no longer separates the operator's manual bootstrap from an event-woken sweep: the sweep caller's bootstrap dispatch input does — the trigger passes no, a bare manual dispatch defaults to yes. The sweep reusable also takes pr_workflow_name, exported as SELF_WORKFLOW for the #208 reconciler (harmless to earlier ones; zero file overlap with #208). The trigger is deliberately loud: a pin bumped without the sweep caller, its bootstrap input, or actions: write on the labels caller goes red at the trigger job instead of silently never sweeping again — documented in docs/CONSUMERS.md with the split stubs and the atomic-adoption note. Refs #209 Co-Authored-By: Claude Fable 5 --- .github/workflows/labels-sweep.yml | 120 +++++++++++++++++ .github/workflows/labels.yml | 123 +++++++----------- .github/workflows/self-labels-sweep.yml | 46 +++++++ .github/workflows/self-labels.yml | 26 ++-- changelog.d/209.md | 10 ++ docs/CONSUMERS.md | 166 +++++++++++++++++------- test/issueflow-reconcile.test.sh | 2 +- test/labels-triggers.test.sh | 58 ++++++++- 8 files changed, 411 insertions(+), 140 deletions(-) create mode 100644 .github/workflows/labels-sweep.yml create mode 100644 .github/workflows/self-labels-sweep.yml create mode 100644 changelog.d/209.md diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml new file mode 100644 index 0000000..9ec1be8 --- /dev/null +++ b/.github/workflows/labels-sweep.yml @@ -0,0 +1,120 @@ +name: labels-sweep +# Reusable sweep half of the labels automation — the reconcile + issueflow +# jobs that rode labels.yml until #209. Triggers and permissions live in the +# caller; docs/CONSUMERS.md carries the complete caller stub +# (workflow_dispatch plus the hourly cron, which relocated here with the +# sweep). Board events still yield a sweep within seconds: labels.yml's +# trigger job dispatches this workflow's caller on every event it used to +# run reconcile on. +# +# Detached on purpose (#209): every sweep covers every open PR and all +# sweeps serialize through ONE shared concurrency group, so GitHub's +# one-running-plus-one-pending queue records every extra run as CANCELLED. +# That displacement is semantically lossless — the surviving sweep does the +# displaced run's work — but while the sweep rode pull_request_target runs +# the ❌ landed on that PR's checks and read as red CI. Here a displaced +# run attaches to no PR: the cancellations live on the Actions tab only. +# +# Bootstrap semantics: a manual dispatch of the caller bootstraps the +# taxonomy (its `bootstrap` input defaults to "yes"), exactly what +# dispatching the labels caller did before the split. The trigger job's +# dispatches carry bootstrap=no — ~20 label upserts per sweep is too chatty +# for every board event, the same reason cron runs never bootstrapped. +# +# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not +# create workflow runs from GITHUB_TOKEN-raised events (the trigger's +# workflow_dispatch is one of the two documented exemptions; this workflow +# dispatches nothing). Agent writes use a PAT and therefore do trigger — +# exactly the asymmetry wanted. +on: + workflow_call: + inputs: + pr_workflow_name: + description: >- + The `name:` of the consumer's PR-facing labels caller, exported + to the reconcile step as SELF_WORKFLOW so the sweep can leave + the label machinery's own check entries (scope, trigger) out of + its CI verdict: a red trigger means "fix the caller", which no + PR edit can do, so it must never count toward blocker:ci-red. + Read by the #208 reconciler; harmless to earlier ones. + type: string + required: false + default: labels + +env: + # A called workflow arrives without its repository. Keep this literal pin + # aligned with the ceremony release consumed by callers (issue #9 D3). + CEREMONY_SELF_REF: "0.4.0" + +jobs: + reconcile: + runs-on: ubuntu-latest + # ONE shared group: every reconcile sweeps every open PR, so cron and + # dispatched runs must serialize or two sweeps race the same PR's labels + # and both pass the request-the-human-once guard. + concurrency: + group: labels-reconcile + cancel-in-progress: false + steps: + # No PR code is ever checked out or executed: the sweep checks out + # the consumer's default branch and the pinned ceremony + # implementation only. Keep it that way. + - uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ref: ${{ github.event.repository.default_branch }} + - uses: actions/checkout@v4 + # The self-consumption bypass — release.yml's twin, and load-bearing + # for the same reason (#11): ceremony's own labels bootstrap must + # run BEFORE any release tag exists for this checkout to fetch — the + # release label the merge door reads is created by that dispatch, so + # without the bypass the first release deadlocks on its own pin. The + # base-branch checkout above already IS ceremony on the dogfood + # path. + if: github.repository != 'heavy-duty/ceremony' + with: + repository: heavy-duty/ceremony + ref: ${{ env.CEREMONY_SELF_REF }} + path: .ceremony-src + # Two steps, mutually exclusive `if:`s, because a `uses:` path must be + # a literal — the same fork release.yml's CEREMONY_DIR env line + # papers over for `run:` steps, which composite `uses:` has no + # equivalent of. + # + # bootstrap: every trigger-driven wake arrives as workflow_dispatch + # too (that is how `gh workflow run` wakes the caller), so the event + # name alone no longer separates the operator's manual full-board + # bootstrap from an event-woken sweep — the caller's `bootstrap` + # dispatch input does: the trigger passes "no", a bare manual + # dispatch defaults to "yes". A caller reached on any other event + # (the cron) has no input and stays "no". + - name: reconcile state + stale + if: github.repository != 'heavy-duty/ceremony' + uses: ./.ceremony-src/actions/labels-reconcile + with: + bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SELF_WORKFLOW: ${{ inputs.pr_workflow_name }} + - name: reconcile state + stale (dogfood — the workspace IS ceremony) + if: github.repository == 'heavy-duty/ceremony' + uses: ./actions/labels-reconcile + with: + bootstrap: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.bootstrap != 'no' && 'yes' || 'no' }} + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SELF_WORKFLOW: ${{ inputs.pr_workflow_name }} + - name: reconcile issue flow + if: github.repository != 'heavy-duty/ceremony' + uses: ./.ceremony-src/actions/issueflow-reconcile + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + - name: reconcile issue flow (dogfood — the workspace IS ceremony) + if: github.repository == 'heavy-duty/ceremony' + uses: ./actions/issueflow-reconcile + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 86328ae..5763e3c 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -6,24 +6,39 @@ name: labels # family arrives from a fork, where pull_request runs 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 — scope reads changed paths and the -# path mapping via the API and checks out only the ceremony implementation, -# and reconcile checks out the BASE branch only. Keep it that way. +# path mapping via the API and checks out only the ceremony implementation. +# Keep it that way. # -# 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 -# safety net (#199 relaxed it from */15 to hourly, but did NOT drop it). The -# cron is the sweep's only discovery path for every transition no subscribed -# event carries: a verdict landing, blocker:ci-red set/cleared, a -# blocker:conflict when another PR merges under this one, and the time-based -# 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. +# The reconcile sweep lived here until #209. Riding the PR-triggered run +# meant every displacement in the sweep's shared concurrency queue recorded +# a CANCELLED `reconcile` check on some PR — read as red CI by every human +# and agent, though the surviving sweep does the displaced run's work. The +# sweep now lives in labels-sweep.yml behind its own caller, and the +# trigger job below is its wake: it fires on every event this caller +# subscribes — the exact surface that used to run reconcile directly — so +# the wake latency (#137) is unchanged, while a displaced sweep cancels on +# the Actions tab, attached to no PR. PR checks show scope + trigger only. # -# This cannot loop: reconciler 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. +# This cannot loop: the trigger's dispatch and the reconciler's label +# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from +# GITHUB_TOKEN-raised events — workflow_dispatch and repository_dispatch +# are the two documented exemptions, which is exactly why the trigger can +# wake the sweep with no PAT anywhere in the path — and the sweep itself +# dispatches nothing. Agent writes use a PAT and therefore do trigger — +# exactly the asymmetry wanted. on: workflow_call: + inputs: + sweep_workflow: + description: >- + Filename of the consumer's sweep caller — the workflow that + calls labels-sweep.yml (docs/CONSUMERS.md carries the stub). + The trigger job dispatches it by this name. Override it only + when the caller file is not named labels-sweep.yml (ceremony's + own dogfood names it self-labels-sweep.yml). + type: string + required: false + default: labels-sweep.yml env: # A called workflow arrives without its repository. Keep this literal pin @@ -35,7 +50,7 @@ jobs: # Not on labeled/unlabeled: those events change no paths, so scope has # nothing new to derive — and label churn is precisely what they are. # review_requested/review_request_removed likewise change no paths — they - # exist to wake reconcile (#137) — and running labeler on them widens + # exist to wake the sweep (#137) — and running labeler on them widens # exactly the window #130 documents, where a label written during a # scope run is clobbered. if: >- @@ -85,65 +100,27 @@ jobs: # the mapping it is judged by CONFIG_REF: ${{ github.sha }} - reconcile: + trigger: + # The sweep's wake (#209). No `if:`: reconcile carried none, so the + # trigger keeps the whole event surface the caller subscribes — + # workflow_dispatch of the labels caller itself included. That cannot + # double-fire bootstrap: this dispatch always carries bootstrap=no, so + # a dispatched labels caller yields one plain sweep, and the taxonomy + # bootstrap fires solely on a manual dispatch of the sweep caller + # (whose input defaults to "yes"). Excluding workflow_dispatch here + # would instead make a dispatched labels caller do nothing at all — + # a silent no-op run is worse than a redundant sweep. + # + # LOUD on failure — never `|| true`: a red trigger is the + # misconfiguration alarm. A consumer that bumps the pin without adding + # the sweep caller (workflow-not-found), without its declared + # `bootstrap` input (unexpected input), or without `actions: write` + # on this caller (permission denied) fails HERE, visibly on the PR, + # instead of silently never sweeping again. 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. - concurrency: - group: labels-reconcile - cancel-in-progress: false steps: - # pull_request_target is required for fork PR write permission. It is - # safe here because no PR code is ever checked out or executed: - # labels-scope reads the mapping and changed paths via the API, and - # reconcile checks out the BASE branch only. Keep it that way. - - uses: actions/checkout@v4 - with: - repository: ${{ github.repository }} - ref: ${{ github.event.repository.default_branch }} - - uses: actions/checkout@v4 - # The self-consumption bypass — release.yml's twin, and load-bearing - # for the same reason (#11): ceremony's own labels bootstrap must - # run BEFORE any release tag exists for this checkout to fetch — the - # release label the merge door reads is created by that dispatch, so - # without the bypass the first release deadlocks on its own pin. The - # base-branch checkout above already IS ceremony on the dogfood - # path. - if: github.repository != 'heavy-duty/ceremony' - with: - repository: heavy-duty/ceremony - ref: ${{ env.CEREMONY_SELF_REF }} - path: .ceremony-src - # Two steps, mutually exclusive `if:`s, because a `uses:` path must be - # a literal — the same fork release.yml's CEREMONY_DIR env line - # papers over for `run:` steps, which composite `uses:` has no - # equivalent of. - - name: reconcile state + stale - if: github.repository != 'heavy-duty/ceremony' - uses: ./.ceremony-src/actions/labels-reconcile - with: - bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }} + - name: dispatch the sweep env: GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - - name: reconcile state + stale (dogfood — the workspace IS ceremony) - if: github.repository == 'heavy-duty/ceremony' - uses: ./actions/labels-reconcile - with: - bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }} - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - - name: reconcile issue flow - if: github.repository != 'heavy-duty/ceremony' - uses: ./.ceremony-src/actions/issueflow-reconcile - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - - name: reconcile issue flow (dogfood — the workspace IS ceremony) - if: github.repository == 'heavy-duty/ceremony' - uses: ./actions/issueflow-reconcile - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} + SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }} + run: gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no diff --git a/.github/workflows/self-labels-sweep.yml b/.github/workflows/self-labels-sweep.yml new file mode 100644 index 0000000..94ae4a3 --- /dev/null +++ b/.github/workflows/self-labels-sweep.yml @@ -0,0 +1,46 @@ +name: labels-sweep +# Ceremony's own sweep caller (#209) — self-labels.yml's detached half, +# wearing the same local-`uses:` deviation and the same warning: consumers +# must NEVER copy the local form (it rides main, unpinned — correct only +# for the repo that IS the source). Consumers write: +# uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@ +on: + # The consumer owns this cadence (#203). Hourly is the recommended default + # when no other engine drives board state: the cron is then the sweep's ONLY + # wake for four transition classes — a review verdict landing (there is no + # pull_request_review trigger on the labels caller), 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 labels caller's events carry the rest in seconds, one + # trigger-job dispatch away. 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. A bare dispatch (input default "yes") also + # bootstraps the taxonomy on a fresh repo — what dispatching the labels + # caller did before #209. The reusable's trigger job wakes this workflow + # with bootstrap=no on every board event — an event-woken sweep must not + # re-upsert ~20 labels each time — so declaring this input is part of the + # caller contract: a dispatch naming an undeclared input is refused, and + # the trigger job goes loudly red. + workflow_dispatch: + inputs: + bootstrap: + description: Bootstrap the label taxonomy before sweeping + type: choice + options: ["yes", "no"] + default: "yes" +permissions: + contents: read + checks: read # mergeability/check-rollup read for PR state + statuses: read # commit-status rollup read for PR state + issues: write + pull-requests: write +jobs: + sweep: + # pr_workflow_name keeps its default: ceremony's PR-facing caller is + # named `labels` (self-labels.yml). + uses: ./.github/workflows/labels-sweep.yml diff --git a/.github/workflows/self-labels.yml b/.github/workflows/self-labels.yml index 272f429..715f1ee 100644 --- a/.github/workflows/self-labels.yml +++ b/.github/workflows/self-labels.yml @@ -4,22 +4,13 @@ name: labels # same warning: consumers must NEVER copy the local form (it rides main, # unpinned — correct only for the repo that IS the source). Consumers write: # uses: heavy-duty/ceremony/.github/workflows/labels.yml@ +# +# Since #209 this caller carries the PR/issue event surface only. The +# reconcile sweep no longer rides these runs — the reusable's trigger job +# dispatches the sweep caller (self-labels-sweep.yml here), which owns the +# hourly cron and the manual/bootstrap workflow_dispatch. A board event +# below still yields a sweep within seconds, one dispatch hop later. on: - # The consumer owns this cadence (#203). Hourly is the recommended default - # when no other engine drives board state: the cron is then the sweep's ONLY - # 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 @@ -48,8 +39,13 @@ permissions: contents: read checks: read # mergeability/check-rollup read for PR state statuses: read # commit-status rollup read for PR state + actions: write # the trigger job's `gh workflow run` dispatch of the sweep caller (#209) issues: write pull-requests: write jobs: labels: uses: ./.github/workflows/labels.yml + with: + # Dogfood filename deviation only — consumers keep the default, + # labels-sweep.yml, and pass nothing. + sweep_workflow: self-labels-sweep.yml diff --git a/changelog.d/209.md b/changelog.d/209.md new file mode 100644 index 0000000..55be82b --- /dev/null +++ b/changelog.d/209.md @@ -0,0 +1,10 @@ +### Changed + +- The reconcile sweep is detached from PR-triggered runs: a new reusable + `labels-sweep.yml` carries it, woken by `labels.yml`'s new `trigger` job, so + a queue-displaced sweep cancels on the Actions tab instead of landing a + cancelled `reconcile` check on a PR (#209). +- Labels consumers add a sweep caller (`labels-sweep.yml`, stub in + docs/CONSUMERS.md), relocate the hourly cron and manual bootstrap dispatch + to it, and grant the labels caller `actions: write`; a pin bump without the + sweep caller goes loudly red at the trigger job (#209). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index eaac5c1..847bbbe 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -27,7 +27,7 @@ edits to this guide (#12). - **The `release` label must exist** before the first ceremony PR — it is the merge door's declared-intent read ([lib/facts.sh](../lib/facts.sh#L88-L101)). Bootstrap it via the labels - workflow's `workflow_dispatch` + sweep caller's `workflow_dispatch` ([Labels automation](#labels-automation)), or create it by hand, matching the core table ([actions/labels-reconcile/labels-reconcile.sh](../actions/labels-reconcile/labels-reconcile.sh#L369)): @@ -118,12 +118,13 @@ the machinery at all: consumer. In particular, `0.1.0` carries `changelog-armed`, `changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not `changelog-assembled` or `runner-isolated`. -6. **Labels automation** (optional but recommended): the caller from - [Labels automation](#labels-automation), plus `.github/labels.conf` +6. **Labels automation** (optional but recommended): the two callers from + [Labels automation](#labels-automation) — the event-facing labels + caller and the sweep caller (#209) — plus `.github/labels.conf` (panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the - path→scope globs). Run `workflow_dispatch` once — **this bootstraps - the taxonomy, `release` label included** — and use it again whenever an - operator needs a full-board sweep immediately. + path→scope globs). Run the sweep caller's `workflow_dispatch` once — + **this bootstraps the taxonomy, `release` label included** — and use it + again whenever an operator needs a full-board sweep immediately. 7. **The artifact hook** (optional): `.github/actions/release-artifact/` per [The artifact hook](#the-artifact-hook). No hook → the source tarball is the package. @@ -150,7 +151,8 @@ precisely so the machinery is safe to work on - [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in the bootstrap list above (with `fetch-depth: 0` on the checkout). - [ ] Replace `labels.yml` with the caller from - [Labels automation](#labels-automation); extract + [Labels automation](#labels-automation) and add the sweep caller + `labels-sweep.yml` beside it (#209); extract `.github/labels.conf` from the old reconciler's embedded config — the `panel=` roster line and the repo's `scope:*` rows ([the format](#labels-automation)). `.github/labeler.yml` stays as @@ -269,13 +271,30 @@ build (#15) and incubator's GHCR image push (#16). ## Labels automation -The reusable labels workflow owns two independent jobs: additive path-based -`scope:*` labels and reconciliation of PR state, blockers, handoff, stale -status, and the `needs-ruling` invariants on both surfaces — the bare-flag -check and the 7-day comment-only nudge (#52; the sweep reads that flag and -never writes it). The consumer keeps its path mapping in -`.github/labeler.yml` and its review panel plus scope taxonomy in -`.github/labels.conf`. +The labels automation is two reusable workflows since #209, adopted +together at the same pin: + +- **`labels.yml`** — the event-facing half, called on PR and issue events. + Two jobs: additive path-based `scope:*` labels, and a few-seconds + `trigger` job that wakes the sweep by dispatching the consumer's sweep + caller (`gh workflow run`, plain `GITHUB_TOKEN` — `workflow_dispatch` is + one of the two documented exemptions from the token's no-retrigger rule, + so no PAT anywhere in the path and no loop: the sweep dispatches + nothing). +- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers, + handoff, stale status, the issue work queue, and the `needs-ruling` + invariants on both surfaces — the bare-flag check and the 7-day + comment-only nudge (#52; the sweep reads that flag and never writes it). + Detached from PR-triggered runs on purpose: all sweeps serialize through + one shared concurrency group, and GitHub records every queue-displaced + run as CANCELLED — harmless (the surviving sweep does its work) until it + rode a `pull_request_target` run and the ❌ landed on that PR's checks + as fake red CI. Behind its own caller, a displaced sweep cancels on the + Actions tab, attached to no PR; PR checks show `scope` and the green + `trigger` only. + +The consumer keeps its path mapping in `.github/labeler.yml` and its +review panel plus scope taxonomy in `.github/labels.conf`. **Additive means additive** (unreleased — #130): the scope job's only label write is `POST /issues/{n}/labels`, which adds the derived scopes and removes @@ -292,25 +311,11 @@ half-honoured. The reconcile sweep also warns (never sets) when a non-draft PR carries a bare `X.Y.Z` version differing from its base but no `release` label — the merge door would refuse that merge, and the sweep says so first. -The complete caller is: +The complete event-facing caller is: ```yaml name: labels on: - # The consumer owns this cadence (#203). Hourly is the recommended default - # when no other engine drives board state: the cron is then the sweep's only - # 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: # Fork PRs; these carry the head/draft/review facts state:* derives from. # labeled/unlabeled are the handoff wake (state:needs-human confirmed here); @@ -334,18 +339,81 @@ permissions: contents: read checks: read # mergeability/check-rollup read for PR state statuses: read # commit-status rollup read for PR state - actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60) + actions: write # the trigger job's `gh workflow run` dispatch of the sweep caller (#209) issues: write pull-requests: write jobs: labels: uses: heavy-duty/ceremony/.github/workflows/labels.yml@ + # If the sweep caller below is named anything but labels-sweep.yml, + # say so: `with: { sweep_workflow: }`. Ceremony's own + # dogfood does (self-labels-sweep.yml). +``` + +And the complete sweep caller, `labels-sweep.yml` beside it — the hourly +cron lives HERE since #209, not on the labels caller: + +```yaml +name: labels-sweep +on: + # The consumer owns this cadence (#203). Hourly is the recommended default + # when no other engine drives board state: the cron is then the sweep's only + # wake for four transition classes — a review verdict landing (no + # pull_request_review trigger on the labels caller), blocker:ci-red + # set/cleared, blocker:conflict when another PR merges under this one, and + # time-based stale / 48h claim-reclaim. The labels caller's events carry the + # rest in seconds, one trigger-job dispatch away. 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. A bare dispatch (input default "yes") also + # bootstraps the taxonomy on a fresh repo. The labels caller's trigger job + # wakes this workflow with bootstrap=no on every board event, so the + # declared input is part of the contract: a dispatch naming an undeclared + # input is refused, and the trigger job goes loudly red. + workflow_dispatch: + inputs: + bootstrap: + description: Bootstrap the label taxonomy before sweeping + type: choice + options: ["yes", "no"] + default: "yes" +permissions: + contents: read + checks: read # mergeability/check-rollup read for PR state + statuses: read # commit-status rollup read for PR state + actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60) + issues: write + pull-requests: write +jobs: + sweep: + uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@ + # If this repo's PR-facing labels caller is named anything but `labels`, + # pass that name: `with: { pr_workflow_name: }`. The sweep exports + # it as SELF_WORKFLOW so the label machinery's own check entries (scope, + # trigger) never count toward blocker:ci-red — a red trigger means "fix + # the caller", which no PR edit can do (#208 reads it). ``` Naming any permission sets every unnamed permission to `none`. Public repositories allow check data to be read regardless, but a private consumer -needs all three explicit reads above; without them the failure appears as an empty -`state:*` axis on the board rather than a red workflow run. +needs the explicit reads above; without them the failure appears as an empty +`state:*` axis on the board rather than a red workflow run. The labels +caller's `actions: write` is different — it is required everywhere, public +repos included: the trigger job's `gh workflow run` is a write, and without +it every event run goes red at the trigger. + +**The failure mode to know before bumping**: a consumer that bumps its pin +to a #209-carrying tag without adding the sweep caller keeps green-looking +silence nowhere — the trigger job goes **red on every PR and issue event** +(workflow-not-found; likewise on a sweep caller missing its `bootstrap` +input, or a labels caller missing `actions: write`), and event-woken sweeps +stop until the caller lands. That loudness is deliberate: never read +silence, or a green `scope` alone, as health. Make the adoption one atomic +PR — pin bump, sweep caller file, `actions: write` line together. 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 @@ -366,8 +434,17 @@ 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. +The two-caller split (ceremony#209) is **unreleased**. A consumer pinned to +`0.4.0` or earlier keeps the previous single-caller shape — the labels +caller carrying the cron, `workflow_dispatch`, and `actions: read` — and +adopts the split at the pin bump to the first tag carrying ceremony#209, +as one atomic PR: the pin, the reshaped labels caller (cron and dispatch +removed, `actions: write` added), and the new sweep caller file. Never mix +refs to adopt it early, and never bump without the sweep caller — that is +the red-trigger failure mode above. + `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 workflows execute no PR code. They check out only the consumer's base branch and the pinned ceremony implementation. 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 @@ -403,21 +480,22 @@ way — keep the file data only). Core state, blocker, work-queue, and release labels come from ceremony. Scope rows remain consumer-owned because paths and surfaces differ by repository. -After adding the caller and configuration, run `workflow_dispatch` once to -bootstrap labels on a fresh repository. It is also the operator's general -manual full-board sweep — the answer when the board looks wrong now rather -than after the next scheduled cadence: +After adding the callers and configuration, dispatch the sweep caller once +to bootstrap labels on a fresh repository. A bare dispatch is also the +operator's general manual full-board sweep — the answer when the board +looks wrong now rather than after the next scheduled cadence: ```sh -gh workflow run labels.yml -R / +gh workflow run labels-sweep.yml -R / ``` -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. +Ceremony dogfoods the callers under the filenames `self-labels.yml` and +`self-labels-sweep.yml`, so the equivalent command in this repository +substitutes that filename. Scheduled and trigger-driven runs only +reconcile; they do not repeatedly upsert the taxonomy (the trigger's +dispatch carries `bootstrap=no`). When a ceremony pin bump adds a core +label, bump the pin first and then re-dispatch; the scheduled sweep warns +when the pinned taxonomy declares a core label the repository lacks. ## Doctrine mirror diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index a326da9..cfc83ae 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -30,7 +30,7 @@ check "dogfood caller wakes on issue events" 0 " issues:" \ grep -F " issues:" "$ROOT/.github/workflows/self-labels.yml" dogfood_pr_step="$(sed -n \ '/name: reconcile state + stale (dogfood/,/name: reconcile issue flow/p' \ - "$ROOT/.github/workflows/labels.yml")" + "$ROOT/.github/workflows/labels-sweep.yml")" # shellcheck disable=SC2016 # GitHub expressions are asserted as literals check "dogfood PR reconcile receives repository" 0 ' REPO: ${{ github.repository }}' \ grep -F ' REPO: ${{ github.repository }}' <<<"$dogfood_pr_step" diff --git a/test/labels-triggers.test.sh b/test/labels-triggers.test.sh index b408663..c547ae9 100644 --- a/test/labels-triggers.test.sh +++ b/test/labels-triggers.test.sh @@ -12,8 +12,10 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$ROOT/test/harness.sh" REUSABLE="$ROOT/.github/workflows/labels.yml" +SWEEP="$ROOT/.github/workflows/labels-sweep.yml" SELF="$ROOT/.github/workflows/self-labels.yml" -STUB="$ROOT/docs/CONSUMERS.md" # the published caller stub, a fenced yaml block +SELF_SWEEP="$ROOT/.github/workflows/self-labels-sweep.yml" +STUB="$ROOT/docs/CONSUMERS.md" # the published caller stubs, fenced yaml blocks # 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. @@ -39,26 +41,68 @@ trigger_types() { # $1 = file, $2 = trigger key # ---- 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. +# trading correctness for minutes — so it stays false, forever. The job lives +# in labels-sweep.yml since #209; the guard moved with it. check "reconcile serializes, never cancels mid-board" 0 "false" \ - job_cancel_in_progress "$REUSABLE" reconcile + job_cancel_in_progress "$SWEEP" 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" + }; [ "$(job_cancel_in_progress "$1")" = true ]' _ "$SWEEP" # 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 sweep is detached from PR-triggered runs (#209) --------------------- +# While reconcile rode the PR-event run, every displacement in its shared +# queue recorded a CANCELLED check on some PR — fake red CI. The reusable +# labels.yml must never grow the job back; its trigger job wakes the sweep +# caller by dispatch instead, and that dispatch is the misconfiguration +# alarm: a pin bumped without the sweep caller must go loudly red at the +# trigger, so the dispatch line is never allowed to silence itself. +check "labels.yml carries no reconcile job" 1 "" \ + grep -E '^ reconcile:' "$REUSABLE" +check "labels-sweep.yml carries the reconcile job" 0 " reconcile:" \ + grep -E '^ reconcile:' "$SWEEP" +check "the sweep keeps the ONE shared concurrency group" 0 "group: labels-reconcile" \ + grep -F 'group: labels-reconcile' "$SWEEP" +check "labels.yml carries the trigger job" 0 " trigger:" \ + grep -E '^ trigger:' "$REUSABLE" +# shellcheck disable=SC2016 # $SWEEP_WORKFLOW is the workflow's own env var, asserted literally +check "the trigger dispatches the sweep caller, never bootstrapping" 0 \ + 'gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no' \ + grep -F 'gh workflow run' "$REUSABLE" +# shellcheck disable=SC2016 # $1 expands in the nested bash, not here +check "the trigger dispatch is never silenced with || true" 1 "" \ + bash -c 'grep -F "gh workflow run" "$1" | grep -qF "|| true"' _ "$REUSABLE" +check "the sweep caller filename input defaults to labels-sweep.yml" 0 \ + "default: labels-sweep.yml" grep -F 'default: labels-sweep.yml' "$REUSABLE" +# the dogfood callers wear the split: the event caller names its deviant +# sweep filename, and the sweep caller declares the bootstrap input the +# trigger's -f flag requires (an undeclared input reds every dispatch) +check "self caller passes its dogfood sweep filename" 0 \ + "sweep_workflow: self-labels-sweep.yml" \ + grep -F 'sweep_workflow: self-labels-sweep.yml' "$SELF" +check "self sweep caller declares the bootstrap dispatch input" 0 \ + "bootstrap:" grep -E '^ bootstrap:' "$SELF_SWEEP" +check "stub sweep caller declares the bootstrap dispatch input" 0 \ + "bootstrap:" grep -E '^ bootstrap:' "$STUB" +# the labels caller's event runs must not carry the sweep's cron or manual +# dispatch — those relocated to the sweep caller with #209 +check "self caller carries no cron" 1 "" grep -F 'cron:' "$SELF" +check "self caller carries no workflow_dispatch" 1 "" \ + grep -E '^ workflow_dispatch:' "$SELF" + # ---- 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" +# The cron rides the sweep caller since #209. +check "self sweep caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF_SWEEP" # 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 "self sweep caller cron line no longer fires */15" 1 "" \ + bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF_SWEEP" 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 "" \ From be660358f27e4c0da6b4ca7467209f69eec2fe59 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 1 Aug 2026 17:25:26 +0000 Subject: [PATCH 003/162] docs: spell out crew's four-edit migration; fold in crew#250 field facts (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crew#250 verified two facts the design prose now carries: a queue- displaced run is not independently rerunnable (gh run rerun / --failed / --job all refuse), so a victim PR had no manual escape hatch; and the displacing burst is deterministic — one review_requested event per panelist per request — so displacement is the steady state of a working fleet, scaling with panel size, not a traffic spike. CONSUMERS.md now walks the adoption as one atomic four-edit PR with crew as the worked example: the pin bump in every ceremony uses: reference, the new labels-sweep.yml caller, the cron RELOCATED (bold warning: a copied-not-moved schedule double-fires sweeps into the one shared group and reads as the bug getting worse after the fix), and actions: write replacing the labels caller's actions: read. Refs #209 Co-Authored-By: Claude Fable 5 --- .github/workflows/labels-sweep.yml | 9 +++++-- .github/workflows/labels.yml | 7 +++++- docs/CONSUMERS.md | 39 +++++++++++++++++++++++++----- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml index 9ec1be8..9e68d22 100644 --- a/.github/workflows/labels-sweep.yml +++ b/.github/workflows/labels-sweep.yml @@ -12,8 +12,13 @@ name: labels-sweep # one-running-plus-one-pending queue records every extra run as CANCELLED. # That displacement is semantically lossless — the surviving sweep does the # displaced run's work — but while the sweep rode pull_request_target runs -# the ❌ landed on that PR's checks and read as red CI. Here a displaced -# run attaches to no PR: the cancellations live on the Actions tab only. +# the ❌ landed on that PR's checks and read as red CI, with no manual +# escape hatch: GitHub refuses to rerun a queue-displaced run (crew#250). +# And displacement is the steady state of a working fleet, not a spike — +# one panel request emits one review_requested event per reviewer, so +# every review round over-fills the one-running-plus-one-pending queue. +# Here a displaced run attaches to no PR: the cancellations live on the +# Actions tab only. # # Bootstrap semantics: a manual dispatch of the caller bootstraps the # taxonomy (its `bootstrap` input defaults to "yes"), exactly what diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 5763e3c..8a13c2e 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -12,7 +12,12 @@ name: labels # The reconcile sweep lived here until #209. Riding the PR-triggered run # meant every displacement in the sweep's shared concurrency queue recorded # a CANCELLED `reconcile` check on some PR — read as red CI by every human -# and agent, though the surviving sweep does the displaced run's work. The +# and agent, though the surviving sweep does the displaced run's work. Two +# field facts made that untenable (crew#250): a displaced run cannot be +# rerun — `gh run rerun`, `--failed`, and `--job` all refuse — so a victim +# PR has no manual escape hatch; and the displacing burst is deterministic, +# one `review_requested` event per panelist per request, so every review +# round displaces runs and the rate scales with panel size. The # sweep now lives in labels-sweep.yml behind its own caller, and the # trigger job below is its wake: it fires on every event this caller # subscribes — the exact surface that used to run reconcile directly — so diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 847bbbe..82931bb 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -289,7 +289,9 @@ together at the same pin: one shared concurrency group, and GitHub records every queue-displaced run as CANCELLED — harmless (the surviving sweep does its work) until it rode a `pull_request_target` run and the ❌ landed on that PR's checks - as fake red CI. Behind its own caller, a displaced sweep cancels on the + as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun` + and its `--failed`/`--job` forms all decline a queue-displaced run). + Behind its own caller, a displaced sweep cancels on the Actions tab, attached to no PR; PR checks show `scope` and the green `trigger` only. @@ -437,11 +439,36 @@ the stub and ceremony's own caller stay byte-for-byte identical, the parity The two-caller split (ceremony#209) is **unreleased**. A consumer pinned to `0.4.0` or earlier keeps the previous single-caller shape — the labels caller carrying the cron, `workflow_dispatch`, and `actions: read` — and -adopts the split at the pin bump to the first tag carrying ceremony#209, -as one atomic PR: the pin, the reshaped labels caller (cron and dispatch -removed, `actions: write` added), and the new sweep caller file. Never mix -refs to adopt it early, and never bump without the sweep caller — that is -the red-trigger failure mode above. +adopts the split at the pin bump to the first tag carrying ceremony#209. +Never mix refs to adopt it early. + +The migration is **one atomic PR** with exactly four edits — crew, the +consumer whose displaced-check evidence drove #209 (crew#227, crew#250), +is the worked example; written here against `0.4.1` as the illustrative +first tag carrying the split: + +1. **Pin bump, every reference together** ([Version pinning](#version-pinning)): + `0.4.0` → `0.4.1` in the labels caller's `uses:` line **and in every + other ceremony `uses:` in the repo** — crew also pins in + `release.yml` and its `ci.yml` guard steps. A repo on the doctrine + mirror re-runs `docs-sync --fix` in the same PR. +2. **New file `.github/workflows/labels-sweep.yml`** — the sweep caller + stub above, verbatim, `bootstrap` input included (the trigger's + `-f bootstrap=no` dispatch is refused if the input is undeclared). +3. **The hourly cron RELOCATES — it is moved, never copied.** Delete the + `schedule:` block (and the bare `workflow_dispatch:`) from the labels + caller in the same edit that adds the sweep caller. + **Warning**: a consumer that copies the sweep caller and leaves the + old schedule on the labels caller gets DOUBLE sweeps — every cron tick + fires both callers into the one shared `labels-reconcile` group — so + displacement goes **up**, and the fix reads as the bug getting worse. +4. **`actions: write` on the labels caller** — consumers carry + `actions: read` today (crew does); the trigger job's `gh workflow run` + is a write. The sweep caller keeps `actions: read`. + +Bump without the sweep caller and the trigger job goes red on every PR +and issue event — the loud failure mode above — so never split these +four edits across PRs. `pull_request_target` is intentional: fork PRs need the base repository's token to write labels. The reusable workflows execute no PR code. They check From 74d59e9554449cfb63e8569e7c0ca7074940ee83 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 1 Aug 2026 17:54:47 +0000 Subject: [PATCH 004/162] release: cut 0.4.1 Consume the two displacement-fix fragments into the 0.4.1 section, stamp VERSION and every CEREMONY_SELF_REF carrier, and record the doors-unchanged drill ruling with the candidate-head evidence table. Refs #212 Co-Authored-By: Claude Fable 5 --- .github/workflows/labels-sweep.yml | 2 +- .github/workflows/labels.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 25 ++++++++++++++++++ VERSION | 2 +- changelog.d/208.md | 11 -------- changelog.d/209.md | 10 ------- drills/0.4.1.md | 42 ++++++++++++++++++++++++++++++ 8 files changed, 71 insertions(+), 25 deletions(-) delete mode 100644 changelog.d/208.md delete mode 100644 changelog.d/209.md create mode 100644 drills/0.4.1.md diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml index 9e68d22..ec50bb7 100644 --- a/.github/workflows/labels-sweep.yml +++ b/.github/workflows/labels-sweep.yml @@ -49,7 +49,7 @@ on: env: # A called workflow arrives without its repository. Keep this literal pin # aligned with the ceremony release consumed by callers (issue #9 D3). - CEREMONY_SELF_REF: "0.4.0" + CEREMONY_SELF_REF: "0.4.1" jobs: reconcile: diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 8a13c2e..ca159b8 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -48,7 +48,7 @@ on: env: # A called workflow arrives without its repository. Keep this literal pin # aligned with the ceremony release consumed by callers (issue #9 D3). - CEREMONY_SELF_REF: "0.4.0" + CEREMONY_SELF_REF: "0.4.1" jobs: scope: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d604f0e..9081740 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ env: # `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the # shared logic arrives as script files via checkout, not as inner `uses:` # references. - CEREMONY_SELF_REF: "0.4.0" + CEREMONY_SELF_REF: "0.4.1" VERSION_SOURCE: ${{ inputs.version-source }} jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index fc33418..406114a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,31 @@ Entries arrive as fragments — one `changelog.d/.md` per PR, never an edit to this file — and the release PR assembles them into the next section here (`bin/changelog-assemble`, #112). +## 0.4.1 — 2026-08-01 + +### Changed + +- The reconcile sweep is detached from PR-triggered runs: a new reusable + `labels-sweep.yml` carries it, woken by `labels.yml`'s new `trigger` job, so + a queue-displaced sweep cancels on the Actions tab instead of landing a + cancelled `reconcile` check on a PR (#209). +- Labels consumers add a sweep caller (`labels-sweep.yml`, stub in + docs/CONSUMERS.md), relocate the hourly cron and manual bootstrap dispatch + to it, and grant the labels caller `actions: write`; a pin bump without the + sweep caller goes loudly red at the trigger job (#209). + +### Fixed + +- `checks_state` drops rollup entries belonging to the workflow it runs + inside — `SELF_WORKFLOW`, defaulting to the ambient `GITHUB_WORKFLOW` — + before the newest-per-context collapse: the label machine never grades + its own runs, and an empty name filters nothing (#208). +- A sweep displaced from the shared concurrency queue attaches CANCELLED to + its PR while its successor attaches elsewhere, so the sweep set + `blocker:ci-red` off its own displaced run and re-affirmed it every + cadence (crew#227). A rollup of only self entries now honestly scores + NONE (#208). + ## 0.4.0 — 2026-07-29 ### Added diff --git a/VERSION b/VERSION index 1351681..267577d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.1-dev +0.4.1 diff --git a/changelog.d/208.md b/changelog.d/208.md deleted file mode 100644 index aa1d9b7..0000000 --- a/changelog.d/208.md +++ /dev/null @@ -1,11 +0,0 @@ -### Fixed - -- `checks_state` drops rollup entries belonging to the workflow it runs - inside — `SELF_WORKFLOW`, defaulting to the ambient `GITHUB_WORKFLOW` — - before the newest-per-context collapse: the label machine never grades - its own runs, and an empty name filters nothing (#208). -- A sweep displaced from the shared concurrency queue attaches CANCELLED to - its PR while its successor attaches elsewhere, so the sweep set - `blocker:ci-red` off its own displaced run and re-affirmed it every - cadence (crew#227). A rollup of only self entries now honestly scores - NONE (#208). diff --git a/changelog.d/209.md b/changelog.d/209.md deleted file mode 100644 index 55be82b..0000000 --- a/changelog.d/209.md +++ /dev/null @@ -1,10 +0,0 @@ -### Changed - -- The reconcile sweep is detached from PR-triggered runs: a new reusable - `labels-sweep.yml` carries it, woken by `labels.yml`'s new `trigger` job, so - a queue-displaced sweep cancels on the Actions tab instead of landing a - cancelled `reconcile` check on a PR (#209). -- Labels consumers add a sweep caller (`labels-sweep.yml`, stub in - docs/CONSUMERS.md), relocate the hourly cron and manual bootstrap dispatch - to it, and grant the labels caller `actions: write`; a pin bump without the - sweep caller goes loudly red at the trigger job (#209). diff --git a/drills/0.4.1.md b/drills/0.4.1.md new file mode 100644 index 0000000..dc6a2e6 --- /dev/null +++ b/drills/0.4.1.md @@ -0,0 +1,42 @@ +# 0.4.1 — drill record + +Run 2026-08-01 by `dan-claude-bot` against release PR (Refs #212), candidate +branch `build/212-release-0-4-1` on `heavy-duty/ceremony` main at `c2987fd`. + +## Scope ruling — doors unchanged, no disposable-repo rehearsal + +The 0.4.0 drill (drills/0.4.0.md) probed both release doors end-to-end in a +disposable repo, and the live 0.4.0 release then exercised the merge door for +real: one tag, one release, main re-armed to `0.4.1-dev`. Since that tag, +`release.yml`'s only delta is the `CEREMONY_SELF_REF` pin line — no door +logic, no decide table, no publish step changed. Re-running a full +disposable-repo drill would rehearse machinery this repo already proved both +in rehearsal and in production within the last three days. This record +therefore rests on the standing evidence below, and says so honestly rather +than staging a ceremony for a tree the doors cannot distinguish from the last +one. The panel reviews this claim like any other; if any reviewer rules a +full drill owed, that verdict wins. + +## Standing evidence at the candidate head + +| # | probe | where | result | +|---|---|---|---| +| 1 | decide + merge-door step-replay (dogfood) | `release-exercise / step-replay (dogfood)` on this PR | CI-gating; green required to merge | +| 2 | decide + merge-door step-replay (consumer) | `release-exercise / step-replay (consumer)` on this PR | CI-gating; green required to merge | +| 3 | fragment chain: armed → assemble → monotonic | `release-exercise / fixture-chain` on this PR | CI-gating; green required to merge | +| 4 | the real tree's own guards (armed, monotonic, drill-recorded, self-ref) | `self-guards` on this PR | CI-gating; green required to merge | +| 5 | the 0.4.1 payload live: split labels machinery dogfooding on this very PR | this PR's checks | `labels / scope` + `labels / trigger` green, and NO `labels / reconcile` check attached — the #209 acceptance shape, observed on the first post-split PR in this repo | + +Probe 5 is the one piece 0.4.0's drill could not have covered: the sweep +split is 0.4.1's payload, and this repo adopted it on merge (#211 split +`self-labels.yml` / `self-labels-sweep.yml`). Every PR opened after that +merge — this one included — is a live consumer-shaped proof that PR checks +carry scope and trigger only, with sweeps dispatched to the detached +`self-labels-sweep` runs. + +## Deviations + +No candidate-ref deviation arises: this record stages no scratch caller, so +nothing needs to resolve `CEREMONY_SELF_REF: "0.4.1"` before the tag exists. +The sibling crew migration is the post-tag consumer proof, per #212's +acceptance criteria. From 80da0a8a1f4c41cb715db4c5a3424fd619eb83d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 1 Aug 2026 18:15:54 +0000 Subject: [PATCH 005/162] =?UTF-8?q?chore:=20bump=20main=20to=200.4.2-dev?= =?UTF-8?q?=20=E2=80=94=20a=20dev=20install=20must=20not=20impersonate=200?= =?UTF-8?q?.4.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 267577d..7532512 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.1 +0.4.2-dev From 8db6c3ae29cf341891632bef7f2132bc93502ace Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 2 Aug 2026 13:23:41 +0000 Subject: [PATCH 006/162] =?UTF-8?q?feat:=20per-author=20review=20panels=20?= =?UTF-8?q?=E2=80=94=20labels.conf=20gains=20panel[]=3D=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One resolution point (panel_for_author) feeds set_required_bots; the author's row when the conf defines one, the base panel= otherwise, minus the author in either case. Bracket prefixes are matched quoted so the case patterns cannot glob (D7, panela= tripwire). configured_label_rows skips the rows so a dispatch bootstrap cannot mint a label named after one. BUILDER.md/REVIEWER.md carry the one D9 wording; CONSUMERS.md publishes the row as unreleased with the parse-failure warning. Refs #224 Co-Authored-By: Claude Fable 5 --- BUILDER.md | 9 ++- REVIEWER.md | 4 +- actions/labels-reconcile/labels-reconcile.sh | 79 +++++++++++++++++++- changelog.d/224.md | 6 ++ docs/CONSUMERS.md | 19 ++++- test/issueflow-reconcile.test.sh | 10 +++ test/labels-reconcile.test.sh | 43 +++++++++++ test/labels.test.sh | 68 +++++++++++++++++ 8 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 changelog.d/224.md diff --git a/BUILDER.md b/BUILDER.md index 13a395d..f8492e4 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -212,11 +212,12 @@ triage bug, and the move is to say so on the issue, not to guess. repo-specific facts such as the panel roster live in that repo's own CONTRIBUTING; the shared flow lives here and is not restated there.) -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 - issue is in. The PR repo's `.github/labels.conf` `panel=` line is the +1. Mark ready-for-review; request **the whole panel**. The panel is the PR + repo's `panel[]=` line if it defines one, else its `panel=` + line; minus the author in either case (#224) — and never the roster of + the repo the issue is in. The PR repo's `.github/labels.conf` is the machine's answer; its CONTRIBUTING roster is the human-readable answer, - and `panel=` governs if they disagree because that is what the state + and the conf governs if they disagree because that is what the state machine reads. If the PR repo names no roster, ask triage on the authorizing issue before marking ready-for-review; do not guess. You may request an off-panel reviewer, but say that their verdict is advisory and diff --git a/REVIEWER.md b/REVIEWER.md index 22f102f..b661c29 100644 --- a/REVIEWER.md +++ b/REVIEWER.md @@ -65,7 +65,9 @@ saw Y" outranks one that says "this looks like it might". wait for the repo to appear on a list: review is reversible read-plus-comment work, and the requester already decided it should happen. - **A request is authorization, not panel membership.** Convergence is - measured against the target repo's `panel=` roster minus the author. If you + measured against the target repo's `panel[]=` line if its + `labels.conf` defines one for the PR author, else its `panel=` line; minus + the author in either case (#224). If you are requested off-panel, post the verdict anyway and say in its body that it is advisory; neither your silence nor your request-changes is a gate the reconciler enforces. The nine-hour wait for kimi's off-panel verdict on diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 86ca3ab..432c841 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -37,6 +37,12 @@ fi HUMAN="${HUMAN_REVIEWER:-danmt}" BOTS=() +# Per-author panels (#224): parallel arrays because the conf is tiny and an +# associative array buys nothing but a bash-4 dependency statement. One entry +# per panel[]= row — PANEL_AUTHORS holds the login, PANEL_ROWS the +# space-joined reviewer set at the same index. +PANEL_AUTHORS=() +PANEL_ROWS=() REQUIRED_BOTS=() STATES=(state:building state:bots-reviewing state:addressing state:needs-human) BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested) @@ -127,8 +133,16 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional return 1 } BOTS=() + PANEL_AUTHORS=() + PANEL_ROWS=() + # shellcheck disable=SC2094 # parse_panel_author_row takes $conf for its + # error messages only — nothing in this loop writes the file it reads while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue + # The panel[ prefix is matched QUOTED (#224 D7): in a case pattern an + # unquoted panel[abc]=* is a bracket expression that matches panela=…, + # panelb=…, panelc=… — silently rerouting ordinary settings. The + # panela= tripwire in test/labels.test.sh goes red if this regresses. case "$line" in panel=*) [ "$panel_seen" = false ] || { @@ -142,6 +156,7 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional return 1 } ;; + "panel["*) parse_panel_author_row "$line" "$conf" || return ;; triage-actors=*) ;; *) parse_label_row "$line" >/dev/null || return ;; esac @@ -152,6 +167,43 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional } } +parse_panel_author_row() { # panel[]= (#224) + # Every failure here is a hard one that names the offending line (D3): a + # conf error takes the whole board down, and the run log is the only place + # the operator can read why. A malformed bracket is refused AS a bracket + # (D4) — falling through to parse_label_row would report it as a + # "malformed label row", the misleading diagnostic #224 was filed over. + local line="$1" conf="$2" login rest existing + case "$line" in + "panel["*"]="*) ;; + *) + echo "labels: malformed panel[]= row (expected panel[]=): $line in $conf" >&2 + return 1 + ;; + esac + login="${line#panel[}" + login="${login%%]=*}" + [ -n "$login" ] || { + echo "labels: empty login in panel row: $line in $conf" >&2 + return 1 + } + for existing in ${PANEL_AUTHORS[@]+"${PANEL_AUTHORS[@]}"}; do + [ "$existing" != "$login" ] || { + echo "labels: duplicate panel[$login]= row in $conf: $line" >&2 + return 1 + } + done + local -a row=() + rest="${line#*]=}" + read -r -a row <<<"$rest" + [ "${#row[@]}" -gt 0 ] || { + echo "labels: panel[$login]= must name at least one reviewer in $conf: $line" >&2 + return 1 + } + PANEL_AUTHORS+=("$login") + PANEL_ROWS+=("${row[*]}") +} + parse_label_row() { # exact name|color|description; pipes in descriptions are refused local line="$1" name color desc extra IFS='|' read -r name color desc extra <<<"$line" @@ -167,15 +219,38 @@ configured_label_rows() { # validated scope rows, excluding the panel setting [ -f "$conf" ] || return 0 while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue - case "$line" in panel=* | triage-actors=*) continue ;; esac + # "panel["* quoted for the same D7 reason as load_config's case; skipping + # the bracketed rows (D5) keeps a dispatch bootstrap from trying to + # create a label named panel[]. + case "$line" in panel=* | "panel["* | triage-actors=*) continue ;; esac parse_label_row "$line" || return done <"$conf" } +panel_for_author() { # $1 = author → the effective panel, space-joined (#224 D2) + # THE resolution point: the author's panel[]= row when the conf + # defines one, the base panel= otherwise. Everything that computes a + # required set goes through here, because two places computing the panel + # is how the engine and the reconciler came to disagree in the first place. + local author="$1" i + for i in ${PANEL_AUTHORS[@]+"${!PANEL_AUTHORS[@]}"}; do + if [ "${PANEL_AUTHORS[i]}" = "$author" ]; then + printf '%s\n' "${PANEL_ROWS[i]}" + return + fi + done + printf '%s\n' "${BOTS[*]}" +} + set_required_bots() { # the PR author is recused by construction + # Minus-the-author applies to WHICHEVER set panel_for_author returns (#224 + # D2's safety net): an author who mistakenly appears inside its own + # bracketed row is still recused. local author="$1" bot + local -a effective=() + read -r -a effective <<<"$(panel_for_author "$author")" REQUIRED_BOTS=() - for bot in "${BOTS[@]}"; do + for bot in ${effective[@]+"${effective[@]}"}; do [ "$bot" = "$author" ] || REQUIRED_BOTS+=("$bot") done } diff --git a/changelog.d/224.md b/changelog.d/224.md new file mode 100644 index 0000000..cac5b2a --- /dev/null +++ b/changelog.d/224.md @@ -0,0 +1,6 @@ +### Added + +- `labels.conf` accepts optional `panel[]=` rows: the required set for + a PR authored by that login is the row minus the author; other authors keep + `panel=`. Consumers gain the row at their next pin bump — adding it before + that bump is a parse failure that takes the label board down (#224). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 82931bb..0afb58c 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -482,10 +482,12 @@ quiet repo wears that flag until the backstop cron; a consumer picks them up by pinning `0.3.0` or later, never through mixed refs. `.github/labels.conf` has one mandatory panel setting, one mandatory -`triage-actors` setting, and then zero or more scope rows: +`triage-actors` setting, zero or more optional per-author panel rows, and +then zero or more scope rows: ```text panel=claude-bot example-codex-bot example-grok-bot +panel[example-builder]=example-codex-bot example-grok-bot triage-actors=example-triage-bot scope:cli|C5DEF5|The command-line surface scope:docs|C5DEF5|Documentation @@ -497,11 +499,24 @@ rows only; adding `triage-actors=` is a parse failure, not an ignored setting. Add it at the same pin bump as the `issues:` trigger — `0.2.0` or later — never before it and never through mixed refs. +The optional `panel[]=` rows are **unreleased** (#224). A row names +the effective panel for PRs authored by exactly that login — the reconciler +computes that PR's required set from the row, minus the author as always — +and every other author keeps the base `panel=`, which stays mandatory. The +panel is configured or it is the base one: ceremony never infers a reviewer +set from the model behind a login. On any earlier pin a bracketed row is a +**parse failure, not an ignored setting** — the same shape `triage-actors=` +bought at `0.2.0`, but harsher in practice: the reconcile job dies on every +PR event and every sweep until the row is removed, so the whole label board +goes down. Add the row only at or after the pin bump that carries it, never +before it and never through mixed refs. + Both actor lists are whitespace-separated. `triage-actors` names the identities allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly `name|color|description`; blank lines are ignored and extra pipes are refused. There are no comment lines: every non-blank line must be the `panel=` -setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose +setting, a `panel[]=` row, the `triage-actors=` setting, or a label +row, so `#`-prefixed prose is a parse failure, not a comment (rig #13's conversion found this the hard way — keep the file data only). Core state, blocker, work-queue, and release labels come from ceremony. Scope diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index cfc83ae..7f7c10b 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -22,6 +22,16 @@ printf '%s\n' 'panel=one' >"$TMP/missing.conf" check "missing triage actors fails loudly" 1 "missing triage-actors=" load_issueflow_config "$TMP/missing.conf" printf '%s\n' 'triage-actors=one' 'triage-actors=two' >"$TMP/duplicate.conf" check "duplicate triage actors fails loudly" 1 "duplicate triage-actors" load_issueflow_config "$TMP/duplicate.conf" +# A panel[]= row (#224 D8) must not take the issue board down: this +# loader ignores every line that is not triage-actors=. That tolerance was +# incidental; this row makes it deliberate, so a future tightening cannot +# break the sweep as a side effect. +printf '%s\n' \ + 'panel=one two' \ + 'panel[builder-z]=two' \ + 'triage-actors=triage-one' >"$TMP/bracketed.conf" +check "a per-author panel row is tolerated by the issue-flow loader" 0 "" \ + load_issueflow_config "$TMP/bracketed.conf" # The dogfood caller and reusable workflow must expose the same runtime facts # as the documented consumer stub. Static pins catch YAML blocks drifting to diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index ff42d69..8752026 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -986,5 +986,48 @@ for ev in schedule pull_request_target; do expect "...and deletes nothing" \ no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)" done +# -- per-author panels (#224): the required set flows from the one ---------- +# resolution point, and convergence counts the effective set — never the +# base panel beside a reduced request set (the must-fail the issue names) +PANEL_DIR="$RTMP/panel-author" +mkdir -p "$PANEL_DIR" +printf '%s\n' 'panel=bot-a bot-b bot-c bot-d' \ + 'panel[builder-z]=bot-b bot-c bot-d' >"$PANEL_DIR/labels.conf" +load_config "$PANEL_DIR/labels.conf" +set_required_bots builder-z +expect "a bracketed author requires exactly its configured row" \ + "bot-b bot-c bot-d" "${REQUIRED_BOTS[*]}" +set_required_bots bot-a +expect "an unbracketed author beside a bracketed row requires panel minus self" \ + "bot-b bot-c bot-d" "${REQUIRED_BOTS[*]}" +set_required_bots outsider +expect "an unbracketed non-panelist author requires the whole base panel" \ + "bot-a bot-b bot-c bot-d" "${REQUIRED_BOTS[*]}" + +# The engine shape: the three configured reviewers approving the head IS the +# whole round for a bracketed author — bot-a's absent verdict must not hold +# convergence, or the request side and the convergence side disagree forever +# (the deadlock crew#285 was filed over). +set_required_bots builder-z +THREE_APPROVE="$(reviews \ + "$(rev bot-b APPROVED head1 ok 2026-08-02T10:00:00Z)" \ + "$(rev bot-c APPROVED head1 ok 2026-08-02T10:01:00Z)" \ + "$(rev bot-d APPROVED head1 ok 2026-08-02T10:02:00Z)")" +DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$THREE_APPROVE" \ + MERGEABLE=MERGEABLE CHECKS=SUCCESS LABELS="" +expect "the bracketed author's round converges on its three approvals" \ + state:needs-human "$(decide_state)" +expect "...with no blocker standing" "" "$(blockers)" +# The control: the same three approvals under the base panel are NOT a full +# round — the fourth verdict is owed and unrequested. If this pair ever +# reads the same, one side stopped consulting the resolution point. +printf '%s\n' 'panel=bot-a bot-b bot-c bot-d' >"$PANEL_DIR/labels.conf" +load_config "$PANEL_DIR/labels.conf" +set_required_bots builder-z +expect "without the row the same approvals leave the round incomplete" \ + state:addressing "$(decide_state)" +expect "...and the owed, unasked verdict is named" \ + blocker:unrequested "$(blockers)" + printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] diff --git a/test/labels.test.sh b/test/labels.test.sh index e08d7c0..0e1eb92 100755 --- a/test/labels.test.sh +++ b/test/labels.test.sh @@ -53,6 +53,74 @@ load_config "$TMP/good.conf" set_required_bots two check "PR author is recused from the required panel" 0 "one three" printf '%s\n' "${REQUIRED_BOTS[*]}" +# -- per-author panel rows (#224): the config-parse matrix ------------------- +# required_for loads a conf fresh in a subshell and prints the required set +# behind a RESULT: anchor, so substring matching cannot confuse "b c" with +# "a b c". +# shellcheck disable=SC2016 # expansion belongs to the nested bash +required_for() { # $1 = conf, $2 = author → RESULT: + bash -c 'source "$1"; load_config "$2" || exit 1 + set_required_bots "$3"; printf "RESULT:%s\n" "${REQUIRED_BOTS[*]}"' _ \ + "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$1" "$2" +} +printf '%s\n' 'panel=a b c' >"$TMP/plain.conf" +check "no bracketed row: panelist author gets panel minus self" 0 "RESULT:b c" \ + required_for "$TMP/plain.conf" a +check "no bracketed row: outside author gets the whole panel" 0 "RESULT:a b c" \ + required_for "$TMP/plain.conf" z +printf '%s\n' 'panel=a b c' 'panel[z]=b c' >"$TMP/author.conf" +check "bracketed author gets exactly its row" 0 "RESULT:b c" \ + required_for "$TMP/author.conf" z +check "unbracketed author beside a bracketed row is unchanged" 0 "RESULT:b c" \ + required_for "$TMP/author.conf" a +printf '%s\n' 'panel[z]=b c' 'panel=a b c' >"$TMP/reversed.conf" +check "row order is irrelevant: bracketed row before panel=" 0 "RESULT:b c" \ + required_for "$TMP/reversed.conf" z +check "row order is irrelevant for the base panel too" 0 "RESULT:b c" \ + required_for "$TMP/reversed.conf" a +printf '%s\n' 'panel=a b c' 'panel[a]=a b' >"$TMP/self.conf" +check "author inside its own bracketed row is still recused" 0 "RESULT:b" \ + required_for "$TMP/self.conf" a +# shellcheck disable=SC2016 # expansion belongs to the nested bash +check "base panel is byte-identical with the bracketed rows deleted" 0 "SAME" \ + bash -c 'source "$1"; load_config "$2"; with="${BOTS[*]}" + load_config "$3"; [ "$with" = "${BOTS[*]}" ] && echo SAME' _ \ + "$ROOT/actions/labels-reconcile/labels-reconcile.sh" \ + "$TMP/author.conf" "$TMP/plain.conf" +printf '%s\n' 'panel=a b c' 'panel[z]=b' 'panel[z]=c' >"$TMP/dup-author.conf" +check "duplicate rows for one login fail naming the line" 1 \ + "duplicate panel[z]= row" load_config "$TMP/dup-author.conf" +printf '%s\n' 'panel=a b c' 'panel[z]=' >"$TMP/empty-set.conf" +check "a bracketed row naming zero reviewers fails loudly" 1 \ + "panel[z]= must name at least one reviewer" load_config "$TMP/empty-set.conf" +printf '%s\n' 'panel=a b c' 'panel[]=b c' >"$TMP/empty-login.conf" +check "an empty login fails loudly" 1 "empty login in panel row" \ + load_config "$TMP/empty-login.conf" +printf '%s\n' 'panel=a b c' 'panel[z=b c' >"$TMP/broken-bracket.conf" +check "a malformed bracket is refused as a bracket (D4)" 1 \ + "malformed panel[]= row" load_config "$TMP/broken-bracket.conf" +# shellcheck disable=SC2016 # expansion belongs to the nested bash +check "...and never as a label row" 1 "" bash -c \ + 'source "$1"; load_config "$2" 2>&1 | grep -F "malformed label row"' _ \ + "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/broken-bracket.conf" +# The D7 tripwire: in a case pattern an unquoted panel[abc]=* is a bracket +# expression matching panela=… — this row going green as a panel setting is +# exactly the silent mis-route the quoted prefix exists to prevent. +printf '%s\n' 'panel=a b c' 'panela=b c' >"$TMP/glob-guard.conf" +check "panela= is still a malformed label row, never a panel setting (D7)" 1 \ + "malformed label row" load_config "$TMP/glob-guard.conf" +printf '%s\n' 'panel[z]=b c' >"$TMP/bracket-only.conf" +check "a bracketed row does not satisfy the mandatory panel=" 1 \ + "missing panel= line" load_config "$TMP/bracket-only.conf" +printf '%s\n' 'panel=a b c' 'panel[z]=b c' \ + 'scope:one|C5DEF5|First scope' >"$TMP/mixed.conf" +check "configured_label_rows returns the scope rows alone" 0 \ + "scope:one|C5DEF5|First scope" configured_label_rows "$TMP/mixed.conf" +# shellcheck disable=SC2016 # expansion belongs to the nested bash +check "no panel[...] row reaches the bootstrap" 1 "" bash -c \ + 'source "$1"; configured_label_rows "$2" | grep -F "panel["' _ \ + "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/mixed.conf" + # LABELS.md is mirrored byte-identically into every governed repo, so any # scope enumeration it carries is true at home and false everywhere else — # 14 of 16 vendored rows were false across the family when this fired (#104). From 7c53267377934a2bdb48dab15ddd2d5f2c40a684 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 2 Aug 2026 13:25:59 +0000 Subject: [PATCH 007/162] fix: a standing non-approving verdict outranks draft in decide_state round_outranks_draft consults the round before draft short-circuits: a re-drafted PR carrying CHANGES_REQUESTED, an owed round-reply, or push-staled approvals reads state:addressing; a live panel request on a draft surfaces as state:bots-reviewing rather than being absorbed (the must-not-paper-over combination, decided as: visible). Approvals do not outrank draft, so a draft never reads needs-human, and a virgin draft is byte-identical to before. LABELS.md's state:building row makes draft evidence, not the definition. Refs #205 Co-Authored-By: Claude Fable 5 --- LABELS.md | 2 +- actions/labels-reconcile/labels-reconcile.sh | 31 +++++++++++- changelog.d/205.md | 6 +++ test/labels-reconcile.test.sh | 52 ++++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 changelog.d/205.md diff --git a/LABELS.md b/LABELS.md index 8eba639..7402bd4 100644 --- a/LABELS.md +++ b/LABELS.md @@ -17,7 +17,7 @@ and the reconciler recomputes it from GitHub's own facts. | Label | Color | Waiting on | |---|---|---| -| `state:building` | `#FBCA04` | the builder — PR is a draft | +| `state:building` | `#FBCA04` | the builder — pre-round: no verdict stands against the head. Draft is evidence for it, not the definition of it: a draft carrying a standing non-approving verdict is a fix round and reads `state:addressing` (#205) | | `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, or a ruling is pending | | `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head | diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 432c841..9df22b8 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -469,8 +469,37 @@ blockers() { # → the blocker:* labels this PR should carry, one per line fi } +round_outranks_draft() { # 0 when the round's standing word survives a re-draft (#205) + # A standing non-approving verdict outranks draft: a PR that took a round, + # carries CHANGES_REQUESTED (or a comment owed a reply, or approvals a push + # staled), and is then converted back to draft is a fix round in progress, + # not a build — and hiding it behind state:building is a dropped ball the + # staleness sweep reads as work in progress. Approvals do NOT outrank + # draft: a re-draft after a passed round is deliberately building again, + # and a draft must never read state:needs-human. + # + # A LIVE panel request on a draft also falls through — deliberately + # surfaced, not absorbed (#205's must-not-paper-over): the bots ignore + # drafts by design, so a draft wearing state:bots-reviewing on the board + # is the visible symptom of a real defect (a request nobody cleared at + # round close, or a hand-requested draft), and reading it as building + # would hide exactly that. + local b + for b in "${REQUIRED_BOTS[@]}"; do + requested "$b" && return 0 + case "$(bot_verdict "$b")" in BLOCK | FEEDBACK | STALE) return 0 ;; esac + done + [ "$(bot_verdict "$HUMAN")" = BLOCK ] +} + decide_state() { # → the one state:* label this PR should carry - if [ "$DRAFT" = true ]; then echo state:building; return; fi + # Draft decides the state only when the round implies nothing else (#205): + # a draft with no round history reads state:building exactly as it always + # has, and round_outranks_draft is what "nothing else" means. + if [ "$DRAFT" = true ] && ! round_outranks_draft; then + echo state:building + return + fi local s s="$(round_state)" diff --git a/changelog.d/205.md b/changelog.d/205.md new file mode 100644 index 0000000..d1c2371 --- /dev/null +++ b/changelog.d/205.md @@ -0,0 +1,6 @@ +### Fixed + +- A standing non-approving verdict now outranks draft in `decide_state`: a + re-drafted PR mid-round reads `state:addressing`, a live panel request on a + draft surfaces as `state:bots-reviewing`, and a draft with no round history + still reads `state:building` (#205). diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 8752026..accba15 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -986,6 +986,58 @@ for ev in schedule pull_request_target; do expect "...and deletes nothing" \ no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)" done +# -- a re-drafted fix round is not a build (#205) ---------------------------- +# Draft used to short-circuit decide_state before the round was consulted, so +# a PR carrying a standing CHANGES_REQUESTED that its builder converted back +# to draft read state:building — and the staleness sweep read a dropped fix +# round as a build in progress. +load_config .github/labels.conf +set_required_bots codex-bot-andresmgsl +MERGEABLE=MERGEABLE CHECKS=SUCCESS LABELS="" HEAD_SHA=head1 +DRAFT=true REQUESTED="" REVIEWS_JSON="$(reviews \ + "$(rev "$BOT1" CHANGES_REQUESTED head1 no t1)" \ + "$(rev "$BOT2" APPROVED head1 ok t2)" \ + "$(rev "$BOT3" APPROVED head1 ok t3)")" +expect "a re-drafted PR with a standing block is addressing, not building" \ + state:addressing "$(decide_state)" +REVIEWS_JSON="$(reviews "$(rev "$BOT1" COMMENTED head1 thoughts t1)")" +expect "a re-drafted PR owing a round-reply is addressing" \ + state:addressing "$(decide_state)" +REVIEWS_JSON="$(reviews \ + "$(rev "$BOT1" APPROVED head0 ok t1)" \ + "$(rev "$BOT2" APPROVED head0 ok t2)" \ + "$(rev "$BOT3" APPROVED head0 ok t3)")" +expect "a re-drafted PR whose approvals a push staled is addressing" \ + state:addressing "$(decide_state)" +REVIEWS_JSON="$(reviews \ + "$(rev "$BOT1" APPROVED head1 ok t1)" \ + "$(rev "$BOT2" APPROVED head1 ok t2)" \ + "$(rev "$BOT3" APPROVED head1 ok t3)" \ + "$(rev "$HUMAN" CHANGES_REQUESTED head1 no t4)")" +expect "the human's standing changes-requested outranks draft too" \ + state:addressing "$(decide_state)" +# Approvals do NOT outrank draft: a re-draft after a passed round is +# deliberately building again — and a draft must never read needs-human. +REVIEWS_JSON="$(reviews \ + "$(rev "$BOT1" APPROVED head1 ok t1)" \ + "$(rev "$BOT2" APPROVED head1 ok t2)" \ + "$(rev "$BOT3" APPROVED head1 ok t3)")" +expect "a re-draft after a passed round is building again" \ + state:building "$(decide_state)" +REQUESTED="$HUMAN" +expect "...even with the human requested — a draft never reads needs-human" \ + state:building "$(decide_state)" +# The must-not-paper-over combination: a live panel request on a draft is a +# board defect (the bots ignore drafts by design) and stays VISIBLE as +# bots-reviewing rather than being absorbed into building. +REQUESTED="$BOT2" REVIEWS_JSON='[]' +expect "a live panel request on a draft surfaces as bots-reviewing" \ + state:bots-reviewing "$(decide_state)" +# The byte-identical baseline: a virgin draft still reads building. +REQUESTED="" REVIEWS_JSON='[]' +expect "a draft with no round history still reads building" \ + state:building "$(decide_state)" + # -- per-author panels (#224): the required set flows from the one ---------- # resolution point, and convergence counts the effective set — never the # base panel beside a reduced request set (the must-fail the issue names) From ecb0371cad31397e4e07882e64fd9eca7cfc5a25 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 2 Aug 2026 13:27:06 +0000 Subject: [PATCH 008/162] docs: clear five stale unreleased markers; the release PR owns clearing Each marker now says available-at-tag in the guide's existing L420 phrasing, verified by tag containment in #221; every never-mix-refs sentence survives verbatim. The convention paragraph gains its missing half: the release PR that ships machinery clears, in that same PR, every marker its assembled section makes false. Refs #221 Co-Authored-By: Claude Fable 5 --- changelog.d/221.md | 9 +++++++++ docs/CONSUMERS.md | 44 +++++++++++++++++++++++++------------------- 2 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 changelog.d/221.md diff --git a/changelog.d/221.md b/changelog.d/221.md new file mode 100644 index 0000000..e1b4997 --- /dev/null +++ b/changelog.d/221.md @@ -0,0 +1,9 @@ +### Fixed + +- Five stale **unreleased** markers in `docs/CONSUMERS.md` now name their + tags: fragment mode, `changelog-assembled` and `runner-isolated` at + `0.2.0`; the additive labeler at `0.3.0`; the two-caller split at `0.4.1` + (#221). +- The marker convention now names its clearing owner: the release PR that + ships machinery clears, in that same PR, every marker its assembled + section makes false (#221). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 0afb58c..e00e085 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -60,10 +60,10 @@ the machinery at all: the release PR assembles the section ([Assembling a release section](#assembling-a-release-section)). - Fragment mode is **unreleased** and not in `0.1.0`. A consumer pinned - to `0.1.0` bootstraps the legacy shape instead — the preamble plus an - empty `## Unreleased` section for entries to land under — and converts - on the pin bump to the first tag carrying fragment mode; never mix + Fragment mode is available at `0.2.0` and later, and not in `0.1.0`. + A consumer pinned to `0.1.0` bootstraps the legacy shape instead — the + preamble plus an empty `## Unreleased` section for entries to land + under — and converts on the pin bump to `0.2.0` or later; never mix refs to adopt it early. 3. **`drills/README.md`** defining what a drill *means* in this repo — each repo names its own @@ -85,14 +85,16 @@ the machinery at all: fetch-depth: 0 - uses: heavy-duty/ceremony/actions/changelog-armed@ - uses: heavy-duty/ceremony/actions/changelog-monotonic@ - # Unreleased: changelog-assembled is not in 0.1.0. Adopt this step - # with the pin bump to the first tag that carries it; never mix - # refs. Green NOTICE on every non-release PR; on a release PR it - # asserts the stamped section is exactly the fragments it consumed. + # changelog-assembled is available at 0.2.0 and later, not in + # 0.1.0. Adopt this step with the pin bump to 0.2.0 or later; + # never mix refs. Green NOTICE on every non-release PR; on a + # release PR it asserts the stamped section is exactly the + # fragments it consumed. - uses: heavy-duty/ceremony/actions/changelog-assembled@ - uses: heavy-duty/ceremony/actions/drill-recorded@ - # Unreleased: runner-isolated is not in 0.1.0. Adopt this step with - # the pin bump to the first tag that carries it; never mix refs. + # runner-isolated is available at 0.2.0 and later, not in 0.1.0. + # Adopt this step with the pin bump to 0.2.0 or later; never mix + # refs. - uses: heavy-duty/ceremony/actions/runner-isolated@ ``` @@ -112,7 +114,11 @@ the machinery at all: somebody adds one. This guide documents `main`. New machinery is marked **unreleased** - here until a release tag ships it. If an action does not exist at the + here until a release tag ships it — and the release PR that ships the + machinery clears, in that same PR, every marker its own assembled + section makes false: the section cites its issues, each marker cites + the same issue, and the release PR's diff is the one place both + halves are visible at once (#221). If an action does not exist at the consumer's pinned tag, adopt it with the pin bump to the first tag that carries it; never mix a moving or newer ref into an otherwise exact-pin consumer. In particular, `0.1.0` carries `changelog-armed`, @@ -298,7 +304,7 @@ together at the same pin: The consumer keeps its path mapping in `.github/labeler.yml` and its review panel plus scope taxonomy in `.github/labels.conf`. -**Additive means additive** (unreleased — #130): the scope job's only label +**Additive means additive** (available at `0.3.0` and later — #130): the scope job's only label write is `POST /issues/{n}/labels`, which adds the derived scopes and removes nothing, so a label applied while the job runs survives it. Earlier tags used `actions/labeler@v5`, which — even under `sync-labels: false` — replaces the @@ -436,16 +442,16 @@ 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. -The two-caller split (ceremony#209) is **unreleased**. A consumer pinned to -`0.4.0` or earlier keeps the previous single-caller shape — the labels -caller carrying the cron, `workflow_dispatch`, and `actions: read` — and -adopts the split at the pin bump to the first tag carrying ceremony#209. -Never mix refs to adopt it early. +The two-caller split (ceremony#209) is available at `0.4.1` and later. A +consumer pinned to `0.4.0` or earlier keeps the previous single-caller +shape — the labels caller carrying the cron, `workflow_dispatch`, and +`actions: read` — and adopts the split at the pin bump to `0.4.1` or +later. Never mix refs to adopt it early. The migration is **one atomic PR** with exactly four edits — crew, the consumer whose displaced-check evidence drove #209 (crew#227, crew#250), -is the worked example; written here against `0.4.1` as the illustrative -first tag carrying the split: +is the worked example; written here against `0.4.1`, the first tag +carrying the split: 1. **Pin bump, every reference together** ([Version pinning](#version-pinning)): `0.4.0` → `0.4.1` in the labels caller's `uses:` line **and in every From 6f245639ca013cd8446323d525a33055441db20b Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 2 Aug 2026 13:27:40 +0000 Subject: [PATCH 009/162] =?UTF-8?q?docs:=20the=20write-capable=20token=20r?= =?UTF-8?q?ule=20=E2=80=94=20repo-owned=20by=20default,=20established=20pu?= =?UTF-8?q?blishers=20only,=20SHA-pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruling from discussion #171 as ruled: canonical text in REVIEWER.md §What you review against item 2 (beside the verify-at-pin sub-bullet it is the sibling of), short form in BUILDER.md §Building pointing at it. CONTRIBUTING.md and docs/CONSUMERS.md checked for contradiction or duplication: none — their pin prose is the mirror/caller pinning rule — so both are deliberately untouched. Refs #216 Co-Authored-By: Claude Fable 5 --- BUILDER.md | 7 +++++++ REVIEWER.md | 13 +++++++++++++ changelog.d/216.md | 7 +++++++ 3 files changed, 27 insertions(+) create mode 100644 changelog.d/216.md diff --git a/BUILDER.md b/BUILDER.md index f8492e4..0ab03f1 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -200,6 +200,13 @@ triage bug, and the move is to say so on the issue, not to guess. guard still refuses anything that deletes a shipped heading. - 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. +- **A write-capable job gets a repo-owned script, not a third-party action.** + If the job's token can write (`packages: write`, `contents: write`, + `id-token: write`, deploy secrets), default to a script in the repo that a + test can drive; a third-party action there needs an established publisher + and a full-commit-SHA pin. Read-only jobs still SHA-pin. The full rule and + the red-flag profile a reviewer will apply are in REVIEWER.md §What you + review against, item 2 (incubator#53/#54; #216). - **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 — diff --git a/REVIEWER.md b/REVIEWER.md index b661c29..f41565b 100644 --- a/REVIEWER.md +++ b/REVIEWER.md @@ -49,6 +49,19 @@ In order of authority: `0.1.0`'s `load_config` rejected `triage-actors=...` with `malformed label row` and `exit=1`. CI green on a conversion PR proves nothing about the new config: the base branch's workflow is what ran. + - **Third-party actions never hold a write-capable token by default.** In + any job whose token is write-capable (`packages: write`, + `contents: write`, `id-token: write`, or one carrying deploy secrets), + the default is a repo-owned script a test can drive. A third-party + action may hold that token only if it comes from an **established + publisher** — a real organization with maintenance history and more + than one maintainer, not a memberless shell or a lone account shipping + an unauditable `dist/` blob — and is **pinned by full commit SHA**. An + action matching the incubator red-flag profile never holds a write + token, however well it works. Read-only jobs: ordinary dependency + judgement, SHA-pinning still required. This is bot-run infrastructure — + no human watches runtime logs, so a compromised action's window is + unbounded (incubator#53/#54; #216). 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 diff --git a/changelog.d/216.md b/changelog.d/216.md new file mode 100644 index 0000000..f111098 --- /dev/null +++ b/changelog.d/216.md @@ -0,0 +1,7 @@ +### Changed + +- Doctrine: third-party actions never hold a write-capable token by default — + repo-owned scripts in write-capable jobs, established publisher plus + full-SHA pin for the exception, SHA pins everywhere. Canonical in + REVIEWER.md, short form in BUILDER.md; consumers adopt at the pin bump + (#216). From 069faf481a7b99b3231407ef721a9d6f50c30646 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 2 Aug 2026 14:13:25 +0000 Subject: [PATCH 010/162] =?UTF-8?q?fix:=20refuse=20a=20bracket=20login=20t?= =?UTF-8?q?hat=20is=20not=20[A-Za-z0-9-]=20=E2=80=94=20round=201,=20codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit panel[z]]=b parsed at the round-1 head: the case pattern only proves some ]= occurs, so the stray ] stayed inside the login and the real author silently fell back to the base panel — the misroute D4 exists to refuse. The login charset is now enforced with the bracket-specific diagnostic; codex's probe and an invalid-character row are the new must-fail fixtures. Refs #224 Co-Authored-By: Claude Fable 5 --- actions/labels-reconcile/labels-reconcile.sh | 28 +++++++++++++++++++- test/labels.test.sh | 8 ++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 9df22b8..cd573f6 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -187,6 +187,18 @@ parse_panel_author_row() { # panel[]= (#224) echo "labels: empty login in panel row: $line in $conf" >&2 return 1 } + # The login must be exactly one well-formed bracket pair of login + # characters. Without this, panel[z]]=b parses: the case above only + # establishes that SOME ]= occurs, ${login%%]=*} keeps the stray ] inside + # the login (z]), and set_required_bots for the real z then silently falls + # back to the base panel — the misroute D4 exists to refuse. GitHub logins + # are [A-Za-z0-9-], per the #285 spec. + case "$login" in + *[!A-Za-z0-9-]*) + echo "labels: malformed panel[]= row (a login is [A-Za-z0-9-] only): $line in $conf" >&2 + return 1 + ;; + esac for existing in ${PANEL_AUTHORS[@]+"${PANEL_AUTHORS[@]}"}; do [ "$existing" != "$login" ] || { echo "labels: duplicate panel[$login]= row in $conf: $line" >&2 @@ -504,6 +516,20 @@ decide_state() { # → the one state:* label this PR should carry local s s="$(round_state)" + # A draft disqualifies needs-human unconditionally (#205, round 1): with + # the short-circuit above now conditional, a draft carrying a live human + # request plus a standing bot block or comment fell through to + # round_state, whose explicit-human-request precedence sits above the + # BLOCK/FEEDBACK cases — and GitHub cannot merge a draft at all, so + # "a human could merge this right now" would lie no matter what the + # round says. state:addressing is the same honest landing the blocker/ + # needs-ruling/blocked clauses below use: the round's word stands, only + # the mergeable-now claim is off the table while the PR is a draft. + if [ "$s" = state:needs-human ] && [ "$DRAFT" = true ]; then + echo state:addressing + return + fi + # 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 @@ -606,7 +632,7 @@ round_state() { # → the state the REVIEW ROUND alone implies; knows no branch core_label_rows() { cat <<'EOF' -state:building|FBCA04|PR is a draft — the coding agent is still building +state:building|FBCA04|Pre-round: the builder is still building — draft is evidence for it, not the definition 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 diff --git a/test/labels.test.sh b/test/labels.test.sh index 0e1eb92..e4c9b6f 100755 --- a/test/labels.test.sh +++ b/test/labels.test.sh @@ -96,6 +96,14 @@ check "a bracketed row naming zero reviewers fails loudly" 1 \ printf '%s\n' 'panel=a b c' 'panel[]=b c' >"$TMP/empty-login.conf" check "an empty login fails loudly" 1 "empty login in panel row" \ load_config "$TMP/empty-login.conf" +# codex's round-1 probe: the stray ] used to parse, record login z], and +# silently misroute z to the base panel — exactly the D4 refusal owed. +printf '%s\n' 'panel=a b c' 'panel[z]]=b' >"$TMP/stray-bracket.conf" +check "a stray ] inside the bracket is refused as a bracket" 1 \ + "malformed panel[]= row" load_config "$TMP/stray-bracket.conf" +printf '%s\n' 'panel=a b c' 'panel[a_b]=c' >"$TMP/bad-login.conf" +check "a non-login character in the bracket is refused" 1 \ + "malformed panel[]= row" load_config "$TMP/bad-login.conf" printf '%s\n' 'panel=a b c' 'panel[z=b c' >"$TMP/broken-bracket.conf" check "a malformed bracket is refused as a bracket (D4)" 1 \ "malformed panel[]= row" load_config "$TMP/broken-bracket.conf" From 44b1a3d23c7201443ace74dc917f58fafef2e4e1 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 2 Aug 2026 14:13:25 +0000 Subject: [PATCH 011/162] =?UTF-8?q?fix:=20a=20draft=20never=20reads=20stat?= =?UTF-8?q?e:needs-human=20=E2=80=94=20round=201,=20claude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reorder let a draft with a live human request plus a standing block or comment fall through to round_state, whose human-request precedence sits above BLOCK/FEEDBACK — 224 of claude's 1500 fixture cases read needs-human on a PR GitHub cannot merge. decide_state now disqualifies needs-human unconditionally under DRAFT=true, landing on state:addressing like the blocker/needs-ruling/blocked clauses. The two new rows assert the criterion where it can actually fail: human requested x {CHANGES_REQUESTED, COMMENTED}. Also grok's nit: the bootstrap row for state:building now matches LABELS.md (draft is evidence, not the definition), and the CONSUMERS.md reflow nits are in. Refs #205 Co-Authored-By: Claude Fable 5 --- docs/CONSUMERS.md | 8 ++++---- test/labels-reconcile.test.sh | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index e00e085..3160b0a 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -304,7 +304,8 @@ together at the same pin: The consumer keeps its path mapping in `.github/labeler.yml` and its review panel plus scope taxonomy in `.github/labels.conf`. -**Additive means additive** (available at `0.3.0` and later — #130): the scope job's only label +**Additive means additive** (available at `0.3.0` and later — #130): the +scope job's only label write is `POST /issues/{n}/labels`, which adds the derived scopes and removes nothing, so a label applied while the job runs survives it. Earlier tags used `actions/labeler@v5`, which — even under `sync-labels: false` — replaces the @@ -522,9 +523,8 @@ allowed to mint issues without the sweep applying `needs-triage`. Label rows use `name|color|description`; blank lines are ignored and extra pipes are refused. There are no comment lines: every non-blank line must be the `panel=` setting, a `panel[]=` row, the `triage-actors=` setting, or a label -row, so `#`-prefixed prose -is a parse failure, not a comment (rig #13's conversion found this the hard -way — keep the file data only). +row, so `#`-prefixed prose is a parse failure, not a comment (rig #13's +conversion found this the hard way — keep the file data only). Core state, blocker, work-queue, and release labels come from ceremony. Scope rows remain consumer-owned because paths and surfaces differ by repository. diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index accba15..f1b0aa6 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -1027,6 +1027,25 @@ expect "a re-draft after a passed round is building again" \ REQUESTED="$HUMAN" expect "...even with the human requested — a draft never reads needs-human" \ state:building "$(decide_state)" +# Round 1's 224-case hole (claude's differential): a draft with a LIVE HUMAN +# REQUEST plus a standing block or comment fell through to round_state, +# whose human-request precedence sits above BLOCK/FEEDBACK — and read +# needs-human on a PR GitHub cannot merge. These are the same inputs as the +# addressing rows above with REQUESTED="$HUMAN", which is where the +# criterion can actually fail. +REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \ + "$(rev "$BOT1" CHANGES_REQUESTED head1 no t1)" \ + "$(rev "$BOT2" APPROVED head1 ok t2)" \ + "$(rev "$BOT3" APPROVED head1 ok t3)")" +expect "a draft with a human request and a standing block is addressing" \ + state:addressing "$(decide_state)" +REVIEWS_JSON="$(reviews \ + "$(rev "$BOT1" COMMENTED head1 thoughts t1)" \ + "$(rev "$BOT2" APPROVED head1 ok t2)" \ + "$(rev "$BOT3" APPROVED head1 ok t3)")" +expect "a draft with a human request and an owed reply is addressing" \ + state:addressing "$(decide_state)" + # The must-not-paper-over combination: a live panel request on a draft is a # board defect (the bots ignore drafts by design) and stays VISIBLE as # bots-reviewing rather than being absorbed into building. From 0df40f8d087676f3beb229ee0780b43d12665f98 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 3 Aug 2026 10:55:45 +0000 Subject: [PATCH 012/162] fix: select the best-shaped escalation, not the earliest Closes-adjacent contract in the PR body; authorizing issue #226. ruling_escalation_row scored every setter in-window row 0-4 by the shared field matcher; highest wins, equal scores break to the earliest epoch, an undecodable body scores 0. ruling_shape_decision now grades through the same matcher, so the selector and the check cannot drift (crew#293). Co-Authored-By: Claude Fable 5 --- changelog.d/226.md | 9 +++++++ lib/ruling.sh | 63 ++++++++++++++++++++++++++++++++++--------- test/ruling.test.sh | 65 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 changelog.d/226.md diff --git a/changelog.d/226.md b/changelog.d/226.md new file mode 100644 index 0000000..b945142 --- /dev/null +++ b/changelog.d/226.md @@ -0,0 +1,9 @@ +### Fixed + +- `ruling_escalation_row` selects the setter's best-shaped in-window comment, + ties broken to the earliest, instead of the earliest outright — a whole-round + reply landing seconds before the escalation is no longer graded in its place + (crew#293). +- The escalation selector and `ruling_shape_decision` share one field-presence + matcher, and an undecodable body column scores 0 instead of erroring the + sweep. diff --git a/lib/ruling.sh b/lib/ruling.sh index 119a5cf..9ac6b0b 100644 --- a/lib/ruling.sh +++ b/lib/ruling.sh @@ -99,18 +99,40 @@ ruling_bare_comment_needed() { # $1 labeled epoch, $2 newest marked-comment epoc fi } +ruling_shape_field_present() { # $1 field label; escalation body on stdin → 0 iff present + # THE one spelling of the field-presence test — the escalation selector + # scores by it and the shape check grades by it, on purpose in one place: + # a selector scoring by one grep while the check grades by another is how + # the crew#293 misgrade would come back from the other side (#226). + # Line-anchored, allowing leading whitespace and Markdown bold + # (`**Options:**` is how the live escalations write them): the labels + # appearing only mid-sentence is not the template. + local field="$1" + grep -Eq "^[[:space:]]*(\*\*)?$field" +} + +ruling_shape_score() { # escalation body on stdin → 0–4, one point per field present + # The selection rule's metric (#226). An empty body scores 0 through the + # same loop — no special case, and never an error. + local body field score=0 + body="$(cat)" + for field in "${RULING_SHAPE_FIELDS[@]}"; do + if ruling_shape_field_present "$field" <<<"$body"; then score=$((score + 1)); fi + done + echo "$score" +} + ruling_shape_decision() { # escalation body on stdin → SHAPED | MALFORMED # Presence only (#50 D4): that `Recommend:` exists is checkable, that the # recommendation is any good is not — no counting options, no parsing the - # prose. Line-anchored, allowing leading whitespace and Markdown bold - # (`**Options:**` is how the live escalations write them): the labels - # appearing only mid-sentence is not the template. The `🧭 needs-ruling` - # header line is deliberately unchecked — it is prose, and an emoji grep - # on an LC_ALL=C runner is a portability trap for zero enforcement value. + # prose. The per-field test is ruling_shape_field_present, shared with the + # selector (#226). The `🧭 needs-ruling` header line is deliberately + # unchecked — it is prose, and an emoji grep on an LC_ALL=C runner is a + # portability trap for zero enforcement value. local body field missing="" body="$(cat)" for field in "${RULING_SHAPE_FIELDS[@]}"; do - grep -Eq "^[[:space:]]*(\*\*)?$field" <<<"$body" || missing="$missing $field" + ruling_shape_field_present "$field" <<<"$body" || missing="$missing $field" done if [ -z "$missing" ]; then echo SHAPED; else echo "MALFORMED$missing"; fi } @@ -170,17 +192,32 @@ ruling_newest_flag() { # "loginiso8601" lines on stdin → the newest line } ruling_escalation_row() { # $1 setter, $2 labeled epoch; "login epoch url [b64]" lines on stdin - # → "url b64" of the EARLIEST in-window comment by the setter, or nothing. - # Earliest, because the natural shape is escalation-then-flag: the first - # qualifying comment is the escalation itself, later ones are follow-ups. - # The body rides along base64-encoded (#73's shape check reads it); rows - # without the column still resolve, with an empty body. - local setter="$1" labeled="$2" login epoch url b64 best_epoch="" best="" + # → "url b64" of the BEST-SHAPED in-window comment by the setter, or + # nothing: highest ruling_shape_score wins, equal scores break to the + # earliest epoch. Earliest-wins outright was the rule until crew#293 + # (2026-08-02): a builder answered its round whole and escalated 33 + # seconds later — both in one window, the reply earlier — and the sweep + # graded the round reply, told a correct escalation it was malformed, and + # the setter re-posted a shape it had already met. Score resolves both + # orderings; the earliest tiebreak keeps escalation-then-follow-ups + # wherever the scores cannot tell candidates apart, including all-zero. + # An undecodable or absent body scores 0 and stays a legal candidate — + # an unreadable fact never invents a verdict, and never errors the sweep. + # The window and the setter gate candidacy before any score is taken. + local setter="$1" labeled="$2" login epoch url b64 body score + local best_score=-1 best_epoch="" best="" while read -r login epoch url b64; do [ -n "$login" ] || continue [ "$login" = "$setter" ] || continue ruling_accompanies "$epoch" "$labeled" || continue - if [ -z "$best_epoch" ] || [ "$epoch" -lt "$best_epoch" ]; then + if body="$(base64 -d <<<"${b64:-}" 2>/dev/null)"; then + score="$(ruling_shape_score <<<"$body")" + else + score=0 + fi + if [ "$score" -gt "$best_score" ] \ + || { [ "$score" -eq "$best_score" ] && [ "$epoch" -lt "$best_epoch" ]; }; then + best_score="$score" best_epoch="$epoch" best="$url ${b64:-}" fi diff --git a/test/ruling.test.sh b/test/ruling.test.sh index 2eaffb7..6019bf9 100644 --- a/test/ruling.test.sh +++ b/test/ruling.test.sh @@ -109,6 +109,71 @@ check "labels only mid-sentence are malformed — line-anchoring is the rule" 0 check "an empty body is missing everything" 0 "MALFORMED Options: Recommend: Blocked: Default:" \ ruling_shape_decision Date: Mon, 3 Aug 2026 12:01:05 +0000 Subject: [PATCH 013/162] release: cut 0.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fragments assembled into '## 0.5.0 — 2026-08-03' (#205 #216 #221 #224 #226); VERSION to bare 0.5.0; the three CEREMONY_SELF_REF carriers stamped "0.5.0" in this one commit; the panel-rows unreleased marker in docs/CONSUMERS.md cleared to name 0.5.0; drills/0.5.0.md records the doors-unchanged ruling with the measurements as they are at 0ac3a6f. Refs #233. Co-Authored-By: Claude Fable 5 --- .github/workflows/labels-sweep.yml | 2 +- .github/workflows/labels.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 38 +++++++++++++++++++++++ VERSION | 2 +- changelog.d/205.md | 6 ---- changelog.d/216.md | 7 ----- changelog.d/221.md | 9 ------ changelog.d/224.md | 6 ---- changelog.d/226.md | 9 ------ docs/CONSUMERS.md | 2 +- drills/0.5.0.md | 50 ++++++++++++++++++++++++++++++ 12 files changed, 93 insertions(+), 42 deletions(-) delete mode 100644 changelog.d/205.md delete mode 100644 changelog.d/216.md delete mode 100644 changelog.d/221.md delete mode 100644 changelog.d/224.md delete mode 100644 changelog.d/226.md create mode 100644 drills/0.5.0.md diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml index ec50bb7..ffa2d61 100644 --- a/.github/workflows/labels-sweep.yml +++ b/.github/workflows/labels-sweep.yml @@ -49,7 +49,7 @@ on: env: # A called workflow arrives without its repository. Keep this literal pin # aligned with the ceremony release consumed by callers (issue #9 D3). - CEREMONY_SELF_REF: "0.4.1" + CEREMONY_SELF_REF: "0.5.0" jobs: reconcile: diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index ca159b8..46234d1 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -48,7 +48,7 @@ on: env: # A called workflow arrives without its repository. Keep this literal pin # aligned with the ceremony release consumed by callers (issue #9 D3). - CEREMONY_SELF_REF: "0.4.1" + CEREMONY_SELF_REF: "0.5.0" jobs: scope: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9081740..62e8928 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ env: # `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the # shared logic arrives as script files via checkout, not as inner `uses:` # references. - CEREMONY_SELF_REF: "0.4.1" + CEREMONY_SELF_REF: "0.5.0" VERSION_SOURCE: ${{ inputs.version-source }} jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 406114a..dcd452c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,44 @@ Entries arrive as fragments — one `changelog.d/.md` per PR, never an edit to this file — and the release PR assembles them into the next section here (`bin/changelog-assemble`, #112). +## 0.5.0 — 2026-08-03 + +### Added + +- `labels.conf` accepts optional `panel[]=` rows: the required set for + a PR authored by that login is the row minus the author; other authors keep + `panel=`. Consumers gain the row at their next pin bump — adding it before + that bump is a parse failure that takes the label board down (#224). + +### Changed + +- Doctrine: third-party actions never hold a write-capable token by default — + repo-owned scripts in write-capable jobs, established publisher plus + full-SHA pin for the exception, SHA pins everywhere. Canonical in + REVIEWER.md, short form in BUILDER.md; consumers adopt at the pin bump + (#216). + +### Fixed + +- `ruling_escalation_row` selects the setter's best-shaped in-window comment, + ties broken to the earliest, instead of the earliest outright — a whole-round + reply landing seconds before the escalation is no longer graded in its place + (crew#293). +- The escalation selector and `ruling_shape_decision` share one field-presence + matcher, and an undecodable body column scores 0 instead of erroring the + sweep. +- Five stale **unreleased** markers in `docs/CONSUMERS.md` now name their + tags: fragment mode, `changelog-assembled` and `runner-isolated` at + `0.2.0`; the additive labeler at `0.3.0`; the two-caller split at `0.4.1` + (#221). +- The marker convention now names its clearing owner: the release PR that + ships machinery clears, in that same PR, every marker its assembled + section makes false (#221). +- A standing non-approving verdict now outranks draft in `decide_state`: a + re-drafted PR mid-round reads `state:addressing`, a live panel request on a + draft surfaces as `state:bots-reviewing`, and a draft with no round history + still reads `state:building` (#205). + ## 0.4.1 — 2026-08-01 ### Changed diff --git a/VERSION b/VERSION index 7532512..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.2-dev +0.5.0 diff --git a/changelog.d/205.md b/changelog.d/205.md deleted file mode 100644 index d1c2371..0000000 --- a/changelog.d/205.md +++ /dev/null @@ -1,6 +0,0 @@ -### Fixed - -- A standing non-approving verdict now outranks draft in `decide_state`: a - re-drafted PR mid-round reads `state:addressing`, a live panel request on a - draft surfaces as `state:bots-reviewing`, and a draft with no round history - still reads `state:building` (#205). diff --git a/changelog.d/216.md b/changelog.d/216.md deleted file mode 100644 index f111098..0000000 --- a/changelog.d/216.md +++ /dev/null @@ -1,7 +0,0 @@ -### Changed - -- Doctrine: third-party actions never hold a write-capable token by default — - repo-owned scripts in write-capable jobs, established publisher plus - full-SHA pin for the exception, SHA pins everywhere. Canonical in - REVIEWER.md, short form in BUILDER.md; consumers adopt at the pin bump - (#216). diff --git a/changelog.d/221.md b/changelog.d/221.md deleted file mode 100644 index e1b4997..0000000 --- a/changelog.d/221.md +++ /dev/null @@ -1,9 +0,0 @@ -### Fixed - -- Five stale **unreleased** markers in `docs/CONSUMERS.md` now name their - tags: fragment mode, `changelog-assembled` and `runner-isolated` at - `0.2.0`; the additive labeler at `0.3.0`; the two-caller split at `0.4.1` - (#221). -- The marker convention now names its clearing owner: the release PR that - ships machinery clears, in that same PR, every marker its assembled - section makes false (#221). diff --git a/changelog.d/224.md b/changelog.d/224.md deleted file mode 100644 index cac5b2a..0000000 --- a/changelog.d/224.md +++ /dev/null @@ -1,6 +0,0 @@ -### Added - -- `labels.conf` accepts optional `panel[]=` rows: the required set for - a PR authored by that login is the row minus the author; other authors keep - `panel=`. Consumers gain the row at their next pin bump — adding it before - that bump is a parse failure that takes the label board down (#224). diff --git a/changelog.d/226.md b/changelog.d/226.md deleted file mode 100644 index b945142..0000000 --- a/changelog.d/226.md +++ /dev/null @@ -1,9 +0,0 @@ -### Fixed - -- `ruling_escalation_row` selects the setter's best-shaped in-window comment, - ties broken to the earliest, instead of the earliest outright — a whole-round - reply landing seconds before the escalation is no longer graded in its place - (crew#293). -- The escalation selector and `ruling_shape_decision` share one field-presence - matcher, and an undecodable body column scores 0 instead of erroring the - sweep. diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 3160b0a..99e1ce2 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -506,7 +506,7 @@ rows only; adding `triage-actors=` is a parse failure, not an ignored setting. Add it at the same pin bump as the `issues:` trigger — `0.2.0` or later — never before it and never through mixed refs. -The optional `panel[]=` rows are **unreleased** (#224). A row names +The optional `panel[]=` rows are available at `0.5.0` and later (#224). A row names the effective panel for PRs authored by exactly that login — the reconciler computes that PR's required set from the row, minus the author as always — and every other author keeps the base `panel=`, which stays mandatory. The diff --git a/drills/0.5.0.md b/drills/0.5.0.md new file mode 100644 index 0000000..b63fba3 --- /dev/null +++ b/drills/0.5.0.md @@ -0,0 +1,50 @@ +# 0.5.0 — drill record + +Run 2026-08-03 by `dan-claude-bot` against the release PR (Refs #233), +candidate branch `build/233-release-0-5-0` on `heavy-duty/ceremony` main at +`0ac3a6f`. + +## Scope ruling — doors unchanged, no disposable-repo rehearsal + +The 0.4.0 drill (drills/0.4.0.md) probed both release doors end-to-end in a +disposable repo; the live 0.4.0 and 0.4.1 releases then exercised the merge +door for real, twice, within the last three days. Measured at `0ac3a6f`, +stated as they are rather than as #233's D3 predicted them at `b18c0bc`: + +- `git diff 0.4.1..main -- .github/workflows/release.yml` — **empty**. No + door logic, no decide table, no publish step moved. +- `git diff 0.4.1..main -- bin` — **empty**. No release tooling moved. +- `git diff 0.4.1..main -- lib` — **not empty**: `lib/ruling.sh` +50/−13, + #226's best-shaped escalation selection, merged this morning as PR #234. + That is the labels-sweep library the reconcilers source; nothing in the + release path reads it. The doors-unchanged ruling rests on the two empty + surfaces above, not on a lib claim this tag can no longer make. + +Re-running a full disposable-repo drill would rehearse machinery this repo +proved in rehearsal at 0.4.0 and in production at both subsequent tags. This +record rests on the standing evidence below and says so honestly. The panel +reviews this claim like any other; if any reviewer rules a full drill owed, +that verdict wins (#233 D3). + +## Standing evidence at the candidate head + +| # | probe | where | result | +|---|---|---|---| +| 1 | decide + merge-door step-replay (dogfood) | `release-exercise / step-replay (dogfood)` on this PR | CI-gating; green required to merge | +| 2 | decide + merge-door step-replay (consumer) | `release-exercise / step-replay (consumer)` on this PR | CI-gating; green required to merge | +| 3 | fragment chain: armed → assemble → monotonic | `release-exercise / fixture-chain` on this PR | CI-gating; green required to merge | +| 4 | the real tree's own guards (armed, monotonic, drill-recorded, self-ref) | `self-guards` on this PR | CI-gating; green required to merge | +| 5 | the 0.5.0 payload live: #226's selection running in this repo's own ruling sweep since the #234 merge | `self-labels-sweep` runs after `0ac3a6f` | live dogfood — the changed lib is exercised by the very board that ships it | + +Probe 5 is the one piece the prior drills could not cover: `lib/ruling.sh` +is the only library delta this tag carries, and this repo's own sweeps have +been executing it since the merge — the consumer-shaped proof that the +labels machinery at the candidate's content stays green outside a fixture. + +## Deviations + +No candidate-ref deviation arises: this record stages no scratch caller, so +nothing needs to resolve `CEREMONY_SELF_REF: "0.5.0"` before the tag exists. +The sibling adoption proof is post-tag and crew's: crew#298's pin bump, then +its `panel[]=` rows, then crew's first clean sweep — owned by that +issue's criteria, reported back on #233 per its post-merge criterion. From d48374ff0060d86701eac55493fe73f8aacfa5e7 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 3 Aug 2026 12:28:40 +0000 Subject: [PATCH 014/162] drill: probe 5 states suite coverage, not live dogfood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #226 delta sits behind reconcile_ruling's needs-ruling gate and this board has no such item — the post-merge sweeps ran the file, never the delta. The record now says what was observed and why the live claim is unreachable (round 1, claude). Co-Authored-By: Claude Fable 5 --- drills/0.5.0.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/drills/0.5.0.md b/drills/0.5.0.md index b63fba3..6a29c1f 100644 --- a/drills/0.5.0.md +++ b/drills/0.5.0.md @@ -34,12 +34,20 @@ that verdict wins (#233 D3). | 2 | decide + merge-door step-replay (consumer) | `release-exercise / step-replay (consumer)` on this PR | CI-gating; green required to merge | | 3 | fragment chain: armed → assemble → monotonic | `release-exercise / fixture-chain` on this PR | CI-gating; green required to merge | | 4 | the real tree's own guards (armed, monotonic, drill-recorded, self-ref) | `self-guards` on this PR | CI-gating; green required to merge | -| 5 | the 0.5.0 payload live: #226's selection running in this repo's own ruling sweep since the #234 merge | `self-labels-sweep` runs after `0ac3a6f` | live dogfood — the changed lib is exercised by the very board that ships it | +| 5 | the 0.5.0 payload's one lib delta: #226's best-shaped selection | `test/ruling.test.sh` in the `test` job at this head, plus claude-bot's independent reproduction in the #234 review round | green — suite coverage, **not** live dogfood; see below | -Probe 5 is the one piece the prior drills could not cover: `lib/ruling.sh` -is the only library delta this tag carries, and this repo's own sweeps have -been executing it since the merge — the consumer-shaped proof that the -labels machinery at the candidate's content stays green outside a fixture. +Probe 5 makes no live-exercise claim, and the reason is stated so the next +reader does not restore one: the post-merge sweeps (`30811983604`, +`30812172095`, both at `0ac3a6f`) do run the candidate's `lib/ruling.sh` on +the dogfood path, but every #226 call site sits inside `reconcile_ruling`, +whose single caller is behind the `needs-ruling` flag check +(`labels-reconcile.sh:865–866`), and this board has no `needs-ruling` item, +standing or historical — so not one line of the delta this tag carries has +executed live here. Its coverage at this candidate is `test/ruling.test.sh` +(107 checks, including the crew#293 replay and the six regression proofs +named in PR #234) and the #234 round's independent re-run of that proof. +Per drills/README.md — the record is the evidence — and #135's defect +class, a smaller true probe stands where a larger unobserved one would not. ## Deviations From 985ee7e6e452df91936444c026891b1d8552d609 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 3 Aug 2026 12:40:50 +0000 Subject: [PATCH 015/162] =?UTF-8?q?chore:=20bump=20main=20to=200.5.1-dev?= =?UTF-8?q?=20=E2=80=94=20a=20dev=20install=20must=20not=20impersonate=200?= =?UTF-8?q?.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 8f0916f..53978e5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.5.1-dev From bded7d0b54a6c7872204e69c89aa4ce30de7d932 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:48:05 +0000 Subject: [PATCH 016/162] docs(labels): the attention absolute stops denying the shipped reconciler LABELS.md asserted 'nothing in actions/ sets, clears, reads, or validates it' and then documented two exceptions to itself four sentences later. The sentence is false on two of the four verbs: issueflow-reconcile.sh clears a carried attention on the derived claimed -> post-merge transition and reads it to gate the post-merge-assigned diagnostic. Keep the hand-set intent, drop the absolute (#231). --- LABELS.md | 9 +++++++-- changelog.d/231.md | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 changelog.d/231.md diff --git a/LABELS.md b/LABELS.md index 7402bd4..af7ccf3 100644 --- a/LABELS.md +++ b/LABELS.md @@ -171,8 +171,13 @@ The flag is additive: it composes with `ready`, `claimed`, or `blocked` and with `needs-ruling`, and never substitutes for queue state. It pauses no clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate, unanswered `attention` is exactly the silence the 48-hour reclaim should -take. It is hand-set doctrine only: nothing in `actions/` sets, clears, -reads, or validates it, and no reconciler enforces the assignee requirement. +take. It is hand-set: the machine never sets `attention`, never assigns +anyone to receive one, and never decides that one has been answered — the +assignee's removal is the only ack. It writes the label in exactly one +place, the derived `claimed` → `post-merge` transition below, and nowhere +else; where it reads the flag it reads it to diagnose, and a diagnosis is a +comment that leaves the label alone. No reconciler enforces the assignee +requirement either: an unassigned flag may be reported, never repaired. An `attention` issue without an assignee is therefore a board bug, not a demand; anyone may assign it or remove the flag. It never composes with `post-merge`, whose released claim has no assignee to answer the demand. The diff --git a/changelog.d/231.md b/changelog.d/231.md new file mode 100644 index 0000000..df32aeb --- /dev/null +++ b/changelog.d/231.md @@ -0,0 +1,6 @@ +### Fixed + +- LABELS.md no longer claims nothing in `actions/` clears or reads + `attention`: the reconciler has done both since the derived `claimed` → + `post-merge` transition shipped. The amended text keeps the hand-set rule + and admits the one clear and the diagnostic read (#231). From f4afaa1346739e443f01ea3d044af08f2d51d254 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:23:15 +0000 Subject: [PATCH 017/162] fix(issueflow): the deliverable PR is the last merged, not the highest numbered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit post_merge_pr_for_issue answered "which merged Refs PR is this issue's deliverable?" with sort -n | tail -n1. Merge order is not number order: crew#176's two Refs PRs merged #184 at 19:05:16Z and #182 at 19:05:18Z. MERGED_REF_PR_RECORDS gains mergedAt as a third column — a field on the merged-PR node set already fetched, so no additional GraphQL request — and the selection sorts on it, breaking ties by highest PR number so the answer never depends on input order. Refs #242 --- .../issueflow-reconcile.sh | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index e0b3174..b4d8e3c 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -157,9 +157,19 @@ post_merge_decision() { # $1 merged Refs PR, $2 linked open PR, $3 already handl fi } -post_merge_pr_for_issue() { # $1 issue; records are ISSUEPR - awk -F '\t' -v issue="$1" '$1 == issue { print $2 }' \ - <<<"${MERGED_REF_PR_RECORDS:-}" | sort -n | tail -n1 +post_merge_pr_for_issue() { # $1 issue; records are ISSUEPRMERGED_AT + # The deliverable is the PR that merged last, not the one numbered highest. + # Merge order is not number order in this family: crew#176's two Refs PRs + # merged #184 at 19:05:16Z and #182 at 19:05:18Z — the higher number two + # seconds earlier. Number order is also what spends a marker on the wrong + # PR: crew#321 carries `post-merge-transition-pr-326` while its real + # deliverable crew#322 — a lower number, merging later — is still open, so + # under the old rule the transition it owes could never fire (#242). + # mergedAt is ISO-8601 UTC, so it sorts as a string; ties break by highest + # PR number so the answer never depends on input order. + awk -F '\t' -v issue="$1" '$1 == issue { print $3 "\t" $2 }' \ + <<<"${MERGED_REF_PR_RECORDS:-}" \ + | sort -t $'\t' -k1,1 -k2,2n | tail -n1 | cut -f2 } post_merge_transition_marker() { # $1 merged PR number @@ -507,16 +517,16 @@ main() { query($owner: String!, $name: String!, $endCursor: String) { repository(owner: $owner, name: $name) { pullRequests(first: 100, states: MERGED, after: $endCursor) { - nodes { number body } + nodes { number mergedAt body } pageInfo { hasNextPage endCursor } } } }' --jq '.data.repository.pullRequests.nodes[] - | .number as $pr | .body | split("\n")[] - | [$pr, .] | @tsv' \ - | while IFS=$'\t' read -r pr body; do + | .number as $pr | .mergedAt as $merged | .body | split("\n")[] + | [$pr, $merged, .] | @tsv' \ + | while IFS=$'\t' read -r pr merged body; do while IFS= read -r issue; do - [ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr" + [ -n "$issue" ] && printf '%s\t%s\t%s\n' "$issue" "$pr" "$merged" done < <(refs_references <<<"$body") done)" From 9c690f02b7975b52b4f3148e2408eeaa82ddf123 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:27:43 +0000 Subject: [PATCH 018/162] test(issueflow): drive the merge-order selection, and the spent-marker shape end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue_probe's merged-PR argument becomes a spec list — `PR` or `PR@` — so a probe can state merge order; the bare form keeps every existing call site literal. The direct-drive cases cover crew#176's shape (the lower number merged later), agreeing orders, interleaved issues, the mergedAt tie broken by highest PR number under both input orders, and the empty answer. The end-to-end probe is crew#321's: a marker already standing for the later-merged, lower-numbered PR must suppress the transition, which selecting by number could never do. Two static pins keep the request count honest — the sweep issues exactly two GraphQL queries, with mergedAt selected on the merged-PR node it already fetched. Refs #242 --- .../issueflow-reconcile.sh | 10 ++- changelog.d/242.md | 5 ++ test/issueflow-reconcile.test.sh | 81 ++++++++++++++++--- 3 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 changelog.d/242.md diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index b4d8e3c..6a2ba11 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -524,7 +524,15 @@ main() { }' --jq '.data.repository.pullRequests.nodes[] | .number as $pr | .mergedAt as $merged | .body | split("\n")[] | [$pr, $merged, .] | @tsv' \ - | while IFS=$'\t' read -r pr merged body; do + | while IFS= read -r record; do + # Split on exact tabs rather than IFS: tab is IFS whitespace, so bash + # collapses a run of them, and a middle column that ever came back + # empty would silently shift the body one field left. The body is + # arbitrary text and stays last, where the remainder belongs. + pr="${record%%$'\t'*}" + rest="${record#*$'\t'}" + merged="${rest%%$'\t'*}" + body="${rest#*$'\t'}" while IFS= read -r issue; do [ -n "$issue" ] && printf '%s\t%s\t%s\n' "$issue" "$pr" "$merged" done < <(refs_references <<<"$body") diff --git a/changelog.d/242.md b/changelog.d/242.md new file mode 100644 index 0000000..73e8d48 --- /dev/null +++ b/changelog.d/242.md @@ -0,0 +1,5 @@ +### Fixed + +- The issue-flow sweep now reads an issue's deliverable as the `Refs` PR that + merged last, not the one numbered highest — merge order is not number order, + and the old rule spent the transition marker on the wrong PR (#242). diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 7f7c10b..4e7e95d 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -106,6 +106,43 @@ check "a handled merged Refs episode does not transition again" 0 "KEEP" \ post_merge_decision 12 false true <<<"- [ ] verify after merge" check "merged Refs with all criteria checked does not transition" 0 "KEEP" \ post_merge_decision 12 false false PRMERGED_AT (#242). A spec is `PR` or + # `PR@`; the bare form takes a fixed hour-old merge, which is every + # probe that does not care about merge order. An empty list is no record + # at all, so the no-merged-PR probes read exactly as they did. + MERGED_REF_PR_RECORDS="$( + # shellcheck disable=SC2086 # the spec list is deliberately word-split + for spec in $merged_ref_prs; do + pr="${spec%%@*}" + merged_at="${spec#*@}" + [ "$merged_at" != "$spec" ] || merged_at="$(iso_at $((INOW - 3600)))" + printf '%s\t%s\t%s\n' "$1" "$pr" "$merged_at" + done)" run() { "$@"; } gh() { issue_stub_gh "$@"; } reconcile_issue "$1" 2>&1 @@ -432,6 +476,25 @@ check "a later merged Refs PR gets an episode-specific transition comment" 0 "" check "...and the later episode still transitions" 0 "" \ grep -qF 'merged Refs PR -> post-merge; claim released' <<<"$second_transition" +# End to end on the crew#321 shape: the later merge is the *lower*-numbered +# PR, and its marker is already on the issue. Selecting by number would find +# no marker for #461, fire the transition a second time, and release a claim +# the board already released (#242). +recent_timeline 46 +jq -n --arg b '' \ + --arg at "$(iso_at $((INOW - 60)))" \ + '[{"body":$b,"created_at":$at}]' >"$(cfix 46)" +spent_edit_count="$(wc -l <"$TMP/issue-edits")" +spent="$(issue_probe 46 claimed 1 false \ + "461@$(iso_at $((INOW - 7200))) 460@$(iso_at $((INOW - 3600)))" \ + '- [ ] verify after merge')" +check "the marker of the later-merged lower-numbered PR is the one read" 0 "" \ + test -z "$spent" +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "...so the spent transition is not fired a second time" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ + "$spent_edit_count" "$TMP/issue-edits" "$TMP/posted-46" + printf '[]\n' >"$(cfix 45)" issue_probe 45 $'claimed\npost-merge' >/dev/null # shellcheck disable=SC2016 # Markdown backticks are literal evidence @@ -632,7 +695,7 @@ check "...and the sweep still runs" 0 "" \ # Keep this at main() granularity: the GraphQL gather and loop are the code # a sourced decision probe cannot exercise (#91's lesson). printf '%s\n' \ - '{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"mergedAt":"2026-07-30T19:05:16Z","body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$ARRIVAL/fixtures/graphql.json" printf '[{"number":40}]\n' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json" From 544d4a060307dce4132be699ad61683a69943978 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:29:21 +0000 Subject: [PATCH 019/162] style(test): separate the merge-order block from the offsite decisions Refs #242 --- test/issueflow-reconcile.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 4e7e95d..c389e3f 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -143,6 +143,7 @@ check "the sweep still issues exactly two GraphQL queries" 0 "2" \ check "...with mergedAt selected on the merged-PR node it already fetched" 0 "" \ grep -qF 'nodes { number mergedAt body }' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" + check "one closed offsite PR nudges" 0 "NUDGE" offsite_resolved_decision <<<"CLOSED" check "two closed offsite PRs nudge" 0 "NUDGE" offsite_resolved_decision <<< $'CLOSED\nCLOSED' check "one open offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision <<< $'CLOSED\nOPEN' From 19ae4aedd10450e22b9f903f029ca579a934a2e5 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:48:25 +0000 Subject: [PATCH 020/162] test: reproduce open Refs claim loss --- test/issueflow-reconcile.test.sh | 68 ++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index c389e3f..1453cb1 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -630,17 +630,25 @@ cat >"$ARRIVAL/stub/gh" <<'EOF' # answers an empty list, a .error sentinel fails the call like a dead API. if [ "$1" = api ]; then shift - endpoint="" jqexpr="" + endpoint="" jqexpr="" query="" while [ $# -gt 0 ]; do case "$1" in --jq) jqexpr="$2"; shift ;; - -f|-F) shift ;; + -f|-F) + case "$2" in query=*) query="${2#query=}" ;; esac + shift ;; -*) ;; *) [ -n "$endpoint" ] || endpoint="$1" ;; esac shift done file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json" + if [ "$endpoint" = graphql ]; then + case "$query" in + *'states: OPEN'*) file="$GH_FIXTURES/graphql-open.json" ;; + *'states: MERGED'*) file="$GH_FIXTURES/graphql-merged.json" ;; + esac + fi [ ! -f "$file.error" ] || exit 1 if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$payload"; else printf '%s\n' "$payload"; fi @@ -653,7 +661,8 @@ EOF chmod +x "$ARRIVAL/stub/gh" printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ - >"$ARRIVAL/fixtures/graphql.json" + >"$ARRIVAL/fixtures/graphql-open.json" +cp "$ARRIVAL/fixtures/graphql-open.json" "$ARRIVAL/fixtures/graphql-merged.json" arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; } arrival_run() { : >"$ARRIVAL/fixtures/edits" @@ -695,9 +704,12 @@ check "...and the sweep still runs" 0 "" \ # The merged-Refs transition must survive the executable's set -e path too. # Keep this at main() granularity: the GraphQL gather and loop are the code # a sourced decision probe cannot exercise (#91's lesson). +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":401,"body":"Refs #40","isDraft":false,"closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$ARRIVAL/fixtures/graphql-open.json" printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"mergedAt":"2026-07-30T19:05:16Z","body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ - >"$ARRIVAL/fixtures/graphql.json" + >"$ARRIVAL/fixtures/graphql-merged.json" printf '[{"number":40}]\n' \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json" jq -n --arg at "$(iso_at "$INOW")" \ @@ -711,14 +723,56 @@ subprocess_out="$( bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 )" subprocess_rc=$? -check "executable sweep transitions merged Refs work" 0 "" \ +check "an open Refs-bodied PR suppresses the post-merge transition" 0 "" \ test "$subprocess_rc" -eq 0 -check "...reaches the transition through GraphQL and the issue loop" 0 "" \ +check "...leaves the live claim assigned" 1 "" \ grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out" -check "...and performs the release edit from the executable path" 0 "" \ +check "...performs no release edit" 1 "" \ grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \ "$ARRIVAL/fixtures/edits" +# The same body linkage protects the reclaim clock even when no Refs-linked +# PR has merged. This is the derived half of crew#321's destructive shape. +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":411,"body":"Refs #41","isDraft":false,"closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$ARRIVAL/fixtures/graphql-open.json" +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$ARRIVAL/fixtures/graphql-merged.json" +printf '[{"number":41}]\n' \ + >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json" +jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ + '{number:41,user:{login:"triage-one"},created_at:$at,body:"- [ ] build",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \ + >"$ARRIVAL/fixtures/repos_owner_repo_issues_41.json" +printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_41_comments.json" +jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ + '[{"event":"assigned","created_at":$at}]' \ + >"$ARRIVAL/fixtures/repos_owner_repo_issues_41_timeline.json" +: >"$ARRIVAL/fixtures/edits" +reclaim_out="$( + env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + ISSUEFLOW_NOW="$INOW" REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +)" +reclaim_rc=$? +check "an open Refs-bodied PR suppresses stale reclaim" 0 "" test "$reclaim_rc" -eq 0 +check "...keeps the quiet live claim" 1 "" \ + grep -qF '#41: stale claim reclaimed -> ready' <<<"$reclaim_out" + +# Drafts are live claim evidence by the same OPEN query (D4); no mergeability +# or readiness field is allowed to narrow this set. +sed 's/"isDraft":false/"isDraft":true/' "$ARRIVAL/fixtures/graphql-open.json" \ + >"$ARRIVAL/fixtures/graphql-open.json.tmp" +mv "$ARRIVAL/fixtures/graphql-open.json.tmp" "$ARRIVAL/fixtures/graphql-open.json" +: >"$ARRIVAL/fixtures/edits" +draft_out="$( + env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + ISSUEFLOW_NOW="$INOW" REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +)" +check "a draft Refs-bodied PR suppresses stale reclaim identically" 1 "" \ + grep -qF '#41: stale claim reclaimed -> ready' <<<"$draft_out" + # D2 preserved: only the deliberate stand-downs changed; a genuine failure on # the arrival path still kills the run loudly. : >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error" From e1339248878bfe3cb9a3f0118ba6bde2f6a8cf73 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:49:57 +0000 Subject: [PATCH 021/162] fix: preserve claims linked by open Refs PRs --- .../issueflow-reconcile.sh | 22 ++++++++++++++++--- changelog.d/241.md | 3 +++ test/issueflow-reconcile.test.sh | 19 ++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 changelog.d/241.md diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 6a2ba11..a77df04 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -139,6 +139,16 @@ refs_references() { # PR body on stdin -> local issue numbers named by Refs | awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu } +open_pr_issues() { # records on stdin: CLOSING|BODYvalue -> issue numbers + local kind value + while IFS=$'\t' read -r kind value; do + case "$kind" in + CLOSING) [ -n "$value" ] && printf '%s\n' "$value" ;; + BODY) refs_references <<<"$value" ;; + esac + done | sort -nu +} + unchecked_criteria() { # issue body on stdin -> unchecked task-list lines verbatim awk ' /^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+\[[[:space:]]\]/ { @@ -503,16 +513,22 @@ main() { fi owner="${REPO%%/*}" name="${REPO#*/}" + # crew#321 released a live claim because the open side read only closing + # links while the merged side parsed Refs bodies. One parser now supplies + # the local body references on both sides, so transition and reclaim agree. OPEN_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query=' query($owner: String!, $name: String!, $endCursor: String) { repository(owner: $owner, name: $name) { pullRequests(first: 100, states: OPEN, after: $endCursor) { - nodes { closingIssuesReferences(first: 100) { nodes { number } } } + nodes { body closingIssuesReferences(first: 100) { nodes { number } } } pageInfo { hasNextPage endCursor } } } - }' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \ - | sort -nu)" + }' --jq '.data.repository.pullRequests.nodes[] + | (.closingIssuesReferences.nodes[].number + | ["CLOSING", tostring] | @tsv), + ((.body // "") | split("\n")[] | ["BODY", .] | @tsv)' \ + | open_pr_issues)" MERGED_REF_PR_RECORDS="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query=' query($owner: String!, $name: String!, $endCursor: String) { repository(owner: $owner, name: $name) { diff --git a/changelog.d/241.md b/changelog.d/241.md new file mode 100644 index 0000000..e7e7ec5 --- /dev/null +++ b/changelog.d/241.md @@ -0,0 +1,3 @@ +### Fixed + +- Preserve active claims when an open local pull request links them with `Refs #N`. diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 1453cb1..99a2208 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -95,6 +95,13 @@ check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)' check "Refs parser returns only references owned by a valid Refs marker" 0 "" \ test "$(refs_references <<<"$refs_body")" = $'8\n12\n175' +open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5' +check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \ + open_pr_issues <<<"$open_records" +check "cross-repo Refs never enter the local open PR set" 0 "" \ + open_pr_issues <<< $'BODY\tRefs heavy-duty/rig#112' +check "an issue named by both linkage paths appears exactly once" 0 "1" \ + grep -cxF 5 <<<"$(open_pr_issues <<<"$open_records")" check "unchecked criteria preserve their source lines verbatim" 0 \ $'- [ ] first criterion\n * [ ] indented criterion\n1. [ ] numbered criterion' \ unchecked_criteria <<< $'- [x] done\n- [ ] first criterion\r\n * [ ] indented criterion\n1. [ ] numbered criterion' @@ -731,6 +738,18 @@ check "...performs no release edit" 1 "" \ grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \ "$ARRIVAL/fixtures/edits" +sed 's/"isDraft":false/"isDraft":true/' "$ARRIVAL/fixtures/graphql-open.json" \ + >"$ARRIVAL/fixtures/graphql-open.json.tmp" +mv "$ARRIVAL/fixtures/graphql-open.json.tmp" "$ARRIVAL/fixtures/graphql-open.json" +: >"$ARRIVAL/fixtures/edits" +draft_transition_out="$( + env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +)" +check "a draft Refs-bodied PR suppresses post-merge transition identically" 1 "" \ + grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$draft_transition_out" + # The same body linkage protects the reclaim clock even when no Refs-linked # PR has merged. This is the derived half of crew#321's destructive shape. printf '%s\n' \ From 702ec5fc5d6a11815f0788634e0bfcacd0da45f0 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:51:35 +0000 Subject: [PATCH 022/162] test: model both open PR linkage paths --- test/issueflow-reconcile.test.sh | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 99a2208..046434c 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -324,17 +324,21 @@ issue_stub_gh() { fi } -issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 open PR, $5 merged PR specs, $6 body +issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 false|closing|refs, $5 merged PR specs, $6 body ( local assignees="${3:-1}" open_pr="${4:-false}" merged_ref_prs="${5:-}" - local body="${6:-}" assignee_json='[]' spec pr merged_at + local body="${6:-}" assignee_json='[]' open_pr_records="" spec pr merged_at [ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]' REPO=owner/repo NOW="$INOW" ISSUE_LABELS="$2" ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ --argjson assignees "$assignee_json" --arg body "$body" \ '{created_at: $at, assignees: $assignees, body: $body}')" - if [ "$open_pr" = true ]; then OPEN_PR_ISSUES="$1"; else OPEN_PR_ISSUES=""; fi + case "$open_pr" in + true|closing) open_pr_records="$(printf 'CLOSING\t%s\n' "$1")" ;; + refs|draft-refs) open_pr_records="$(printf 'BODY\tRefs #%s\n' "$1")" ;; + esac + OPEN_PR_ISSUES="$(open_pr_issues <<<"$open_pr_records")" # Records are ISSUEPRMERGED_AT (#242). A spec is `PR` or # `PR@`; the bare form takes a fixed hour-old merge, which is every # probe that does not care about merge order. An empty list is no record @@ -431,14 +435,19 @@ recent_timeline() { } edit_count_before="$(wc -l <"$TMP/issue-edits")" recent_timeline 38 -open_refs="$(issue_probe 38 claimed 1 true 380 '- [ ] verify after merge')" -check "open Refs PR leaves the issue exactly as found" 0 "" \ +open_refs="$(issue_probe 38 claimed 1 refs 380 '- [ ] verify after merge')" +check "issue_probe: open Refs PR leaves the issue exactly as found" 0 "" \ test -z "$open_refs" # shellcheck disable=SC2016 # positional parameters belong to bash -c check "...with no edit or comment" 0 "" \ bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ "$edit_count_before" "$TMP/issue-edits" "$TMP/posted-38" +recent_timeline 46 +open_closing="$(issue_probe 46 claimed 1 closing 460 '- [ ] verify after merge')" +check "issue_probe: closing-linked open PR remains the unchanged control" 0 "" \ + test -z "$open_closing" + recent_timeline 39 merged_closes="$(issue_probe 39 claimed 1 false "" '- [ ] verify after merge')" check "merged Closes PR leaves a recent claim exactly as found" 0 "" \ From 071ac49cc24f0b0c0dd1d54419fda54a70d98975 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:33:22 +0000 Subject: [PATCH 023/162] test: retain executable transition control --- test/issueflow-reconcile.test.sh | 34 ++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 046434c..837194a 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -717,11 +717,12 @@ check "...stands down without minting" 1 "" test -s "$ARRIVAL/fixtures/edits" check "...and the sweep still runs" 0 "" \ grep -qF 'issueflow: reconciled.' <<<"$pr_out" -# The merged-Refs transition must survive the executable's set -e path too. -# Keep this at main() granularity: the GraphQL gather and loop are the code -# a sourced decision probe cannot exercise (#91's lesson). +# Exercise both directions through main(): a merged-Refs transition still +# fires without a linked open PR, then the open-body gather suppresses it. +# A sourced decision probe cannot exercise the GraphQL gather and loop +# (#91's lesson). printf '%s\n' \ - '{"data":{"repository":{"pullRequests":{"nodes":[{"number":401,"body":"Refs #40","isDraft":false,"closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$ARRIVAL/fixtures/graphql-open.json" printf '%s\n' \ '{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"mergedAt":"2026-07-30T19:05:16Z","body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ @@ -733,6 +734,24 @@ jq -n --arg at "$(iso_at "$INOW")" \ >"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json" printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json" : >"$ARRIVAL/fixtures/edits" +transition_out="$( + env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +)" +transition_rc=$? +check "an executable sweep with no linked open PR exits 0" 0 "" \ + test "$transition_rc" -eq 0 +check "...reaches the transition through GraphQL and the issue loop" 0 "" \ + grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$transition_out" +check "...and performs the release edit from the executable path" 0 "" \ + grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \ + "$ARRIVAL/fixtures/edits" + +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":401,"body":"Refs #40","isDraft":false,"closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$ARRIVAL/fixtures/graphql-open.json" +: >"$ARRIVAL/fixtures/edits" subprocess_out="$( env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ @@ -747,6 +766,8 @@ check "...performs no release edit" 1 "" \ grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \ "$ARRIVAL/fixtures/edits" +# The query selects every OPEN PR and deliberately does not select isDraft; +# this fixture-only flip documents that draft identity cannot narrow the set. sed 's/"isDraft":false/"isDraft":true/' "$ARRIVAL/fixtures/graphql-open.json" \ >"$ARRIVAL/fixtures/graphql-open.json.tmp" mv "$ARRIVAL/fixtures/graphql-open.json.tmp" "$ARRIVAL/fixtures/graphql-open.json" @@ -787,8 +808,9 @@ check "an open Refs-bodied PR suppresses stale reclaim" 0 "" test "$reclaim_rc" check "...keeps the quiet live claim" 1 "" \ grep -qF '#41: stale claim reclaimed -> ready' <<<"$reclaim_out" -# Drafts are live claim evidence by the same OPEN query (D4); no mergeability -# or readiness field is allowed to narrow this set. +# Drafts are live claim evidence by the same OPEN query (D4). The query does +# not select isDraft, so this fixture-only flip deliberately leaves production +# input byte-identical and guards the absence of a draft/readiness predicate. sed 's/"isDraft":false/"isDraft":true/' "$ARRIVAL/fixtures/graphql-open.json" \ >"$ARRIVAL/fixtures/graphql-open.json.tmp" mv "$ARRIVAL/fixtures/graphql-open.json.tmp" "$ARRIVAL/fixtures/graphql-open.json" From b5ec236b428f7a2ed9d76e12689b19e5824b9d59 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:12:38 +0000 Subject: [PATCH 024/162] fix(labels): blocker:unrequested waits for green, and for the round to settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `blocker:unrequested` is the one blocker that names an act the author must perform, and it never knew whether performing it was permitted. BUILDER.md's review round requires a green check at the head before requesting, so a builder waiting out a pending run is complying — and the blocker fired on compliance (crew#318 ~12:44Z, ceremony#235 12:30Z, both 2026-08-03). Gate the branch on CHECKS ∈ SUCCESS | NONE (D1): PENDING is CI's move, which state:addressing already says, and FAILURE belongs to blocker:ci-red rather than to a second label on the same stall. Then require the supporting facts — the head's own date and the round's newest submitted review — to have stood for RECONCILE_UNREQUESTED_GRACE (default 300s, D2), measured off those timestamps because this sweep is stateless per pass. A timestamp that cannot be read refuses the blocker. Refs #236 --- actions/labels-reconcile/labels-reconcile.sh | 95 +++++++++++++++++++- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index cd573f6..fe8d566 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -55,6 +55,12 @@ LABELS="" # retirement heals the board instead of stranding a label nothing recomputes. RETIRED=(state:needs-rebase) STALE_AFTER=$((48 * 3600)) +# How long the facts behind blocker:unrequested must have stood still before it +# is written (#236 D2). The operator's "more than 5 minutes", measured off the +# inputs' own timestamps rather than off sweep memory — this script is +# stateless per pass and stays that way. Overridable the way this file's other +# constants are, for a caller whose round cadence is slower or faster. +RECONCILE_UNREQUESTED_GRACE="${RECONCILE_UNREQUESTED_GRACE:-300}" # The workflow whose runs checks_state must never grade — its own (#208). # GITHUB_WORKFLOW is ambient in every Actions step and names the CALLER (the # consumer's PR-facing workflow, since consumers name the caller), so this @@ -277,6 +283,8 @@ set_required_bots() { # the PR author is recused by construction # MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict) # CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup) # LABELS newline-separated labels currently on the PR +# HEAD_COMMIT_AT the head commit's own date, ISO-8601; empty when unread +# NOW this sweep's epoch seconds (main sets it once per run) # --------------------------------------------------------------------------- requested() { grep -qxF "$1" <<<"$REQUESTED"; } @@ -429,6 +437,44 @@ bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK esac } +iso_epoch() { # $1 = ISO-8601 timestamp → epoch seconds; nothing, rc 1, when unreadable + # An absent field reaches this as the empty string or as jq's literal "null"; + # both are "we did not read a time", and neither may be graded as one. + local at="${1-}" epoch + case "$at" in "" | null) return 1 ;; esac + epoch="$(date -d "$at" +%s 2>/dev/null)" || return 1 + [ -n "$epoch" ] || return 1 + printf '%s\n' "$epoch" +} + +unrequested_quiescent() { # 0 when the unrequested facts have stood for the grace (#236 D2) + # The stall blocker's supporting facts are the head and the round's newest + # submitted review: the ask it demands is owed only once both have stopped + # moving. Measured off those timestamps, not off sweep memory — ceremony#235 + # was flagged inside the ~90 seconds between a round-answer push and the + # author's re-request, because a sweep read the facts before the request + # landed and wrote after it. That is a round in motion, not a dropped ball. + # + # "Newest submitted review" is any submitted review, COMMENTED included: a + # non-verdict is still evidence the round is live, and counting it can only + # delay a flag, never invent one. + # + # A timestamp we could not read refuses the blocker (the standing rule: an + # unreadable fact never invents a verdict). This direction is deliberate and + # asymmetric — a missed flag costs one sweep of the 15-minute cadence, a + # false one flags a builder for doing exactly what BUILDER.md requires. + local newest verdict_at verdict_epoch + newest="$(iso_epoch "${HEAD_COMMIT_AT:-}")" || return 1 + verdict_at="$(jq -r '[.[].submitted_at] | max // empty' <<<"${REVIEWS_JSON:-[]}")" + if [ -n "$verdict_at" ]; then + # A round WITH verdicts whose newest one cannot be dated is unreadable, not + # quiescent; a round with no verdicts at all is simply the head's clock. + verdict_epoch="$(iso_epoch "$verdict_at")" || return 1 + [ "$verdict_epoch" -gt "$newest" ] && newest="$verdict_epoch" + fi + [ $((${NOW:-0} - newest)) -ge "$RECONCILE_UNREQUESTED_GRACE" ] +} + 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 @@ -464,7 +510,27 @@ blockers() { # → the blocker:* labels this PR should carry, one per line # 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 + # + # And so is a head whose checks have not answered yet (#236 D1). This is the + # one blocker that names an act the author must PERFORM, so it is the one + # that has to know when performing it is permitted: BUILDER.md's review round + # requires a green check at the head before requesting, so a builder waiting + # out a pending run is complying, and flagging compliance teaches its readers + # to ignore the label. Both 2026-08-03 instances were exactly that — + # crew#318 at ~12:44Z carried state:addressing + blocker:unrequested while + # the head's run was IN_PROGRESS, and ceremony#235 at 12:30Z caught the + # ~90-second gap between a round-answer push and the re-request. + # + # PENDING and FAILURE each already have an owner, which is why gating loses + # no coverage: on PENDING the next move is CI's and state:addressing / + # state:bots-reviewing already say what the PR is doing; on FAILURE + # blocker:ci-red owns that head, and stacking a second blocker on it + # double-flags one stall. NONE joins SUCCESS because no checks configured is + # nothing to wait for — the same reading the request rule gives the builder. + # UNREADABLE never arrives here: the caller skips the PR before deciding. + local checks_permit_the_ask=false + case "${CHECKS:-NONE}" in SUCCESS | NONE) checks_permit_the_ask=true ;; esac + if [ "$DRAFT" != true ] && [ "$checks_permit_the_ask" = true ] && ! requested "$HUMAN"; then local b v owed=false any_requested=false for b in "${REQUIRED_BOTS[@]}"; do requested "$b" && any_requested=true @@ -475,7 +541,10 @@ blockers() { # → the blocker:* labels this PR should carry, one per line v="$(bot_verdict "$b")" case "$v" in MISSING | STALE) owed=true ;; esac done - if [ "$owed" = true ] && [ "$any_requested" = false ]; then + # The quiescence grace (#236 D2) is the last question, after the debt is + # established: it asks whether the debt has stood long enough to be a + # dropped ball rather than a round still in motion. + if [ "$owed" = true ] && [ "$any_requested" = false ] && unrequested_quiescent; then echo blocker:unrequested fi fi @@ -902,6 +971,28 @@ main() { # 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")]')" + # The head's own clock, for the blocker:unrequested grace (#236 D2). One + # read, pinned to the head SHA — not `gh pr view --json commits`, which + # asks for the FIRST hundred commits and would date a longer PR by a + # commit that is not its head. Drafts never reach that blocker, so they + # do not pay for the call. Empty (a failed read, or a body without the + # field) leaves the blocker unjudged this pass, by unrequested_quiescent. + HEAD_COMMIT_AT="" + if [ "$DRAFT" != true ]; then + HEAD_COMMIT_ERR_FILE="$(mktemp)" + HEAD_COMMIT_AT="$(gh api "repos/$REPO/commits/$HEAD_SHA" \ + --jq '.commit.committer.date' 2>"$HEAD_COMMIT_ERR_FILE" || echo "")" + HEAD_COMMIT_ERR="$(cat "$HEAD_COMMIT_ERR_FILE")" + rm -f "$HEAD_COMMIT_ERR_FILE" + case "$HEAD_COMMIT_AT" in + "" | null) + # Say why it degraded (#101 D2/D4), on its own line: this one + # narrows a blocker rather than skipping the PR, so it must not + # read as the wholly-blind shape the counted line above matches. + HEAD_COMMIT_AT="" + log "#$n: could not read the head commit's date: $(read_failure_reason "$HEAD_COMMIT_ERR") — blocker:unrequested not judged this pass" ;; + esac + fi # 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 From 8459a9255b5ba5f35d5e07cfb302714e2dc725d4 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:18:46 +0000 Subject: [PATCH 025/162] test(labels): drive the green gate and the grace, and mutate both to prove them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six cases the issue names, plus the boundary (the grace is inclusive), a verdict inside the window against an old head, both unreadable timestamps, and the configured-grace override. Two proofs run rather than asserted in prose: a copy of the script with the gate removed must flag the PENDING fixture, and a copy with the grace removed must flag the inside-the-window one. The harness checks itself against the unmutated copy first, or a flip would prove nothing. The pre-#236 stall fixtures gain real timestamps. Their symbolic stamps are not unreadable — GNU date reads `t1` as 01:00 in military timezone T, a time on whatever day the suite runs — so a grace measured against a fixed NOW would flip with the calendar. Every assertion is byte-identical. Refs #236 --- changelog.d/236.md | 10 +++ test/labels-reconcile.test.sh | 150 ++++++++++++++++++++++++++++++++-- 2 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 changelog.d/236.md diff --git a/changelog.d/236.md b/changelog.d/236.md new file mode 100644 index 0000000..2d76876 --- /dev/null +++ b/changelog.d/236.md @@ -0,0 +1,10 @@ +### Fixed + +- `blocker:unrequested` no longer fires while a head's checks are pending or + red: the review round forbids requesting there, so the one blocker that + demanded an act flagged builders for complying. Pending is CI's move, red is + `blocker:ci-red`'s (#236). +- `blocker:unrequested` now waits for the round to settle — the head and the + newest verdict must have stood for `RECONCILE_UNREQUESTED_GRACE` (default + 300s) — so a sweep landing between a push and its re-request no longer flags + a round in motion (#236). diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index f1b0aa6..f098b5f 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -38,6 +38,22 @@ rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review obj reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array +# The blocker:unrequested quiescence inputs (#236 D2). Every fixture below +# inherits a readable, settled world — a head commit an hour before this +# sweep's clock — so the cases written before #236 assert exactly what they +# always asserted. The #236 block sets both per case. +# +# One consequence a new fixture has to know: a case that means to raise +# blocker:unrequested needs a REAL submitted_at on its reviews, because the +# grace dates the round's newest review. The symbolic stamps this file uses +# elsewhere (`t1`, `t2`, …) are not unreadable — GNU date reads `t1` as 01:00 +# in military timezone T, i.e. a time on WHATEVER day the suite runs — which is +# worse: the verdict would flip with the calendar, the hazard the LC_ALL pin at +# the top of this file guards on the other axis. Hence a fixed NOW here and +# real timestamps on the three stall fixtures below. +NOW="$(date -d 2026-08-03T12:00:00Z +%s)" +HEAD_COMMIT_AT=2026-08-03T11:00:00Z + # -- a sweep-wide read failure is visible without changing any PR ------------ warning="$(blind_sweep_warning 3 3 "HTTP 403: Resource not accessible by integration")" expect "a wholly blind sweep warns, leading with the observed reason" \ @@ -131,8 +147,8 @@ expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state # 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)")" + "$(rev "$BOT1" APPROVED head1 "" 2026-08-03T10:00:00Z)" \ + "$(rev "$BOT2" APPROVED head1 "" 2026-08-03T10:01:00Z)")" 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)" @@ -283,15 +299,15 @@ expect "...and raises no blocker" "" "$(blockers)" 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)")" +REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" 2026-08-03T10:00:00Z)")" 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)")" + "$(rev "$BOT1" APPROVED oldhead "" 2026-08-03T10:00:00Z)" \ + "$(rev "$BOT2" APPROVED oldhead "" 2026-08-03T10:01:00Z)" \ + "$(rev "$BOT3" APPROVED oldhead "" 2026-08-03T10:02:00Z)")" 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 @@ -1057,6 +1073,128 @@ REQUESTED="" REVIEWS_JSON='[]' expect "a draft with no round history still reads building" \ state:building "$(decide_state)" +# --------------------------------------------------------------------------- +# blocker:unrequested knows when the ask is permitted (#236). The blocker +# demands an act — request the panel — that BUILDER.md forbids under a head +# whose checks have not answered, so the predicate that flags the omission has +# to read CHECKS and has to let a round in motion finish moving. Two guards, +# each proved load-bearing by a mutation at the end of the block. +# --------------------------------------------------------------------------- +DRAFT=false HEAD_SHA=head1 REQUESTED="" MERGEABLE=MERGEABLE LABELS="" +NOW="$(date -d 2026-08-03T12:00:00Z +%s)" +# the genuine #26/#39 debt: three approvals of a head a push staled, nobody +# asked for the re-verdicts, and every fact hours old +OWED_QUIET_ROUND="$(reviews \ + "$(rev "$BOT1" APPROVED oldhead "" 2026-08-03T10:00:00Z)" \ + "$(rev "$BOT2" APPROVED oldhead "" 2026-08-03T10:01:00Z)" \ + "$(rev "$BOT3" APPROVED oldhead "" 2026-08-03T10:02:00Z)")" +REVIEWS_JSON="$OWED_QUIET_ROUND" HEAD_COMMIT_AT=2026-08-03T11:00:00Z +CHECKS=SUCCESS +expect "green, quiescent, owed and unasked is the stall (the control)" \ + blocker:unrequested "$(blockers)" +# D1 — the gate. crew#318's shape: the same debt under a running check, where +# requesting is the one thing the builder must not do. +CHECKS=PENDING +expect "a pending head is CI's move, not a dropped ask" "" "$(blockers)" +CHECKS=FAILURE +expect "a red head raises ci-red alone — the two never co-occur" \ + blocker:ci-red "$(blockers)" +CHECKS=NONE +expect "no checks configured is nothing to wait for, so the stall still shows" \ + blocker:unrequested "$(blockers)" +# D2 — the grace. ceremony#235's shape: a sweep landing in the ~90 seconds +# between a round-answer push and the author's re-request. +CHECKS=SUCCESS HEAD_COMMIT_AT=2026-08-03T11:57:30Z +expect "a head pushed inside the grace is a round in motion, not a stall" \ + "" "$(blockers)" +HEAD_COMMIT_AT=2026-08-03T11:55:00Z +expect "...and exactly at the grace it flags — the boundary is inclusive" \ + blocker:unrequested "$(blockers)" +HEAD_COMMIT_AT=2026-08-03T11:50:00Z +expect "...and a later pass flags it with nothing else changed" \ + blocker:unrequested "$(blockers)" +# a verdict is the other supporting fact, and an old head does not license +# flagging a round whose newest verdict landed a minute ago +HEAD_COMMIT_AT=2026-08-03T10:00:00Z +REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED oldhead "" 2026-08-03T11:59:00Z)")" +expect "a verdict submitted inside the grace is motion too" "" "$(blockers)" +# no verdicts at all is not an unreadable round — it is the first-ask stall, +# and the head's clock is the whole of it +REVIEWS_JSON='[]' HEAD_COMMIT_AT=2026-08-03T11:00:00Z +expect "nothing reviewed and nobody asked flags off the head's clock alone" \ + blocker:unrequested "$(blockers)" +# an unreadable fact never invents a verdict — the standing rule, applied to +# both timestamps +REVIEWS_JSON="$OWED_QUIET_ROUND" HEAD_COMMIT_AT="" +expect "an unread head date leaves the blocker unjudged" "" "$(blockers)" +HEAD_COMMIT_AT=null +expect "...and jq's literal null is unread, not epoch zero" "" "$(blockers)" +HEAD_COMMIT_AT=2026-08-03T11:00:00Z +REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED oldhead "" not-a-timestamp)")" +expect "a round whose newest verdict cannot be dated is unread, not quiescent" \ + "" "$(blockers)" +# the constant is overridable the way this file's others are +REVIEWS_JSON="$OWED_QUIET_ROUND" HEAD_COMMIT_AT=2026-08-03T11:57:30Z +RECONCILE_UNREQUESTED_GRACE=60 +expect "a shorter configured grace flags the same facts" \ + blocker:unrequested "$(blockers)" +RECONCILE_UNREQUESTED_GRACE=300 + +# the timestamp reader, directly: the three unreadable spellings it must refuse +expect "iso_epoch reads a real stamp" \ + "$(date -d 2026-08-03T12:00:00Z +%s)" "$(iso_epoch 2026-08-03T12:00:00Z)" +expect "iso_epoch refuses an absent stamp" "" "$(iso_epoch "")" +expect "iso_epoch refuses jq's null" "" "$(iso_epoch null)" +expect "iso_epoch refuses a stamp date cannot read" "" "$(iso_epoch not-a-timestamp)" +# ...and the trap it does NOT catch, recorded because a fixture author will +# reach for it: `t1` is a valid date to GNU date — 01:00 in military timezone T, +# on the day the suite runs — so it reads as a moving stamp rather than as an +# unreadable one. Real timestamps in any fixture the grace touches. +expect "a symbolic stamp is readable, and moves with the run's day" \ + "$(date -d t1 +%s)" "$(iso_epoch t1)" + +# -- the mutation proofs: both guards are load-bearing, and this runs them ---- +# A guard the fixtures cannot see removed is a guard nobody is testing, so each +# is deleted from a COPY of the script and the fixture that covers it must flip. +# The sed programs target one token each, so a refactor that moves a guard +# fails here loudly instead of passing silently. +mutant_blockers() { # $1 = sed program → blockers() from a copy of the script + # The copy keeps its position in the tree — the script sources lib/ruling.sh + # relative to its own path, and a copy dropped anywhere else would source + # nothing and say so on stderr instead of failing. + local root="$RTMP/mutant" mutated + mutated="$root/actions/labels-reconcile/labels-reconcile.sh" + mkdir -p "$root/actions/labels-reconcile" + ln -sfn "$PWD/lib" "$root/lib" + sed "$1" actions/labels-reconcile/labels-reconcile.sh >"$mutated" + DRAFT="$DRAFT" HEAD_SHA="$HEAD_SHA" REQUESTED="$REQUESTED" \ + REVIEWS_JSON="$REVIEWS_JSON" MERGEABLE="$MERGEABLE" CHECKS="$CHECKS" \ + NOW="$NOW" HEAD_COMMIT_AT="$HEAD_COMMIT_AT" \ + RECONCILE_UNREQUESTED_GRACE="$RECONCILE_UNREQUESTED_GRACE" \ + bash -u -c ' + . "$1" + load_config .github/labels.conf + set_required_bots codex-bot-andresmgsl + blockers + ' bash "$mutated" +} +# the harness itself, unmutated: it must reproduce the verdict the sourced +# functions give, or a "flip" below proves nothing about the guard +REVIEWS_JSON="$OWED_QUIET_ROUND" HEAD_COMMIT_AT=2026-08-03T11:00:00Z CHECKS=SUCCESS +expect "the mutation harness reproduces the control verdict" \ + blocker:unrequested "$(mutant_blockers 's/^#no-such-line$//')" +CHECKS=PENDING +expect "...and the pending fixture is green in the unmutated copy" \ + "" "$(mutant_blockers 's/^#no-such-line$//')" +expect "removing the green gate reds the pending fixture" \ + blocker:unrequested \ + "$(mutant_blockers 's/checks_permit_the_ask=false/checks_permit_the_ask=true/')" +CHECKS=SUCCESS HEAD_COMMIT_AT=2026-08-03T11:57:30Z +expect "removing the grace reds the inside-the-window fixture" \ + blocker:unrequested \ + "$(mutant_blockers 's/ \&\& unrequested_quiescent//')" +HEAD_COMMIT_AT=2026-08-03T11:00:00Z + # -- per-author panels (#224): the required set flows from the one ---------- # resolution point, and convergence counts the effective set — never the # base panel beside a reduced request set (the must-fail the issue names) From d4512a1e82ae08e12e41b5cb64baed66f5de07f5 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:23:11 +0000 Subject: [PATCH 026/162] test(labels): drive the fetch that feeds the grace, at the sweep level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predicate's fixtures cannot see the read that sets HEAD_COMMIT_AT, so a sweep probe drives it both ways: read, and the blocker is written off a dated head; denied, and the denial is named on its own line while the state still converges — this read narrows one blocker, it does not skip the PR the way an unreadable rollup does. Renaming the assignment reds the probe. The read also moves after the mergeability/checks skip: a PR the sweep walks away from must not pay for a call whose only consumer is a blocker that pass will never decide. Refs #236 --- actions/labels-reconcile/labels-reconcile.sh | 45 +++++++------ test/labels-reconcile.test.sh | 71 ++++++++++++++++++++ 2 files changed, 94 insertions(+), 22 deletions(-) diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index fe8d566..003c92c 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -971,28 +971,6 @@ main() { # 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")]')" - # The head's own clock, for the blocker:unrequested grace (#236 D2). One - # read, pinned to the head SHA — not `gh pr view --json commits`, which - # asks for the FIRST hundred commits and would date a longer PR by a - # commit that is not its head. Drafts never reach that blocker, so they - # do not pay for the call. Empty (a failed read, or a body without the - # field) leaves the blocker unjudged this pass, by unrequested_quiescent. - HEAD_COMMIT_AT="" - if [ "$DRAFT" != true ]; then - HEAD_COMMIT_ERR_FILE="$(mktemp)" - HEAD_COMMIT_AT="$(gh api "repos/$REPO/commits/$HEAD_SHA" \ - --jq '.commit.committer.date' 2>"$HEAD_COMMIT_ERR_FILE" || echo "")" - HEAD_COMMIT_ERR="$(cat "$HEAD_COMMIT_ERR_FILE")" - rm -f "$HEAD_COMMIT_ERR_FILE" - case "$HEAD_COMMIT_AT" in - "" | null) - # Say why it degraded (#101 D2/D4), on its own line: this one - # narrows a blocker rather than skipping the PR, so it must not - # read as the wholly-blind shape the counted line above matches. - HEAD_COMMIT_AT="" - log "#$n: could not read the head commit's date: $(read_failure_reason "$HEAD_COMMIT_ERR") — blocker:unrequested not judged this pass" ;; - esac - fi # 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 @@ -1024,6 +1002,29 @@ main() { log "#$n: read failed: $(read_failure_reason "$GH_VIEW_ERR")" exit 0 fi + # The head's own clock, for the blocker:unrequested grace (#236 D2). One + # read, pinned to the head SHA — not `gh pr view --json commits`, which + # asks for the FIRST hundred commits and would date a longer PR by a + # commit that is not its head. Last of the fetches on purpose: a PR the + # skip above walked away from must not pay for it, and neither do drafts, + # which never reach that blocker. Empty (a failed read, or a body without + # the field) leaves the blocker unjudged, by unrequested_quiescent. + HEAD_COMMIT_AT="" + if [ "$DRAFT" != true ]; then + HEAD_COMMIT_ERR_FILE="$(mktemp)" + HEAD_COMMIT_AT="$(gh api "repos/$REPO/commits/$HEAD_SHA" \ + --jq '.commit.committer.date' 2>"$HEAD_COMMIT_ERR_FILE" || echo "")" + HEAD_COMMIT_ERR="$(cat "$HEAD_COMMIT_ERR_FILE")" + rm -f "$HEAD_COMMIT_ERR_FILE" + case "$HEAD_COMMIT_AT" in + "" | null) + # Say why it degraded (#101 D2/D4), on its own line: this one + # narrows a blocker rather than skipping the PR, so it must not + # read as the wholly-blind shape the counted line above matches. + HEAD_COMMIT_AT="" + log "#$n: could not read the head commit's date: $(read_failure_reason "$HEAD_COMMIT_ERR") — blocker:unrequested not judged this pass" ;; + esac + fi reconcile_pr "$n" ) 2>&1 )" || status=$? diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index f098b5f..c9ce210 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -840,6 +840,77 @@ expect "each blind PR logs its reason as its own line beside the counted one" 2 expect "exactly the blind PRs match the counted shape whole-line — no more, no less" 2 \ "$(grep -c '^labels: #[0-9]*: could not read mergeability/checks — left alone this pass$' <<<"$blind_main")" +# -- the grace's own wiring: the fixtures above prove the predicate, and only a +# sweep can prove the fetch that feeds it (#236 D2). The fixture-only version +# of this change would have passed with the global never assigned — the #91 +# shape, where the probes could not reach the per-PR path at all. +unrequested_main_probe() { # $1 = read | denied, the head-commit read's outcome + ( + GITHUB_EVENT_NAME=schedule + REPO=owner/repo + LABELS_CONF=.github/labels.conf + UMODE="$1" + gh() { + if [ "$1" = label ] && [ "$2" = list ]; then core_label_rows | cut -d'|' -f1; return 0; fi + if [ "$1" = pr ] && [ "$2" = list ]; then printf '303\n'; return 0; fi + if [ "$1" = pr ] && [ "$2" = view ]; then + # green, so the D1 gate is open and D2 is the only question left + jq -n '{mergeable:"MERGEABLE", + statusCheckRollup:[{__typename:"CheckRun",workflowName:"ci", + name:"check",conclusion:"SUCCESS", + startedAt:"2026-07-01T00:00:00Z"}]}' + return 0 + fi + # recorded to a file, not to stdout: reconcile_pr sends the edit call's + # stdout to /dev/null, so a narrating stub would look like no edit at all + if [ "$1" = issue ] && [ "$2" = edit ]; then printf '%s\n' "$*" >>"$RTMP/uedits-$UMODE"; return 0; fi + [ "$1" = api ] || return 0 + shift + local jqexpr="" endpoint="" + while [ $# -gt 0 ]; do + case "$1" in + --jq) jqexpr="$2"; shift ;; + -*) ;; + *) [ -n "$endpoint" ] || endpoint="$1" ;; + esac + shift + done + case "$endpoint" in + */commits/*) # the head-commit read; ordered before the commit LIST below + if [ "$UMODE" = denied ]; then + printf 'gh: Not Found (HTTP 404)\n' >&2 + return 1 + fi + jq -n '{commit:{committer:{date:"2026-07-01T00:00:00Z"}}}' | jq -r "${jqexpr:-.}" ;; + */pulls/303) + jq -n '{draft:false,user:{login:"author"},head:{sha:"headsha"}, + base:{sha:"basesha"},labels:[],requested_reviewers:[], + created_at:"2026-07-01T00:00:00Z"}' ;; + *) printf '[]\n' | jq -r "${jqexpr:-.}" ;; # every collection empty + esac + } + main + ) +} + +read_sweep="$(unrequested_main_probe read)" +expect "the sweep reads the head's date and writes the stall it now dates" yes \ + "$(grep -q 'blocker:unrequested' "$RTMP/uedits-read" && echo yes || echo no)" +expect "...saying nothing about a degraded read" no \ + "$(grep -q "could not read the head commit's date" <<<"$read_sweep" && echo yes || echo no)" +denied_sweep="$(unrequested_main_probe denied)" +expect "a denied head-commit read names the denial (#101's shape)" yes \ + "$(grep -q "^labels: #303: could not read the head commit's date: gh: Not Found (HTTP 404)" <<<"$denied_sweep" \ + && echo yes || echo no)" +expect "...and writes no blocker it could not date" no \ + "$(grep -q 'blocker:unrequested' "$RTMP/uedits-denied" && echo yes || echo no)" +# ...while the PR is still converged: this read narrows one blocker, it does not +# skip the PR the way an unreadable rollup does +expect "...while the state still converges — one blocker unjudged, not a skip" yes \ + "$(grep -q 'state:addressing' "$RTMP/uedits-denied" && echo yes || echo no)" +expect "...and the sweep does not report it as a blind pass" 0 \ + "$(grep -c 'could not read mergeability/checks' <<<"$denied_sweep" || true)" + # --------------------------------------------------------------------------- # bootstrap_labels retires the GitHub defaults (#93). LABELS.md published # them as deleted at bootstrap; nothing deleted them — incubator's first From ab60709f49cbb9c6e2317c254ec75bffe45a4397 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:29:15 +0000 Subject: [PATCH 027/162] docs: add release-management doctrine --- BUILDER.md | 2 + RELEASES.md | 100 +++++++++++++++++++++++++++++++++++++++++++++ TRIAGE.md | 1 + changelog.d/248.md | 3 ++ docs/CONSUMERS.md | 8 +++- 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 RELEASES.md create mode 100644 changelog.d/248.md diff --git a/BUILDER.md b/BUILDER.md index 0ab03f1..9066716 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -11,6 +11,8 @@ 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 unclaimed child. Between epics and strays, prefer the issue that unblocks the most other work. +- In a repository that adopts version epics, read [RELEASES.md](RELEASES.md) + before choosing among release-window members. - **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 diff --git a/RELEASES.md b/RELEASES.md new file mode 100644 index 0000000..304ca8a --- /dev/null +++ b/RELEASES.md @@ -0,0 +1,100 @@ +# Release management + +This file describes the release-management pattern available to governed +repositories. Adoption is per repository and operator-ruled: a repository +without version epics is not out of compliance. A repo-local roadmap is the +map; each epic remains the source of truth for its own release. Where an older +repo-local description differs from this file, this file governs. + +## The ladder + +Represent each planned release with one version epic. The epic is the working +surface for that release: it states the goal, names the members, and records +the ordered waves as checklists. Keep the machine-readable progress checklist +under the exact heading `## Task list`; the issue-flow sweep reads that heading +when it decides whether to nudge triage about a completed epic. + +Keep a short repo-local roadmap beside the epics. The roadmap shows the whole +ladder and points to each working surface; it does not duplicate the live +member lists or ordering. crew's roadmap discussion [heavy-duty/crew#338](https://github.com/heavy-duty/crew/discussions/338) +maps the ladder whose `0.1.2` working surface moved from the crufty ledger +[heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to +[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346). + +## Gates + +Each version epic declares the predecessor that opens it. Special ordering — +a double gate or an out-of-chain gate — is written explicitly on that epic; +there is no hidden global schedule. Shipping closes the current epic, and its +close is the signal for triage to open the next release-init cycle by hand. +Version epics carry `epic`, not a queue label: `ready` offers work to builders, +and builders never pick epics. + +The gate orders windows, not their contents. Members enter a release only by +decision during release-init. The double gate on heavy-duty/crew#163 and the +out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348) +are worked examples of exceptions declared where they apply. + +## Release-init + +The preceding epic's close is the trigger. Triage then runs five steps: + +1. Mint the epic's “to mint when this arc opens” list together with findings, + deferred work, and discussion outcomes accumulated since the epic was + written. Each member initially declares `Blocked by `. +2. Graph hard `Blocked by` edges and same-file clusters on the epic. +3. Write the waves into the epic body as checklists in claim order, with a + separate verification lane and the progress view under `## Task list`. +4. Ask the operator to bless the order, then have triage open the first wave + by applying the flip mechanics below. The operator's blessing is the one + step this chain never automates. +5. Ship through the repository's cut process, close the epic, and treat that + close as the trigger for the next window. + +heavy-duty/crew#346 is the worked wave plan; its graph made both hard edges +and shared-file contention visible before builders entered the queue. If init +finds no work worth minting, the operator either folds the empty window into a +later release or skips the version, recording that ruling on the epic before +closing it unshipped. + +## One primary window, declared parallel tracks + +Run one primary release window by default. A cut takes whatever has landed, so +interleaving unrelated windows blurs both the release story and the evidence +behind it. Gates open windows; they do not silently admit members, so builders +still see one deliberately ordered queue. + +The operator may declare a parallel track at init when its footprint is +disjoint from the primary window: another repository, another artifact, or +provably non-overlapping clusters. The declaration names the boundary and any +bridge work that must rejoin the primary. [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348) +is the worked example: its app and artifact form a parallel track while its +small crew-side bridge remains in the primary window. + +## Flip mechanics + +To admit a member, strike its live `Blocked by ` declaration and +swap `blocked` to `ready` in the same edit. Never preserve history by negating +the marker phrase — the blocker parser unions declarations even when prose +says they no longer apply. Preserve the old text only after striking or +rewriting the parseable clause, then verify the parser's resulting set. + +Release membership is a decision, never a sweep default. Triage performs each +flip only after the operator blesses the wave; the issue-flow sweep may resolve +ordinary issue dependencies, but it does not choose a release's contents. +heavy-duty/crew#346 records the member-by-member flip that opened its first +wave. + +## The ledger pattern + +When a release epic has become too crufty to remain a clear working surface, +create a replacement and treat the old epic as a ledger. Do not close the old +epic until every live member declaration points at the replacement and the +blocker parser verifies the new set. Closing early can release every member +that still names the old issue. + +The [heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to +[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346) +transition is the worked example: all member declarations were re-pointed and +parse-verified before #162 closed; #162 remains the historical record while +#346 is the release's working surface. diff --git a/TRIAGE.md b/TRIAGE.md index 840c09a..75e1b6d 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -122,6 +122,7 @@ 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. +Repositories that adopt version epics follow [RELEASES.md](RELEASES.md). ## Backlog hygiene diff --git a/changelog.d/248.md b/changelog.d/248.md new file mode 100644 index 0000000..e33c4f6 --- /dev/null +++ b/changelog.d/248.md @@ -0,0 +1,3 @@ +### Added + +- Document the optional, operator-ruled release-epic flow for governed repositories. diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 99e1ce2..437d64b 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -550,14 +550,18 @@ when the pinned taxonomy declares a core label the repository lacks. Machinery is consumed by reference — GitHub fetches the workflows and actions above from the pin at run time — but documents have no runtime: an agent reads the working tree it stands in. So the agent-facing doc set -(ceremony's `docs/VENDORED.txt`: AGENTS.md, TRIAGE.md, BUILDER.md, -REVIEWER.md, LABELS.md) is vendored into each consumer at **`.ceremony/`**, +declared by ceremony's `docs/VENDORED.txt` is vendored into each consumer at **`.ceremony/`**, byte-identical to ceremony at the pin, plus a generated `.ceremony/README.md` marking the directory machine-managed. `actions/docs-sync` owns the copy: `--fix` writes it (and deletes what the manifest dropped — mirror means mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale pin goes red instead of quietly governing. +`RELEASES.md` joins that mirror with the first tag carrying ceremony#248. +It is **unreleased** until that tag exists: consumers add +`.ceremony/RELEASES.md` only with the ordinary pin bump and re-sync, never by +copying it ahead of their pinned doctrine set. + The consumer's ci.yml gains the guard alongside the others: ```yaml From a74ebb9876fd88db3ac40fc171351a4d28f5c5c2 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:33:39 +0000 Subject: [PATCH 028/162] docs: align release triggers with shipped flow --- RELEASES.md | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index 304ca8a..7942650 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -11,8 +11,10 @@ repo-local description differs from this file, this file governs. Represent each planned release with one version epic. The epic is the working surface for that release: it states the goal, names the members, and records the ordered waves as checklists. Keep the machine-readable progress checklist -under the exact heading `## Task list`; the issue-flow sweep reads that heading -when it decides whether to nudge triage about a completed epic. +under a heading matching `## Task list`, case-insensitively; the issue-flow +sweep reads task rows there until the next heading when it decides whether to +nudge triage about a completed epic. Other member or wave headings are not +completion inputs. Keep a short repo-local roadmap beside the epics. The roadmap shows the whole ladder and points to each working surface; it does not duplicate the live @@ -23,12 +25,13 @@ maps the ladder whose `0.1.2` working surface moved from the crufty ledger ## Gates -Each version epic declares the predecessor that opens it. Special ordering — -a double gate or an out-of-chain gate — is written explicitly on that epic; -there is no hidden global schedule. Shipping closes the current epic, and its -close is the signal for triage to open the next release-init cycle by hand. -Version epics carry `epic`, not a queue label: `ready` offers work to builders, -and builders never pick epics. +Each version epic declares `Blocked by `. Special ordering — a +double gate or an out-of-chain gate — is written explicitly on that epic; +there is no hidden global schedule. The epic carries `epic`, the repository's +release label, and `blocked` while the gate stands. Shipping closes the current +epic; the ordinary blocker-cleared sweep path then replaces `blocked` with +`ready` on the next epic in the same pass. No special epic promotion exists or +is required: the reconciler dispatches `blocked` before `epic`. The gate orders windows, not their contents. Members enter a release only by decision during release-init. The double gate on heavy-duty/crew#163 and the @@ -37,7 +40,11 @@ are worked examples of exceptions declared where they apply. ## Release-init -The preceding epic's close is the trigger. Triage then runs five steps: +A `ready` version epic is the trigger, and today triage must notice it and run +the cycle. [heavy-duty/ceremony#253](https://github.com/heavy-duty/ceremony/issues/253) +tracks the not-yet-shipped sweep announcement of that duty; do not treat the +announcement as present until the consumer's pin carries it. Triage runs five +steps: 1. Mint the epic's “to mint when this arc opens” list together with findings, deferred work, and discussion outcomes accumulated since the epic was From 13e8f54d605a58918c175e1891915df748680ec2 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:01:58 +0000 Subject: [PATCH 029/162] fix(issueflow): a failed read never reaches a decision function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gh api` prints a 5xx response body to stdout AND exits non-zero, and GitHub's 5xx body is a JSON object. Inside the per-issue subshell that payload passed `has("pull_request") | not`, emptied `.labels[]`, and `queue_decision` — correct on the input it was handed — wrote `needs-triage` onto a healthy epic. The run then logged `reconciled.` and exited 0 (crew#329, #247). errexit could not have caught it: a command whose status is tested by `||` runs with errexit suppressed, and the suppression extends through the whole subshell body, so the `|| log` handler is what disables the errexit that would have aborted at the failed read. Removing the handler revives errexit and loses #91's resilience, and an inline `set -e` does not re-arm it. Explicit per-read checks are the mechanism. Every read inside that subshell is now checked — the issue read on its status AND on its payload shape (an HTTP 200 whose body is `null` exits 0 and empties the label set just the same), both reads in `last_issue_activity`, and the comments read in `issue_comment_has_marker`. On failure the issue is left exactly as it is, the reason rides its own `#$n:` line, and the subshell exits with a distinguished status the sweep counts, so a deliberate skip is not reported as a crash and a genuine crash is still named byte-identically. `read_failure_reason` moves to lib/read.sh beside a new `guarded_read`, sourced by both reconcilers: labels-reconcile's copy was the only one, and the issue surface needs the identical rule. Refs #247 --- .../issueflow-reconcile.sh | 146 +++++++++++++++--- actions/labels-reconcile/labels-reconcile.sh | 25 +-- changelog.d/247.md | 16 ++ lib/read.sh | 55 +++++++ test/issueflow-reconcile.test.sh | 18 ++- 5 files changed, 215 insertions(+), 45 deletions(-) create mode 100644 changelog.d/247.md create mode 100644 lib/read.sh diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index a77df04..209ee3f 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -27,10 +27,33 @@ TRIAGE_ACTORS=() # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=lib/ruling.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" +# The guarded read and its reason line (#101, #247) — one implementation for +# both surfaces. +# shellcheck source=lib/read.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/read.sh" + +# The status a per-issue subshell exits with when it walked away from an +# unreadable fact (#247 D4). Distinguished from every other non-zero status so +# a deliberate skip is counted rather than reported as a crash — and so the +# existing crash handler still names a genuine one. +ISSUEFLOW_SKIP=3 +# Set by reconcile_issue_pass, read once by main for the D6 tail. +SKIPPED_COUNT=0 +SKIPPED_ISSUES="" log() { printf 'issueflow: %s\n' "$*"; } run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; } +skip_issue() { # $1 = issue, $2 = the whole reason clause — ends this issue's pass + # Leaves the issue exactly as it is: nothing is derived from a read that + # did not answer. Called from wherever the read lives, so no call site can + # forget to check — which is why it exits rather than returns. Every caller + # runs inside the per-issue subshell, so the exit ends that issue's pass and + # nothing else. The reason rides its own `#$n:`-prefixed line (#247 D5). + log "#$1: skipped this pass — $2" + exit "$ISSUEFLOW_SKIP" +} + load_issueflow_config() { # $1 = labels.conf local conf="$1" line seen=false [ -f "$conf" ] || { echo "issueflow: missing config: $conf" >&2; return 1; } @@ -287,6 +310,31 @@ offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET fi } +issue_payload_valid() { # $1 = the requested issue; payload on stdin + # The second of D3's two required guards, and neither subsumes the other. + # The status check catches the 504 whose body is GitHub's JSON error object + # — valid JSON that passes every jq guard and empties the label set. THIS + # one catches an HTTP 200 whose body is `null`, which exits 0 and empties it + # just the same. `.number` is checked against the issue asked for, so a + # payload about some other issue can never be reconciled as this one. + jq -e --arg n "$1" ' + type == "object" and (.number | tostring) == $n and (.labels | type) == "array" + ' >/dev/null 2>&1 +} + +skipped_tail() { # $1 = skip count, $2 = the issue numbers → the D6 line, or nothing + # `reconciled.` stays byte-identical when the pass was whole — tests pin that + # exact string, and #101 D1 is the precedent for not folding new text into a + # matched line. A partial pass says so on a line of its own, after it, so a + # consumer reading only the tail of a job log can see it. + [ "$1" -gt 0 ] || return 0 + if [ "$1" -eq 1 ]; then + printf '%s issue skipped this pass on an unreadable fact: %s\n' "$1" "$2" + else + printf '%s issues skipped this pass on unreadable facts: %s\n' "$1" "$2" + fi +} + # API edge. Marker comments make warnings and nudges idempotent across sweeps. ensure_comment() { # $1 issue, $2 marker, $3 message local n="$1" marker="$2" message="$3" @@ -295,9 +343,15 @@ ensure_comment() { # $1 issue, $2 marker, $3 message $message" >/dev/null } -issue_comment_has_marker() { # $1 issue, $2 marker - gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ - | grep -qF "" +issue_comment_has_marker() { # $1 issue, $2 marker → 0 found, 1 genuinely absent + # A failed read used to answer "no marker", which re-posts the comment the + # marker exists to suppress — absence of evidence read as evidence of + # absence (#247 D1). It cannot be a return value: every caller treats + # non-zero as "absent", so the skip is taken here, at the read. + local bodies + guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ + || skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")" + grep -qF "" <<<"$bodies" } reference_states() { @@ -324,22 +378,27 @@ offsite_timeline() { # unreadable timelines are deliberately silent gh api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1 } -last_issue_activity() { - local n="$1" created="$2" latest - latest="$({ - printf '%s\n' "$created" - gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' - # Assignment is the claim itself. Ignoring it would let an old issue be - # reclaimed in the seconds between assignment and its required draft PR. - gh api --paginate "repos/$REPO/issues/$n/timeline" \ - --jq '.[] | select(.event == "assigned") | .created_at' - } \ - | sort | tail -n1)" +last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read failed + # Both reads are checked, and a failure reports rather than answering an age + # (#247 D1). Swallowed, the comments read falls back to `created_at`, and a + # `claimed` issue created months ago but commented on seconds earlier is + # reclaimed — the live builder unassigned, under a comment asserting 48 + # hours of silence. `needs-triage` is cheap to remove; that is not. + # gh's stderr is left to flow to this function's own, where the caller's + # guarded_read captures it for the reason line. + local n="$1" created="$2" comments timeline latest + comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at')" \ + || return 1 + # Assignment is the claim itself. Ignoring it would let an old issue be + # reclaimed in the seconds between assignment and its required draft PR. + timeline="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \ + --jq '.[] | select(.event == "assigned") | .created_at')" || return 1 + latest="$(printf '%s\n%s\n%s\n' "$created" "$comments" "$timeline" | sort | tail -n1)" date -d "$latest" +%s } reconcile_issue() { - local n="$1" decision refs cross_refs states age assignees open_pr=false label owners + local n="$1" decision refs cross_refs states age created assignees open_pr=false label owners local merged_ref_pr="" transition_marker="" transition_handled=false local unchecked="" remove_claimed=claimed decision="$(queue_decision <<<"$ISSUE_LABELS")" @@ -386,7 +445,9 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in fi log "#$n: merged Refs PR -> post-merge; claim released" else - age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" + created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" + guarded_read age last_issue_activity "$n" "$created" \ + || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then # Legitimately quiet work does not run the reclaim clock. Only the # clock stops: an unassigned claim is still a repair the decision must @@ -474,8 +535,11 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null log "#$n: unstale (a ruling is pending)" fi - [ -n "${age:-}" ] \ - || age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")" + if [ -z "${age:-}" ]; then + created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" + guarded_read age last_issue_activity "$n" "$created" \ + || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" + fi reconcile_ruling "$n" "$age" "$NOW" fi } @@ -503,6 +567,34 @@ reconcile_opened_issue() { log "#$n: needs-triage (opened by $author)" } +reconcile_issue_pass() { # $1 = issue — one issue's whole pass, in its own subshell + # The subshell is #91's resilience: one unreadable or broken issue must not + # take the sweep down. What it is NOT is an errexit boundary — a command + # whose status is tested by `||` runs with errexit suppressed, and the + # suppression extends through the whole subshell body, so the handler below + # is what disables the errexit that would have caught a failed read (#247 + # D2). Removing it would revive errexit and lose #91. Explicit per-read + # checks are the mechanism instead, and each one exits with ISSUEFLOW_SKIP. + local n="$1" status=0 + ( + guarded_read ISSUE_JSON gh api "repos/$REPO/issues/$n" \ + || skip_issue "$n" "could not read the issue: $(read_failure_reason "$READ_FAILURE_STDERR")" + issue_payload_valid "$n" <<<"$ISSUE_JSON" \ + || skip_issue "$n" "the issue read answered a payload that is not issue #$n carrying a label array" + jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0 + ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" + reconcile_issue "$n" + ) || status=$? + if [ "$status" -eq "$ISSUEFLOW_SKIP" ]; then + SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) + SKIPPED_ISSUES="${SKIPPED_ISSUES:+$SKIPPED_ISSUES }#$n" + elif [ "$status" -ne 0 ]; then + # Byte-identical, and still owed: a skip is deliberate, a crash is not, + # and folding the two together would hide one behind the other (D4). + log "#$n: reconcile failed — continuing with the remaining issues" + fi +} + main() { local owner name REPO="${REPO:?set REPO to owner/name}" @@ -554,17 +646,21 @@ main() { done < <(refs_references <<<"$body") done)" - local n + local n tail_line + SKIPPED_COUNT=0 + SKIPPED_ISSUES="" for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \ --jq '.[] | select(has("pull_request") | not) | .number'); do - ( - ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")" - jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0 - ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" - reconcile_issue "$n" - ) || log "#$n: reconcile failed — continuing with the remaining issues" + reconcile_issue_pass "$n" done log "reconciled." + # The job stays green (D7): an hourly sweep over a hundred-issue board meets + # transient 504s as a matter of course, and reddening the whole run for one + # skipped issue trains consumers to ignore red — the outcome #95 and #101 + # both steered away from on the PR surface. This line is what buys back the + # auditability that costs. + tail_line="$(skipped_tail "$SKIPPED_COUNT" "$SKIPPED_ISSUES")" + [ -z "$tail_line" ] || log "$tail_line" } if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 003c92c..97ada8a 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -74,6 +74,12 @@ SELF_WORKFLOW="${SELF_WORKFLOW:-${GITHUB_WORKFLOW:-}}" # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=lib/ruling.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" +# The guarded read and its reason line (#101) — one implementation for both +# surfaces. read_failure_reason lived here until the issue surface needed the +# identical rule (#247); a second copy of it is the failure lib/ruling.sh's +# own header was written to record. +# shellcheck source=lib/read.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/read.sh" log() { printf 'labels: %s\n' "$*"; } @@ -98,25 +104,6 @@ blind_sweep_warning() { # $1 = unreadable PRs, $2 = all open PRs, $3 = sampled r fi } -read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101) - # Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL - # blobs. Collapsed so the reason is exactly one log line — a raw newline - # inside the captured per-PR output block could collide with a matched - # string — and truncated because an unbounded paste per PR per sweep is - # noise, and annotations are capped anyway. - local reason - reason="$(printf '%s' "${1-}" | tr '\n' ' ')" - if [ -z "$reason" ]; then - # Empty stderr is itself a fact (D4): a read that failed silently is a - # different observation from a denial, and must not read as one. - echo "no error output" - elif [ "${#reason}" -gt 300 ]; then - printf '%s…\n' "${reason:0:300}" - else - printf '%s\n' "$reason" - fi -} - missing_core_labels_warning() { # $1 = declared rows, $2 = repo label names local rows="$1" repo_labels="$2" row name missing="" [ -n "$repo_labels" ] || return 0 diff --git a/changelog.d/247.md b/changelog.d/247.md new file mode 100644 index 0000000..0b0c371 --- /dev/null +++ b/changelog.d/247.md @@ -0,0 +1,16 @@ +### Fixed + +- The issue sweep no longer derives label writes from a read that failed. An + HTTP 504 whose body is GitHub's JSON error object passed every guard and + emptied the label set, so a healthy epic was written `needs-triage` and the + pass reported success (#247). +- A failed comments read no longer reclaims a live claim. Swallowed, it dated + the issue by `created_at` and unassigned the builder under a comment + asserting 48 hours of silence about an issue commented on seconds earlier + (#247). +- A failed comments read no longer reads as "no marker", which re-posted the + comment the marker exists to suppress (#247). +- Every read inside the per-issue subshell is checked explicitly, on its + status and on its payload shape; the issue is left exactly as it is and the + sweep continues. A partial pass names its skipped issues after + `reconciled.` (#247). diff --git a/lib/read.sh b/lib/read.sh new file mode 100644 index 0000000..c391cb2 --- /dev/null +++ b/lib/read.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# lib/read.sh — the guarded read: an unreadable fact never invents a verdict. +# +# Both reconcilers source this file. The rule is the family's oldest one +# (#101, #95) and it has now been bought twice: the PR surface learned it +# when a permissions denial and a network hiccup left byte-identical +# evidence, and the ISSUE surface learned it when an HTTP 504 whose body is +# GitHub's JSON error object flowed straight into a decision function — +# `gh api` prints that body to stdout *and* exits non-zero, so the payload +# reaching the guards was valid JSON, `.labels[]` came back empty, and the +# sweep wrote `needs-triage` onto a healthy epic and called the pass a +# success (crew#329, #247). +# +# Two helpers, both used on both surfaces: +# - guarded_read — run a read, keep its stderr, report its status +# - read_failure_reason — render that stderr into one bounded log line +# +# What the CALLER does with a failed read is the caller's: labels-reconcile +# leaves the PR alone for the pass, issueflow-reconcile skips the issue. The +# one thing neither may do is carry a degraded value into a decision. + +guarded_read() { # $1 = variable to fill, rest = the read; sets READ_FAILURE_STDERR + # The status check and the captured stderr are one operation on purpose: a + # read whose failure is noticed but whose reason is thrown away is what + # #95 had to infer a cause from a control case for — wrongly, it turned + # out (#101 D2). Captured into a file rather than merged into stdout, so + # an unlucky error line can never be read back as the read's own payload. + local __var="$1" __err __out __rc=0 + shift + __err="$(mktemp)" || return 1 + __out="$("$@" 2>"$__err")" || __rc=$? + READ_FAILURE_STDERR="$(cat "$__err")" + rm -f "$__err" + printf -v "$__var" '%s' "$__out" + return "$__rc" +} + +read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101) + # Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL + # blobs. Collapsed so the reason is exactly one log line — a raw newline + # inside the captured per-item output block could collide with a matched + # string — and truncated because an unbounded paste per item per sweep is + # noise, and annotations are capped anyway. + local reason + reason="$(printf '%s' "${1-}" | tr '\n' ' ')" + if [ -z "$reason" ]; then + # Empty stderr is itself a fact (D4): a read that failed silently is a + # different observation from a denial, and must not read as one. + echo "no error output" + elif [ "${#reason}" -gt 300 ]; then + printf '%s…\n' "${reason:0:300}" + else + printf '%s\n' "$reason" + fi +} diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 837194a..678999f 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -289,6 +289,11 @@ check "claimed plus attention is a healthy issue" 0 "KEEP" \ INOW=2000000000 iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } +# gh's own rendering of a 5xx whose body carries a `message` key — the line +# crew#329's job log carried, verbatim (#247), and the payload beside it. +GH_STUB_STDERR="gh: We couldn't respond to your request in time. (HTTP 504)" +GH_STUB_ERROR_BODY='{"message":"We could not respond to your request in time.","documentation_url":"https://docs.github.com/rest"}' + issue_stub_gh() { if [ "$1" = api ]; then shift @@ -303,7 +308,18 @@ issue_stub_gh() { done file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" printf '%s\n' "$endpoint" >>"$TMP/api-calls" - [ ! -f "$file.error" ] || return 1 + # A `.http-error` sentinel is the real 5xx (#247): `gh api` prints the + # response body — GitHub's JSON error object — to STDOUT, says why on + # stderr, and exits non-zero. The `.error` sentinel models a failure with + # no payload, which is the *safe* path (an empty label set is empty either + # way), and is why this class was never caught. Both now speak on stderr, + # because the real gh always does and the reason line renders it. + if [ -f "$file.http-error" ]; then + cat "$file.http-error" + printf '%s\n' "$GH_STUB_STDERR" >&2 + return 1 + fi + [ ! -f "$file.error" ] || { printf '%s\n' "$GH_STUB_STDERR" >&2; return 1; } [ -f "$file" ] || { printf '[]\n'; return 0; } if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi elif [ "$1" = issue ] && [ "$2" = comment ]; then From 865d5bd1df45e4fd9e09660f098272562e67e7f4 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:06:13 +0000 Subject: [PATCH 030/162] =?UTF-8?q?test(issueflow):=20drive=20the=20real?= =?UTF-8?q?=205xx=20=E2=80=94=20a=20JSON=20error=20body=20on=20stdout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PATH-stubbed gh gains a `.http-error` mode: the response body goes to STDOUT, the reason to stderr, the status non-zero. The existing `.error` sentinel produces empty stdout, which is the *safe* path — an empty label set either way — and is why this class was never caught. The three must-fail-before cases, plus the 200-`null` path a status check alone leaves open, the suppressed-marker duplicate, the D6 tail's count and numbers, and the crash handler proven distinct from a skip. Refs #247 --- lib/read.sh | 1 + test/issueflow-reconcile.test.sh | 205 ++++++++++++++++++++++++++++++- 2 files changed, 205 insertions(+), 1 deletion(-) diff --git a/lib/read.sh b/lib/read.sh index c391cb2..1aa26be 100644 --- a/lib/read.sh +++ b/lib/read.sh @@ -29,6 +29,7 @@ guarded_read() { # $1 = variable to fill, rest = the read; sets READ_FAILURE_STD shift __err="$(mktemp)" || return 1 __out="$("$@" 2>"$__err")" || __rc=$? + # shellcheck disable=SC2034 # the out-parameter: every caller reads it beside the status READ_FAILURE_STDERR="$(cat "$__err")" rm -f "$__err" printf -v "$__var" '%s' "$__out" diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 678999f..3ba46c4 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -293,6 +293,7 @@ iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } # crew#329's job log carried, verbatim (#247), and the payload beside it. GH_STUB_STDERR="gh: We couldn't respond to your request in time. (HTTP 504)" GH_STUB_ERROR_BODY='{"message":"We could not respond to your request in time.","documentation_url":"https://docs.github.com/rest"}' +export GH_STUB_STDERR # the PATH-stubbed gh of the executable runs reads it too issue_stub_gh() { if [ "$1" = api ]; then @@ -645,6 +646,119 @@ churned="$(issue_probe 24 $'claimed\nneeds-ruling')" check "8 real-quiet days nudge through a 2-day-old label churn" 0 "" \ grep -q 'ruling nudge' <<<"$churned" +# --------------------------------------------------------------------------- +# An unreadable fact invents no verdict on the issue surface either (#247). +# `gh api` prints a 5xx body to stdout AND exits non-zero, and GitHub's 5xx +# body is a JSON object — so the payload that reached the guards was valid +# JSON, `.labels[]` came back empty, and queue_decision was handed the wrong +# input. The pure guards first, then the two decisions the fall-through +# reached. +# --------------------------------------------------------------------------- +payload_refused() { ! issue_payload_valid "$@"; } # 0 when the payload is refused + +check "a healthy issue payload is accepted" 0 "" \ + issue_payload_valid 40 <<<'{"number":40,"labels":[{"name":"ready"}]}' +check "an issue carrying no labels at all is still a valid payload" 0 "" \ + issue_payload_valid 40 <<<'{"number":40,"labels":[]}' +# The reported shape: gh renders `gh: (HTTP 504)` from a body with a +# `message` key, which proves the body was valid JSON. The status check is what +# catches this one; the shape check refuses it independently. +check "a JSON error object is not an issue payload" 0 "" \ + payload_refused 40 <<<"$GH_STUB_ERROR_BODY" +# The live path a status check alone would leave open (D3): 200, exit 0, and +# `.labels[]` empties exactly as it does on the 504. +check "an HTTP 200 whose body is null is refused" 0 "" payload_refused 40 <<<'null' +check "a payload missing .labels is refused" 0 "" \ + payload_refused 40 <<<'{"number":40}' +check "a payload whose .labels is not an array is refused" 0 "" \ + payload_refused 40 <<<'{"number":40,"labels":"ready"}' +check "a payload about a different issue is refused" 0 "" \ + payload_refused 40 <<<'{"number":41,"labels":[]}' +check "a payload that is not JSON at all is refused" 0 "" \ + payload_refused 40 <<<'not json' +check "an empty payload is refused" 0 "" payload_refused 40 "$(cfix 50)" +printf '%s\n' "$GH_STUB_ERROR_BODY" >"$(cfix 50).http-error" +jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ + '[{"event":"assigned","created_at":$at}]' >"$(tfix 50)" +claim_edits_before="$(wc -l <"$TMP/issue-edits")" +check "a 504 on the comments read skips the issue instead of grading its age" \ + 3 "#50: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ + issue_probe 50 claimed 1 +check "...so the live claim is not reclaimed" 1 "" \ + grep -q 'stale claim reclaimed -> ready' <<<"$(issue_probe 50 claimed 1)" +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "...no unassign, no label swap, and no reclaim comment" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ + "$claim_edits_before" "$TMP/issue-edits" "$TMP/posted-50" + +# -- the suppressed comment: a 504 on the marker read ----------------------- +# The marker is on the issue. Read as "no marker", a failed read re-posts the +# comment the marker exists to suppress — every sweep, forever. +jq -n --arg b '' \ + --arg at "$(iso_at $((INOW - 3600)))" \ + '[{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/m","body":$b}]' \ + >"$(cfix 51)" +printf '%s\n' "$GH_STUB_ERROR_BODY" >"$(cfix 51).http-error" +check "a 504 on the marker read skips rather than reading it as no marker" \ + 3 "#51: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ + issue_probe 51 blocked 1 false "" "no parseable declaration here" +check "...so no duplicate comment is posted" 1 "" test -f "$TMP/posted-51" + +# -- a deliberate skip is counted; a genuine crash is still named (D4) ------- +printf '%s\n' '{"number":60,"labels":[{"name":"ready"}],"assignees":[]}' \ + >"$TMP/repos_owner_repo_issues_60.json" +printf '%s\n' '{"number":61,"labels":[{"name":"ready"}],"assignees":[]}' \ + >"$TMP/repos_owner_repo_issues_61.json" +printf '%s\n' "$GH_STUB_ERROR_BODY" >"$TMP/repos_owner_repo_issues_61.json.http-error" +pass_probe() { # $1 issue; $2 non-empty makes reconcile_issue crash + ( + REPO=owner/repo + gh() { issue_stub_gh "$@"; } + [ -z "${2:-}" ] || reconcile_issue() { return 9; } + SKIPPED_COUNT=0 + SKIPPED_ISSUES="" + reconcile_issue_pass "$1" + printf 'rc=%s count=%s issues=%s\n' "$?" "$SKIPPED_COUNT" "$SKIPPED_ISSUES" + ) +} +check "a genuine non-read crash still names the failure byte-identically" 0 \ + "issueflow: #60: reconcile failed — continuing with the remaining issues" \ + pass_probe 60 crash +check "...and the pass still returns 0, so the loop reaches the next issue" 0 \ + "rc=0" pass_probe 60 crash +check "...and a crash is not counted as a skip" 0 "count=0" pass_probe 60 crash +check "a skipped issue is counted and named" 0 "count=1 issues=#61" pass_probe 61 +check "...and is not also reported as a crash" 1 "" \ + grep -q 'reconcile failed' <<<"$(pass_probe 61)" +check "...leaving the loop free to continue" 0 "rc=0" pass_probe 61 + # --------------------------------------------------------------------------- # The arrival path, executed the way the action executes it (#91): four # triage-authored mints died silently because the stand-down `return`s in @@ -681,7 +795,15 @@ if [ "$1" = api ]; then *'states: MERGED'*) file="$GH_FIXTURES/graphql-merged.json" ;; esac fi - [ ! -f "$file.error" ] || exit 1 + # `.http-error` is the real 5xx (#247): the response body — GitHub's JSON + # error object — goes to STDOUT, the reason to stderr, and the status is + # non-zero. `.error` is the payload-free failure, which is the safe path. + if [ -f "$file.http-error" ]; then + cat "$file.http-error" + printf '%s\n' "${GH_STUB_STDERR:-}" >&2 + exit 1 + fi + [ ! -f "$file.error" ] || { printf '%s\n' "${GH_STUB_STDERR:-}" >&2; exit 1; } if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$payload"; else printf '%s\n' "$payload"; fi exit 0 @@ -849,4 +971,85 @@ check "a dead API on the arrival path still fails the run (D2)" 0 "" \ check "...and the sweep does not run over a lying arrival" 1 "" \ grep -qF 'issueflow: reconciled.' <<<"$err_out" +# --------------------------------------------------------------------------- +# The whole sweep over an unreadable board (#247), executed. The sourced +# probes above drive one issue's pass; only this path exercises the loop, the +# counting and the tail — and only this path reproduces crew#329's log, which +# ended `issueflow: reconciled.` with rc=0 over a label it should never have +# written. Its own fixture directory: the arrival fixtures above are stateful +# across their cases. +# --------------------------------------------------------------------------- +SWEEP="$TMP/sweep" +mkdir -p "$SWEEP" +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$SWEEP/graphql-open.json" +cp "$SWEEP/graphql-open.json" "$SWEEP/graphql-merged.json" +# 70: the 504 with a JSON error body on the per-issue read. +printf '%s\n' "$GH_STUB_ERROR_BODY" >"$SWEEP/repos_owner_repo_issues_70.json.http-error" +# 71: healthy, and carrying no queue label — so if the sweep reaches it, it +# writes needs-triage. That write is the evidence the loop continued. +printf '%s\n' \ + '{"number":71,"user":{"login":"triage-one"},"labels":[{"name":"enhancement"}],"assignees":[]}' \ + >"$SWEEP/repos_owner_repo_issues_71.json" +# 72: HTTP 200 whose body is `null` — exit 0, and the label set empties just +# as it does on the 504. The shape check is the only thing that catches it. +printf 'null\n' >"$SWEEP/repos_owner_repo_issues_72.json" + +sweep_board() { printf '%s\n' "$1" >"$SWEEP/repos_owner_repo_issues_state_open_per_page_100.json"; } +sweep_run() { + : >"$SWEEP/edits" + env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$SWEEP" ISSUEFLOW_NOW="$INOW" \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +} + +sweep_board '[{"number":70},{"number":71}]' +sweep_out="$(sweep_run)" +sweep_rc=$? +check "an unreadable issue does not red the sweep (D7)" 0 "" test "$sweep_rc" -eq 0 +check "the 504's JSON error body is skipped, with the reason named" 0 \ + "issueflow: #70: skipped this pass — could not read the issue: $GH_STUB_STDERR" \ + printf '%s\n' "$sweep_out" +check "...and crew#329's label is never written" 1 "" \ + grep -qF '#70: needs-triage (no queue state)' <<<"$sweep_out" +check "...nor any edit at all on the unreadable issue" 1 "" \ + grep -qF 'issue edit 70' "$SWEEP/edits" +check "...while the readable issue beside it is reconciled as before" 0 "" \ + grep -qxF 'issue edit 71 -R owner/repo --add-label needs-triage' "$SWEEP/edits" +check "...and the partial pass names its count and its issue" 0 \ + 'issueflow: 1 issue skipped this pass on an unreadable fact: #70' \ + printf '%s\n' "$sweep_out" +check "...after a byte-identical reconciled. line" 0 "" \ + grep -qxF 'issueflow: reconciled.' <<<"$sweep_out" + +sweep_board '[{"number":72}]' +null_out="$(sweep_run)" +null_rc=$? +check "an HTTP 200 whose body is null exits 0 and writes nothing" 0 "" \ + test "$null_rc" -eq 0 +check "...because the shape check refuses it, on its own line" 0 \ + 'issueflow: #72: skipped this pass — the issue read answered a payload that is not issue #72 carrying a label array' \ + printf '%s\n' "$null_out" +check "...so no label is derived from an empty label set" 1 "" \ + grep -qF 'issue edit 72' "$SWEEP/edits" +check "...and the tail names it too" 0 \ + 'issueflow: 1 issue skipped this pass on an unreadable fact: #72' \ + printf '%s\n' "$null_out" + +sweep_board '[{"number":70},{"number":72}]' +both_out="$(sweep_run)" +check "two skipped issues are both named, in the plural" 0 \ + 'issueflow: 2 issues skipped this pass on unreadable facts: #70 #72' \ + printf '%s\n' "$both_out" + +sweep_board '[{"number":71}]' +whole_out="$(sweep_run)" +whole_rc=$? +check "a whole pass still exits 0" 0 "" test "$whole_rc" -eq 0 +check "...ends on the byte-identical reconciled. line, with no tail after it" 0 \ + "issueflow: reconciled." printf '%s\n' "$(tail -n1 <<<"$whole_out")" +check "...and says nothing about skipping" 1 "" \ + grep -q 'skipped this pass' <<<"$whole_out" + summary From 24f62cc8e96a11c52ca3f36c13de5146e41b57e2 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:08:19 +0000 Subject: [PATCH 031/162] test(issueflow): the failing --jq read yields no timestamps, as gh does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .http-error mode applies a requested --jq filter to the error body, so a failing comments read returns nothing rather than a JSON blob — which is what let last_issue_activity fall back to created_at and reclaim a live claim. With it, all three of the issue's must-fail-before cases fail against the pre-change script, the destroyed claim included. Pin offsite_timeline's own deliberate silence directly: the activity read hits the same endpoint, so probe 32 now skips before the offsite verification it used to reach (D8 leaves that read alone). Refs #247 --- test/issueflow-reconcile.test.sh | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 3ba46c4..2ac02b0 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -316,7 +316,14 @@ issue_stub_gh() { # way), and is why this class was never caught. Both now speak on stderr, # because the real gh always does and the reason line renders it. if [ -f "$file.http-error" ]; then - cat "$file.http-error" + # A --jq call gets the filter applied to the error body, as gh does. + # That is what "yields no timestamps" looks like — the shape that let + # last_issue_activity fall back to created_at and reclaim a live claim. + if [ -n "$jqexpr" ]; then + jq -r "$jqexpr" "$file.http-error" 2>/dev/null || true + else + cat "$file.http-error" + fi printf '%s\n' "$GH_STUB_STDERR" >&2 return 1 fi @@ -593,6 +600,16 @@ unreadable="$(issue_probe 32 $'claimed\noffsite')" check "an unreadable timeline stays silent" 1 "" test -f "$TMP/posted-32" check "...and leaves the sweep running without an alarming log" 1 "" \ grep -qiE 'error|failed' <<<"$unreadable" +# Both checks above still hold, and #247 D1 changed what reaches them: +# last_issue_activity reads the same timeline endpoint, so the issue is now +# skipped before the offsite verification runs. The skip is why nothing is +# posted, and its reason line is a deliberate report rather than an alarm +# (D4). D8 leaves offsite_timeline's own silence alone, so it is pinned here +# directly rather than through a probe that can no longer reach it. +offsite_timeline_probe() { ( REPO=owner/repo; gh() { issue_stub_gh "$@"; }; offsite_timeline "$1" ); } +check "an unreadable offsite timeline yields nothing and still fails closed" 1 "" \ + offsite_timeline_probe 32 +check "...while a readable one answers its payload" 0 "[]" offsite_timeline_probe 31 : >"$TMP/api-calls" printf '[]\n' >"$(tfix 33)" @@ -799,7 +816,11 @@ if [ "$1" = api ]; then # error object — goes to STDOUT, the reason to stderr, and the status is # non-zero. `.error` is the payload-free failure, which is the safe path. if [ -f "$file.http-error" ]; then - cat "$file.http-error" + if [ -n "$jqexpr" ]; then + jq -r "$jqexpr" "$file.http-error" 2>/dev/null || true + else + cat "$file.http-error" + fi printf '%s\n' "${GH_STUB_STDERR:-}" >&2 exit 1 fi From f965e8404c3b5312896aeebb990da0d24dfb3e14 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:14:19 +0000 Subject: [PATCH 032/162] test(issueflow): an absent fixture answers nothing to a --jq read, as gh does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sourced stub returned a literal `[]` to --jq callers when a fixture was missing, where the real API answers an empty list and the filter yields nothing. last_issue_activity then sorted `[]` beside an ISO-8601 timestamp — and `[]` outsorts a timestamp in the C locale but not in a UTF-8 one, so the sweep dated an issue by a stub artifact on the runner and by created_at here. The old code swallowed the resulting `date` failure and graded the claim on a literal 0 anyway; #247's guards turn a failed read into a skip, which is what made the lie visible. Adopt the arrival stub's shape. Suite green under LC_ALL=C, C.UTF-8 and en_US.UTF-8. Refs #247 --- test/issueflow-reconcile.test.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 2ac02b0..07f4d9c 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -328,8 +328,17 @@ issue_stub_gh() { return 1 fi [ ! -f "$file.error" ] || { printf '%s\n' "$GH_STUB_STDERR" >&2; return 1; } - [ -f "$file" ] || { printf '[]\n'; return 0; } - if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi + # An absent fixture answers an empty list, and a --jq call gets the filter + # applied to it — the arrival stub's shape, and gh's. Returning the raw + # `[]` to a --jq caller made every missing fixture answer a literal `[]` + # where the real API answers nothing, and `[]` outsorts an ISO-8601 + # timestamp in the C locale but not in a UTF-8 one, so last_issue_activity + # dated an issue by a stub artifact on the runner and by its created_at + # here. The old code swallowed the resulting date failure; #247's guards + # turn it into a skip, which is what made the lie visible. + local payload='[]' + [ ! -f "$file" ] || payload="$(cat "$file")" + if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$payload"; else printf '%s\n' "$payload"; fi elif [ "$1" = issue ] && [ "$2" = comment ]; then local n="$3" body="" file shift 3 From 8c29424519a01cdbf48df96aa24ace87eb096956 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:26:58 +0000 Subject: [PATCH 033/162] docs: correct release gate mechanics --- CONTRIBUTING.md | 2 +- RELEASES.md | 32 +++++++++++++++++++------------- changelog.d/248.md | 2 +- docs/VENDORED.txt | 1 + test/docs-sync.test.sh | 6 ++++++ 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b1666c..9c8a7b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,7 +96,7 @@ Two consumption modes, split by what has a runtime: "runtime" is an agent reading the working tree of the repo it stands in — a doc that requires a cross-repo fetch before it governs is a doc that sometimes goes unread. So the agent-facing set — **AGENTS.md, TRIAGE.md, - BUILDER.md, REVIEWER.md, LABELS.md** — is vendored into each governed + BUILDER.md, REVIEWER.md, LABELS.md, RELEASES.md** — is vendored into each governed repo at **`.ceremony/`**, byte-identical to this repo at the pinned ref, by the sync tool (issue #19). A CI guard diffs the mirror against the pin on every PR: hand-editing a vendored file, or bumping the pin without diff --git a/RELEASES.md b/RELEASES.md index 7942650..d3d6816 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -27,21 +27,24 @@ maps the ladder whose `0.1.2` working surface moved from the crufty ledger Each version epic declares `Blocked by `. Special ordering — a double gate or an out-of-chain gate — is written explicitly on that epic; -there is no hidden global schedule. The epic carries `epic`, the repository's -release label, and `blocked` while the gate stands. Shipping closes the current -epic; the ordinary blocker-cleared sweep path then replaces `blocked` with -`ready` on the next epic in the same pass. No special epic promotion exists or -is required: the reconciler dispatches `blocked` before `epic`. +there is no hidden global schedule. The epic carries `epic` and the +repository's release label, with no queue label. Its `Blocked by` line is a +declaration a human reads: shipping closes the predecessor, then triage opens +the next window by hand as the first step of release-init. The issue-flow +sweep does not promote version epics; automating that gate would require a +separately specified change to its queue-category model. The gate orders windows, not their contents. Members enter a release only by -decision during release-init. The double gate on heavy-duty/crew#163 and the +decision during release-init. The double gate on +[heavy-duty/crew#163](https://github.com/heavy-duty/crew/issues/163) and the out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348) are worked examples of exceptions declared where they apply. ## Release-init -A `ready` version epic is the trigger, and today triage must notice it and run -the cycle. [heavy-duty/ceremony#253](https://github.com/heavy-duty/ceremony/issues/253) +The predecessor closing and clearing the next epic's declared gate is the +trigger, and today triage must notice it and open that window by hand. +[heavy-duty/ceremony#253](https://github.com/heavy-duty/ceremony/issues/253) tracks the not-yet-shipped sweep announcement of that duty; do not treat the announcement as present until the consumer's pin carries it. Triage runs five steps: @@ -80,11 +83,14 @@ small crew-side bridge remains in the primary window. ## Flip mechanics -To admit a member, strike its live `Blocked by ` declaration and -swap `blocked` to `ready` in the same edit. Never preserve history by negating -the marker phrase — the blocker parser unions declarations even when prose -says they no longer apply. Preserve the old text only after striking or -rewriting the parseable clause, then verify the parser's resulting set. +To admit a member, delete or rewrite its literal, parseable +`Blocked by ` declaration and swap `blocked` to `ready` in the same +edit. Markdown or HTML strikethrough is insufficient: the blocker parser reads +the raw marker text and still returns the reference. Never preserve history by +negating the marker phrase — the parser unions declarations even when prose +says they no longer apply. Preserve the history only after rewriting the +marker into non-parseable prose, then verify that the parser returns an empty +set for the release gate. Release membership is a decision, never a sweep default. Triage performs each flip only after the operator blesses the wave; the issue-flow sweep may resolve diff --git a/changelog.d/248.md b/changelog.d/248.md index e33c4f6..d6c3bbd 100644 --- a/changelog.d/248.md +++ b/changelog.d/248.md @@ -1,3 +1,3 @@ ### Added -- Document the optional, operator-ruled release-epic flow for governed repositories. +- Document the optional, operator-ruled release-epic flow for governed repositories. (#248) diff --git a/docs/VENDORED.txt b/docs/VENDORED.txt index 10c20a3..ff41f35 100644 --- a/docs/VENDORED.txt +++ b/docs/VENDORED.txt @@ -3,3 +3,4 @@ TRIAGE.md BUILDER.md REVIEWER.md LABELS.md +RELEASES.md diff --git a/test/docs-sync.test.sh b/test/docs-sync.test.sh index 6987304..f5ecae9 100644 --- a/test/docs-sync.test.sh +++ b/test/docs-sync.test.sh @@ -18,6 +18,12 @@ SCRIPT="$ROOT/actions/docs-sync/docs-sync.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT +# RELEASES.md's consumer-availability promise is true only when the real +# manifest carries it (#248's review round). The fixture cases below prove +# manifest-driven behavior; this row binds that behavior to the promised file. +check "real manifest includes the release doctrine" 0 "RELEASES.md" \ + grep -Fx RELEASES.md "$ROOT/docs/VENDORED.txt" + # --- fixture builders -------------------------------------------------------- # The main fake ceremony tree: three manifest entries, one in a subdirectory From 6217798e1452ac5074f7e5d1018e415ec3bc68a0 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:55:52 +0000 Subject: [PATCH 034/162] fix(issueflow): a per-issue pass commits its whole effect, or none of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-read guards closed the reported class — a failed read never reaches a decision function — and left one layer standing. A pass could mutate and only THEN reach a guarded read, fail it, and report the issue as skipped: `stale` removed, or `needs-triage` minted, under a log line saying the sweep had touched nothing. That is the same false report #247 exists to close, told from the other end, and the panel reproduced it on four separate compositions. Fixed as the ordering invariant rather than per site. Inside reconcile_issue_pass's subshell, run() and log() stage their effects, and commit_staged_effects replays them in order once the pass has completed. skip_issue emits its own line directly and exits, so the buffer dies with the subshell. A skip therefore implies zero `gh issue edit`, zero `gh issue comment`, and no log line about a mutation that never landed — for compositions nobody has written yet, because reconcile_issue has no way to mutate directly. Reads stay where they are: they may happen anywhere, since nothing lands until the end. Stated per site it would hold until the next composition. Two consequences worth naming: reconcile_ruling is covered without touching lib/ruling.sh, because it posts through the sourcing script's run()/log() — the PR surface keeps its own and is unaffected; and a genuine crash mid-pass now also lands nothing, where before it left the earlier mutations applied. D4's handler string, D6's tail and D7's exit 0 are all unchanged, and the healthy path is byte-identical: every staged write commits under the same `>/dev/null` its call site already applied. Refs #247 --- .../issueflow-reconcile.sh | 78 ++++++++- changelog.d/247.md | 4 + lib/read.sh | 8 +- test/issueflow-reconcile.test.sh | 160 ++++++++++++++++++ 4 files changed, 241 insertions(+), 9 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 209ee3f..4e29f84 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -41,16 +41,67 @@ ISSUEFLOW_SKIP=3 SKIPPED_COUNT=0 SKIPPED_ISSUES="" -log() { printf 'issueflow: %s\n' "$*"; } -run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; } +# A per-issue pass is ATOMIC: it commits its whole effect or none of it +# (#247 D1). Inside reconcile_issue_pass's subshell, `run` and `log` do not +# act — they append here, and commit_staged_effects replays them in order +# once the pass has completed. Everywhere else (the arrival path, the sweep's +# own lines) they act immediately, as they always did. +# +# This is the ordering invariant itself, not a fix for the two sites that +# happened to violate it: a mutation reached before a later guarded read is +# what let a pass remove `stale`, or mint `needs-triage`, and THEN report the +# issue as skipped — the sweep saying it touched nothing while a write had +# landed, which is the same false-report class #247 exists to close. Stated +# per site it would hold until the next composition; stated here it holds for +# compositions nobody has written yet, because reconcile_issue has no way to +# mutate directly. +# +# Reads are deliberately NOT staged. They may happen anywhere in the pass, +# because nothing lands until the end. +STAGING=false +STAGED_EFFECTS=() + +emit() { printf 'issueflow: %s\n' "$*"; } +apply() { if [ -n "${DRY_RUN:-}" ]; then emit "DRY_RUN: $*"; else "$@"; fi; } + +stage() { # $1 = LOG|WRITE, rest = the effect's argv, kept exact by the count + STAGED_EFFECTS+=("$#" "$@") +} + +log() { if [ "$STAGING" = true ]; then stage LOG "$@"; else emit "$@"; fi; } +run() { if [ "$STAGING" = true ]; then stage WRITE "$@"; else apply "$@"; fi; } + +commit_staged_effects() { + # In staging order, so a completed pass's log and writes read exactly as + # they did when each acted at its own call site. The `>/dev/null` is the one + # every `run` call site already applies: a redirection cannot travel with + # the argv, so it is applied here instead — uniformly, because on this + # surface every staged write has it. + local i=0 argc + STAGING=false + while [ "$i" -lt "${#STAGED_EFFECTS[@]}" ]; do + argc="${STAGED_EFFECTS[i]}" + if [ "${STAGED_EFFECTS[i + 1]}" = LOG ]; then + emit "${STAGED_EFFECTS[@]:i + 2:argc - 1}" + else + apply "${STAGED_EFFECTS[@]:i + 2:argc - 1}" >/dev/null + fi + i=$((i + 1 + argc)) + done + STAGED_EFFECTS=() +} skip_issue() { # $1 = issue, $2 = the whole reason clause — ends this issue's pass # Leaves the issue exactly as it is: nothing is derived from a read that - # did not answer. Called from wherever the read lives, so no call site can - # forget to check — which is why it exits rather than returns. Every caller - # runs inside the per-issue subshell, so the exit ends that issue's pass and - # nothing else. The reason rides its own `#$n:`-prefixed line (#247 D5). - log "#$1: skipped this pass — $2" + # did not answer, and nothing this pass staged is ever committed — `exit` + # discards the subshell that holds the buffer. So a skip implies zero + # `gh issue edit`, zero `gh issue comment`, and no log line claiming an + # effect that never landed, wherever in the pass the failed read lives. + # Called from the read itself, so no call site can forget to check — which + # is why it exits rather than returns. The reason rides its own + # `#$n:`-prefixed line (#247 D5), emitted directly: the skip is a fact + # about the pass, not one of the effects the pass staged. + emit "#$1: skipped this pass — $2" exit "$ISSUEFLOW_SKIP" } @@ -575,15 +626,26 @@ reconcile_issue_pass() { # $1 = issue — one issue's whole pass, in its own sub # is what disables the errexit that would have caught a failed read (#247 # D2). Removing it would revive errexit and lose #91. Explicit per-read # checks are the mechanism instead, and each one exits with ISSUEFLOW_SKIP. + # + # What the subshell IS, since #247's first round, is the atomicity + # boundary: the staged effects live in it, so ending it — by a skip, or by + # a crash — discards them, and no partial pass can ever reach the board. local n="$1" status=0 ( + # Everything below stages rather than acts, and commits at the bottom — + # so a skip taken at any read, and a crash at any statement, leaves the + # issue exactly as it was (D1). `|| exit $?` keeps a crash's status the + # subshell's own, as it was when reconcile_issue was the last command + # here: the commit must not overwrite it, and must not run under it. + STAGING=true guarded_read ISSUE_JSON gh api "repos/$REPO/issues/$n" \ || skip_issue "$n" "could not read the issue: $(read_failure_reason "$READ_FAILURE_STDERR")" issue_payload_valid "$n" <<<"$ISSUE_JSON" \ || skip_issue "$n" "the issue read answered a payload that is not issue #$n carrying a label array" jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0 ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" - reconcile_issue "$n" + reconcile_issue "$n" || exit $? + commit_staged_effects ) || status=$? if [ "$status" -eq "$ISSUEFLOW_SKIP" ]; then SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) diff --git a/changelog.d/247.md b/changelog.d/247.md index 0b0c371..a2243dd 100644 --- a/changelog.d/247.md +++ b/changelog.d/247.md @@ -14,3 +14,7 @@ status and on its payload shape; the issue is left exactly as it is and the sweep continues. A partial pass names its skipped issues after `reconciled.` (#247). +- A per-issue pass is now atomic: its writes and its log lines commit only + once the pass completes. A skip could previously land after an earlier + mutation, reporting an issue as untouched when a label had already been + written or removed (#247). diff --git a/lib/read.sh b/lib/read.sh index 1aa26be..b3a0a7a 100644 --- a/lib/read.sh +++ b/lib/read.sh @@ -11,10 +11,16 @@ # sweep wrote `needs-triage` onto a healthy epic and called the pass a # success (crew#329, #247). # -# Two helpers, both used on both surfaces: +# Two helpers: # - guarded_read — run a read, keep its stderr, report its status # - read_failure_reason — render that stderr into one bounded log line # +# `read_failure_reason` is called from both surfaces. `guarded_read` is +# called from the issue surface only, and that is deliberate rather than +# unfinished: labels-reconcile's two capture sites are byte-identical to each +# other and predate this file, and converting them is a cleanup #247 does not +# own. Do not go looking for a labels-side caller — there is none yet. +# # What the CALLER does with a failed read is the caller's: labels-reconcile # leaves the PR alone for the pass, issueflow-reconcile skips the issue. The # one thing neither may do is carry a degraded value into a decision. diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 07f4d9c..e36adda 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -1082,4 +1082,164 @@ check "...ends on the byte-identical reconciled. line, with no tail after it" 0 check "...and says nothing about skipping" 1 "" \ grep -q 'skipped this pass' <<<"$whole_out" +# --------------------------------------------------------------------------- +# The ordering invariant (#247 D1): a skip implies ZERO writes, wherever in +# the pass the failed read lives. Round 1 measured what the per-read guards +# alone left standing — a pass could remove `stale`, or mint `needs-triage`, +# and only then reach a guarded read, fail it, and report the issue as +# skipped. The sweep said it had touched nothing while a write had landed: +# the same false report #247 exists to close, one layer along. +# +# Every composition is driven TWICE against identical fixtures, differing +# only in whether the late read answers. The healthy run is the control — it +# proves the mutation is genuinely on this path, so the failing run's "no +# edit" is a fact about the guard and not about a branch that never fired. +# Executed through the sweep, because staging is a property of the pass. +# --------------------------------------------------------------------------- +ORDER="$TMP/order" +mkdir -p "$ORDER" +cp "$SWEEP/graphql-open.json" "$SWEEP/graphql-merged.json" "$ORDER/" +order_board() { printf '%s\n' "$1" >"$ORDER/repos_owner_repo_issues_state_open_per_page_100.json"; } +order_fixture() { # $1 issue, $2 labels JSON, $3 body + jq -n --argjson n "$1" --argjson labels "$2" --arg body "${3:-}" \ + --arg at "$(iso_at $((INOW - 10 * 86400)))" \ + '{number: $n, created_at: $at, user: {login: "triage-one"}, + labels: $labels, assignees: [], body: $body}' \ + >"$ORDER/repos_owner_repo_issues_$1.json" +} +order_run() { + : >"$ORDER/edits" + env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ORDER" ISSUEFLOW_NOW="$INOW" \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +} +# The late read fails, or answers. `guarded_read` is what turns either into a +# skip, so which endpoint carries the sentinel is what picks the composition. +order_breaks() { printf '%s\n' "$GH_STUB_ERROR_BODY" >"$ORDER/repos_owner_repo_issues_$1_$2.json.http-error"; } +order_heals() { rm -f "$ORDER/repos_owner_repo_issues_$1_$2.json.http-error"; } +# A skip must leave no trace of the staged effect: not the write, and not the +# log line that would have announced it. Both halves, because a landed write +# under a "skipped" line and a "reconciled" line over no write are the same +# lie told from opposite ends. +order_wrote() { grep -qF "issue $2 $1" "$ORDER/edits"; } + +# -- 1. unstale, then a failed activity read (the round's first composition) - +# `needs-ruling` heals an applied `stale` off before the tail reads the +# issue's activity. The read is two statements later; the write is already +# gone. +order_fixture 80 '[{"name":"ready"},{"name":"needs-ruling"},{"name":"stale"}]' +order_board '[{"number":80}]' +order_heals 80 comments +healthy_unstale="$(order_run)" +check "the control: a healthy pass really does unstale a pending ruling" 0 "" \ + order_wrote 80 edit +check "...and says so" 0 "issueflow: #80: unstale (a ruling is pending)" \ + printf '%s\n' "$healthy_unstale" +order_breaks 80 comments +broken_unstale="$(order_run)" +check "a failed activity read skips the unstale composition" 0 \ + "issueflow: #80: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ + printf '%s\n' "$broken_unstale" +check "...and the stale label is still on the issue" 1 "" order_wrote 80 edit +check "...and nothing claims it came off" 1 "" \ + grep -qF 'unstale (a ruling is pending)' <<<"$broken_unstale" + +# -- 2. ADD_NEEDS_TRIAGE, then a failed activity read (the second) ----------- +# The mint falls through — unlike FLAG_CONFLICT, which returns — into the +# same tail. crew#329's own label, written and then disowned by the log. +order_fixture 81 '[{"name":"enhancement"},{"name":"needs-ruling"}]' +order_board '[{"number":81}]' +order_heals 81 comments +healthy_mint="$(order_run)" +check "the control: a healthy pass really does mint needs-triage here" 0 "" \ + order_wrote 81 edit +check "...and says so" 0 "issueflow: #81: needs-triage (no queue state)" \ + printf '%s\n' "$healthy_mint" +order_breaks 81 comments +broken_mint="$(order_run)" +check "a failed activity read skips the needs-triage composition" 0 \ + "issueflow: #81: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ + printf '%s\n' "$broken_mint" +check "...and crew#329's label is not written on the way out" 1 "" \ + order_wrote 81 edit +check "...and nothing claims it was" 1 "" \ + grep -qF '#81: needs-triage (no queue state)' <<<"$broken_mint" + +# -- 3. the blockers->ready flip, then a failed TIMELINE read ---------------- +# The wider class: the failing read is the second one inside +# last_issue_activity, so the comments read answers and the marker check and +# the flip both complete first. A comment AND a label edit are staged. +printf '%s\n' '{"number":82,"state":"closed"}' \ + >"$ORDER/repos_owner_repo_issues_82.json" +order_fixture 83 '[{"name":"blocked"},{"name":"needs-ruling"}]' 'Blocked by #82.' +order_board '[{"number":83}]' +order_heals 83 timeline +healthy_flip="$(order_run)" +check "the control: a healthy pass really does flip cleared blockers to ready" 0 \ + "issueflow: #83: blockers closed -> ready" printf '%s\n' "$healthy_flip" +check "...writing the label edit" 0 "" order_wrote 83 edit +check "...and posting the blockers-cleared comment" 0 "" order_wrote 83 comment +order_breaks 83 timeline +broken_flip="$(order_run)" +check "a failed timeline read skips the blockers->ready composition" 0 \ + "issueflow: #83: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ + printf '%s\n' "$broken_flip" +check "...leaving the issue blocked" 1 "" order_wrote 83 edit +check "...with no comment posted about it" 1 "" order_wrote 83 comment +check "...and nothing claiming the flip happened" 1 "" \ + grep -qF 'blockers closed -> ready' <<<"$broken_flip" + +# -- 4. a posted nudge, then a failed TIMELINE read ------------------------- +# The comment-only half of the class: an epic nudge is staged, and the +# ruling tail's activity read fails after it. A comment is as much a +# mutation as a label — it is the thing markers exist to make idempotent. +order_fixture 84 '[{"name":"epic"},{"name":"needs-ruling"}]' \ + '## Task list + +- [x] #82' +order_board '[{"number":84}]' +order_heals 84 timeline +healthy_nudge="$(order_run)" +check "the control: a healthy pass really does nudge a completed epic" 0 \ + "issueflow: #84: completed epic nudged" printf '%s\n' "$healthy_nudge" +check "...by posting a comment" 0 "" order_wrote 84 comment +order_breaks 84 timeline +broken_nudge="$(order_run)" +check "a failed timeline read skips the epic-nudge composition" 0 \ + "issueflow: #84: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ + printf '%s\n' "$broken_nudge" +check "...and the nudge comment is never posted" 1 "" order_wrote 84 comment +check "...and nothing claims it was" 1 "" \ + grep -qF 'completed epic nudged' <<<"$broken_nudge" + +# -- the skip is still just a skip: counted, tailed, and green (D4, D6, D7) -- +check "a mutation-bearing composition that skips is still not a crash" 1 "" \ + grep -qF 'reconcile failed' <<<"$broken_flip" +check "...is still counted in the D6 tail" 0 \ + 'issueflow: 1 issue skipped this pass on an unreadable fact: #83' \ + printf '%s\n' "$broken_flip" +order_board '[{"number":83}]' +order_run >/dev/null +check "...and still leaves the job green (D7)" 0 "" test $? -eq 0 + +# -- the invariant is enforced at the source, not remembered ---------------- +# Staging only holds while every mutation goes through run(). A future call +# site reaching gh directly would reopen this hole silently, so it is pinned +# here rather than left to review — the shape lib/ruling.sh already uses for +# #50 D9. reconcile_opened_issue is deliberately exempt: it runs outside the +# per-issue subshell, under live errexit, and stages nothing (#247 D8). +mutation_calls() { + grep -nE '(^|[^_[:alnum:]])gh issue (edit|comment)' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" "$ROOT/lib/ruling.sh" \ + | grep -vE '^\S+:[0-9]+: *#' || true +} +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "every issue mutation on this surface goes through run()" 0 "" \ + bash -c 'while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in *"run gh issue "*) ;; *) printf "unstaged mutation: %s\n" "$line"; exit 1 ;; esac + done <<<"$1"' _ "$(mutation_calls)" +check "...and the pin sees the call sites it is guarding" 0 "" \ + test "$(mutation_calls | wc -l)" -ge 8 + summary From dbb0554288cca46cfe1067bc3eaca0104f576bd7 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:07:08 +0000 Subject: [PATCH 035/162] docs: clarify attention target --- TRIAGE.md | 5 ++++- changelog.d/230.md | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 changelog.d/230.md diff --git a/TRIAGE.md b/TRIAGE.md index 75e1b6d..e2cbe53 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -68,7 +68,10 @@ is the failure this whole flow exists to prevent. agreement is reached, record the ruling as a decision in one comment, remove the label, and return the issue to its flow in that same comment; when that ruling or any directive or answered builder question delivers - the assignee's next move in prose, set `attention` in the same comment. + the assignee's next move in prose, set `attention` in the same comment on + the assigned issue that owns the claim — never on the pull request, even + when the comment lives there. An unassigned issue is a board bug, not a + demand; repair the board rather than setting `attention`. This is not a substitute for minting work or for `needs-ruling`. 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 diff --git a/changelog.d/230.md b/changelog.d/230.md new file mode 100644 index 0000000..159ce2b --- /dev/null +++ b/changelog.d/230.md @@ -0,0 +1,5 @@ +### Fixed + +- Triage now puts `attention` on the assigned issue that owns a claim, never + on its pull request, and treats an unassigned issue as a board bug rather + than a demand (#230). From 374005ef7740f8f60afe7e047c806cb2990c47a5 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:24:59 +0000 Subject: [PATCH 036/162] feat(issueflow): echo the parsed blocker set when it changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clause parse is exact and unforgiving, and its output was invisible: every incident in this class was found by a human running the parser by hand, hours or days late. The sweep now states what it read — one marker comment per distinct parsed set, comment-only, no label writes. Refs #252 --- .../issueflow-reconcile.sh | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 4e29f84..ccc63bd 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -314,6 +314,33 @@ blocked_cross_references() { # body on stdin -> qualified refs, one per line blocked_reference_records | awk -F '\t' '$1 == "CROSS" { print $2 }' | sort -u } +blocked_parse_set() { # $1 local refs, $2 cross refs -> "{#7, #12}" | "{}" + # The parse, rendered once. The comment, the marker and the log line all + # read this one string, so the three can never disagree about what the + # machine read. Both classes are shown because both are parsed: the locals + # in the numeric order blocked_references answers, then the qualified + # references blocked_cross_references answers — a cross-repo clause is as + # capable of being readable-but-wrong as a local one. + local rendered + rendered="$( + { [ -z "$1" ] || sed 's/^/#/' <<<"$1" + [ -z "${2:-}" ] || printf '%s\n' "$2" + } | awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $0 } END { printf "\n" }' + )" + printf '{%s}\n' "$rendered" +} + +blocked_parse_marker() { # $1 rendered set -> the echo's idempotency marker + # Scoped to the SET's value, not to the issue and not to the sweep: an + # unchanged parse finds its own marker and stays quiet on a 15-minute cron, + # and a changed one cannot find it, so the change is what speaks. One + # consequence is deliberate: a declaration edited back to a set already + # echoed stays quiet too, because the thread already carries that echo. + local slug + slug="$(printf '%s' "$1" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" + printf 'blockers-parsed-%s\n' "${slug:-none}" +} + blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs local refs="$1" states="$2" cross_refs="${3:-}" if [ -n "$cross_refs" ]; then echo FLAG_CROSS_REPO @@ -450,7 +477,7 @@ last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read reconcile_issue() { local n="$1" decision refs cross_refs states age created assignees open_pr=false label owners - local merged_ref_pr="" transition_marker="" transition_handled=false + local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" local unchecked="" remove_claimed=claimed decision="$(queue_decision <<<"$ISSUE_LABELS")" case "$decision" in @@ -549,6 +576,32 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in elif has_issue_label blocked; then refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" + # The parse is echoed before any verdict is derived from it (#252). The + # clause parse is exact and unforgiving, and its output was invisible: + # crew#308 silently parsed a negated "no longer blocked by #221" as a + # blocker, crew#71 spent five days as an unresolvable queue conflict, and + # crew#284's declaration had to be re-derived by hand-running the parser. + # Every one of those was found by a human running the parser, hours or + # days late. `blocked-unparseable` already catches the UNREADABLE + # declaration; this catches the readable-but-wrong one, which no flag can + # detect because the machine cannot judge what a human meant — only state + # what it read, and let the human see the divergence in one sweep. + parsed_set="$(blocked_parse_set "$refs" "$cross_refs")" + ensure_comment "$n" "$(blocked_parse_marker "$parsed_set")" \ + "This issue's \`Blocked by\` declarations parse to: $parsed_set + +That is the exact set this sweep gates on — what the machine read, never a +judgment about whether it is what you meant. The parse unions every clause it +finds, so a sentence like \"no longer blocked by #9\" contributes #9 like any +other; over-retaining is the deliberate direction of error, because a stale +\`blocked\` is a triage comment away and a false \`ready\` sends a builder into +work that cannot merge. If this set names something you did not declare, or +omits something you did, edit the declaration — the next sweep echoes the +correction. + +*Comment only: nothing on this path writes a label. The marker carries the set +itself, so an unchanged parse never re-posts.*" + log "#$n: blocked declarations parse to $parsed_set" states="$(reference_states <<<"$refs")" decision="$(blocked_decision "$refs" "$states" "$cross_refs")" case "$decision" in From 5cfb69e10471c697b89b57b46600ae9d191779bc Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:28:31 +0000 Subject: [PATCH 037/162] test(issueflow): the parse echo, mutation-proven in both directions The idempotency contract is the marker's scope, so both directions are pinned: an unchanged set must reuse its marker (or a 15-minute cron repeats itself forever) and a changed one must not (or a misparse hides under a marker the thread already carries). crew#308's negated clause is replayed through the sweep, and the empty parse is echoed beside the untouched `blocked-unparseable` flag. Refs #252 --- .../issueflow-reconcile.sh | 2 +- changelog.d/252.md | 9 ++ test/issueflow-reconcile.test.sh | 88 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 changelog.d/252.md diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index ccc63bd..4cd916d 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -323,7 +323,7 @@ blocked_parse_set() { # $1 local refs, $2 cross refs -> "{#7, #12}" | "{}" # capable of being readable-but-wrong as a local one. local rendered rendered="$( - { [ -z "$1" ] || sed 's/^/#/' <<<"$1" + { [ -z "$1" ] || awk '{ print "#" $0 }' <<<"$1" [ -z "${2:-}" ] || printf '%s\n' "$2" } | awk '{ printf "%s%s", (NR > 1 ? ", " : ""), $0 } END { printf "\n" }' )" diff --git a/changelog.d/252.md b/changelog.d/252.md new file mode 100644 index 0000000..3b47994 --- /dev/null +++ b/changelog.d/252.md @@ -0,0 +1,9 @@ +### Added + +- The issue sweep now echoes an issue's parsed `Blocked by` set as a comment + whenever that set changes, so a readable-but-wrong declaration is visible in + one sweep instead of days later, when a human happens to run the parser by + hand (#252). +- The echo's marker carries the parsed set itself: an unchanged parse never + re-posts on a 15-minute cron, and a changed one always speaks. Comment-only + — no path here writes a label (#252). diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index e36adda..3be7cc2 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -222,6 +222,39 @@ check "cross-repo-only blocker is flagged distinctly" 0 "FLAG_CROSS_REPO" \ blocked_decision "" "" "rig#112" check "cross-repo blocker prevents false promotion when locals close" 0 "FLAG_CROSS_REPO" \ blocked_decision "9" "CLOSED" "rig#9" + +# The parse echo (#252): the machine states what it read, so a +# readable-but-wrong declaration is visible in one sweep instead of five days. +check "the rendered set names the locals in parse order" 0 "{#7, #12}" \ + blocked_parse_set "$(printf '7\n12\n')" "" +check "a single blocker still renders as a set" 0 "{#12}" blocked_parse_set "12" "" +check "an empty parse renders as the empty set" 0 "{}" blocked_parse_set "" "" +check "cross-repo references are echoed beside the locals" 0 "{#12, rig#9}" \ + blocked_parse_set "12" "rig#9" +check "a cross-repo-only parse is echoed too" 0 "{heavy-duty/box#9}" \ + blocked_parse_set "" "heavy-duty/box#9" +# crew#308: a *negated* marker phrase unions as the thing it denies, and the +# silent result was a set nobody saw until a human ran the parser. Echoed, the +# union is visible in the thread that contains the declaration. +echo_308="$(blocked_parse_set \ + "$(blocked_references <<<'Blocked by #162, #265. It is no longer blocked by #221.')" \ + "$(blocked_cross_references <<<'Blocked by #162, #265. It is no longer blocked by #221.')")" +check "the #308 shape echoes the negation-unioned blocker verbatim" 0 "" test \ + "$echo_308" = "{#162, #221, #265}" +# The marker is scoped to the SET's value — the whole idempotency contract. +# Mutation proof, both directions: same set must reuse its marker (or a +# 15-minute cron repeats itself forever), different set must not (or a +# misparse is echoed under a marker the thread already carries, and stays +# invisible — exactly the failure this change exists to close). +check "an unchanged set reuses its marker" 0 "" test \ + "$(blocked_parse_marker '{#7, #12}')" = "$(blocked_parse_marker '{#7, #12}')" +check "a changed set takes a different marker" 1 "" test \ + "$(blocked_parse_marker '{#7, #12}')" = "$(blocked_parse_marker '{#7, #12, #19}')" +check "the empty set has a marker of its own" 0 "blockers-parsed-none" \ + blocked_parse_marker "{}" +check "the marker survives a cross-repo reference's punctuation" 0 \ + "blockers-parsed-12-heavy-duty-box-9" \ + blocked_parse_marker "{#12, heavy-duty/box#9}" # shellcheck disable=SC2016 # expansions belong to the generated fake gh printf '%s\n' \ '#!/usr/bin/env bash' \ @@ -640,6 +673,61 @@ check "no reconciler mutation names offsite (#68 D4)" 1 "" \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" \ "$ROOT/actions/labels-reconcile/labels-reconcile.sh" +# -- the parse echo: one comment per changed set, none per sweep (#252) ------ +# The whole point is a sweep-visible statement of what was read, so it is +# probed through the sweep and not only as a rendering: the marker has to +# survive the comment body, the second pass has to find it, and the third has +# to miss it because the declaration changed. +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_90.json" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_91.json" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_92.json" +printf '[]\n' >"$(cfix 35)" +echo_edits_before="$(wc -l <"$TMP/issue-edits")" +first_echo="$(issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91.")" +check "a first parse is echoed, naming the set" 0 "" \ + grep -qF 'parse to: {#90, #91}' "$TMP/posted-35" +check "...and the sweep log carries the same set" 0 \ + "issueflow: #35: blocked declarations parse to {#90, #91}" \ + printf '%s\n' "$first_echo" +issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91." >/dev/null +check "an unchanged parse draws nothing on the next sweep" 0 "1" \ + grep -cF '' "$TMP/posted-35" +changed_echo="$(issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91, #92.")" +check "a body edit that changes the set draws exactly one new echo" 0 "1" \ + grep -cF '' "$TMP/posted-35" +check "...naming the new set" 0 "" \ + grep -qF 'parse to: {#90, #91, #92}' "$TMP/posted-35" +check "...and saying so in the sweep log" 0 \ + "issueflow: #35: blocked declarations parse to {#90, #91, #92}" \ + printf '%s\n' "$changed_echo" +check "...and leaving the first echo alone" 0 "1" \ + grep -cF '' "$TMP/posted-35" +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "no label write comes from the echo path" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ "$echo_edits_before" "$TMP/issue-edits" + +# crew#308, replayed through the sweep: the declaration denies #221 and the +# parse unions it anyway. Nobody saw that set for as long as it stayed inside +# the machine; the echo puts it in the thread that contains the declaration. +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_162.json" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_221.json" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_265.json" +printf '[]\n' >"$(cfix 36)" +issue_probe 36 blocked 1 false "" \ + 'Blocked by #162, #265. It is no longer blocked by #221.' >/dev/null +check "the #308 misparse is echoed verbatim, denial and all" 0 "" \ + grep -qF 'parse to: {#162, #221, #265}' "$TMP/posted-36" + +# The empty parse says so, and the flag that catches the UNREADABLE +# declaration is untouched beside it: one comment states what was read, the +# other states that nothing was. +printf '[]\n' >"$(cfix 37)" +issue_probe 37 blocked 1 false "" 'No declaration anywhere in this body.' >/dev/null +check "an empty parse is echoed as the empty set" 0 "" \ + grep -qF 'parse to: {}' "$TMP/posted-37" +check "...and blocked-unparseable still fires beside it" 0 "" \ + grep -qF '' "$TMP/posted-37" + # -- an already-applied stale heals off, and no edit names the flag ---------- jq -n --arg l "$(iso_at $((INOW - 3600)))" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' >"$(tfix 23)" From e7750c0c8ffd879eec65c46577bf8ac96e6fd011 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:48:15 +0000 Subject: [PATCH 038/162] feat: diagnose malformed attention targets --- .../issueflow-reconcile.sh | 15 +++ actions/labels-reconcile/labels-reconcile.sh | 9 ++ lib/attention.sh | 97 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 lib/attention.sh diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 4e29f84..5a09755 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -27,6 +27,9 @@ TRIAGE_ACTORS=() # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=lib/ruling.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" +# The attention target invariants (#232) — diagnosis only, both surfaces. +# shellcheck source=lib/attention.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh" # The guarded read and its reason line (#101, #247) — one implementation for # both surfaces. # shellcheck source=lib/read.sh @@ -452,6 +455,7 @@ reconcile_issue() { local n="$1" decision refs cross_refs states age created assignees open_pr=false label owners local merged_ref_pr="" transition_marker="" transition_handled=false local unchecked="" remove_claimed=claimed + local attention_active=true attention_suppression="" decision="$(queue_decision <<<"$ISSUE_LABELS")" case "$decision" in ADD_NEEDS_TRIAGE) @@ -495,6 +499,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in --remove-label "$remove_claimed" --add-label post-merge >/dev/null fi log "#$n: merged Refs PR -> post-merge; claim released" + attention_active=false else created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" guarded_read age last_issue_activity "$n" "$created" \ @@ -526,6 +531,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in fi log "#$n: stale claim reclaimed -> ready" ;; esac + [ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned if has_issue_label offsite; then local timeline if timeline="$(offsite_timeline "$n")"; then @@ -546,6 +552,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in 'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.' log "#$n: assigned or attention-bearing post-merge issue flagged" fi + attention_suppression=post-merge-assigned elif has_issue_label blocked; then refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" @@ -574,6 +581,14 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in fi fi + # The flag composes with every build queue state, but requires an assignee. + # Existing post-merge/claimed diagnostics take precedence so one board bug + # draws one comment (#232 D5); the shared helper still logs the suppression. + if [ "$attention_active" = true ] && has_issue_label attention; then + [ -n "${assignees:-}" ] || assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" + reconcile_attention "$n" issue "$assignees" "$attention_suppression" + fi + # ---- the ruling invariants (#52), on any queue state ---- # The flag composes with the queue labels (#50 D8), so this runs after the # queue branches rather than inside one of them. The FLAG_CONFLICT return diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 97ada8a..a2ef589 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -74,6 +74,9 @@ SELF_WORKFLOW="${SELF_WORKFLOW:-${GITHUB_WORKFLOW:-}}" # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=lib/ruling.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" +# The attention target invariants (#232) — diagnosis only, both surfaces. +# shellcheck source=lib/attention.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh" # The guarded read and its reason line (#101) — one implementation for both # surfaces. read_failure_reason lived here until the issue surface needed the # identical rule (#247); a second copy of it is the failure lib/ruling.sh's @@ -921,6 +924,12 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch if has_label needs-ruling; then reconcile_ruling "$n" "$last_activity_epoch" "$NOW" fi + + # `attention` belongs on the assigned issue that owns the claim, never on + # a pull request (#232). Behind the label gate so ordinary PRs pay no read. + if has_label attention; then + reconcile_attention "$n" pr "$(jq '.assignees | length' <<<"$PR_JSON")" "" + fi } main() { diff --git a/lib/attention.sh b/lib/attention.sh new file mode 100644 index 0000000..e2e2e6a --- /dev/null +++ b/lib/attention.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# lib/attention.sh — the `attention` target invariants (#232, epic #229). +# +# Both reconcilers source this file. Pure decisions sit above the divider; +# the impure orchestrator below reads the current label episode and comments +# through the sourcing script's run()/log(). The machine diagnoses only: it +# never sets, clears, retargets or assigns anything on the strength of these +# checks (#229 D2). + +ATTENTION_MARKER_PREFIX='\n' "$ATTENTION_MARKER_PREFIX" "$1" +} + +# --------------------------------------------------------------------------- +# The impure orchestrator. Called only behind a has-attention gate. +# Needs REPO; uses the caller's run() and log(). +# --------------------------------------------------------------------------- + +reconcile_attention() { # $1 item, $2 pr|issue, $3 assignees, $4 suppression + local n="$1" surface="$2" assignees="$3" suppression="${4:-}" + local target comment labeled_events labeled_at marker comments body + : "${REPO:?reconcile_attention: REPO is required}" + + target="$(attention_target_decision "$surface" "$assignees")" + comment="$(attention_comment_decision "$target" "$suppression")" + [ "$comment" != KEEP ] || return 0 + + if [ "$comment" = SUPPRESS ]; then + log "#$n: malformed attention detected; comment suppressed by $suppression precedence" + return 0 + fi + + if ! labeled_events="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \ + --jq '.[] | select(.event == "labeled" and .label.name == "attention") + | .created_at' 2>/dev/null)"; then + log "#$n: attention timeline unreadable — no verdict invented this pass" + return 0 + fi + if [ -z "$labeled_events" ]; then + log "#$n: attention flag has no visible labeled event — no verdict invented this pass" + return 0 + fi + labeled_at="$(attention_newest_flag <<<"$labeled_events")" + marker="$(attention_episode_marker "$labeled_at")" + + if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \ + --jq '.[].body // ""' 2>/dev/null)"; then + log "#$n: attention comments unreadable — no verdict invented this pass" + return 0 + fi + grep -qF "$marker" <<<"$comments" && return 0 + + case "$target" in + MALFORMED_PR) + body="$marker +This pull request carries \`attention\`, but that label is issue-only. Put +the label on the assigned issue that owns the claim. The sweep cannot infer +which issue that is, so it reports the malformed target without removing or +retargeting the label (heavy-duty/ceremony#232)." ;; + MALFORMED_UNASSIGNED) + body="$marker +This issue carries \`attention\` but has no assignee to receive the demand. +Assign the intended builder or remove the flag. The sweep reports the board +bug without assigning anyone or changing the label (heavy-duty/ceremony#232)." ;; + esac + run gh issue comment "$n" -R "$REPO" --body "$body" >/dev/null + log "#$n: malformed attention ($surface) — commented; no label or assignee changed" +} From a9b3f4d766fcaa244c36ebc30828fbf67642a466 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:53:25 +0000 Subject: [PATCH 039/162] test: cover attention target diagnostics --- LABELS.md | 8 +-- changelog.d/232.md | 5 ++ lib/attention.sh | 4 +- test/attention.test.sh | 47 +++++++++++++++++ test/issueflow-reconcile.test.sh | 87 ++++++++++++++++++++++++++++++++ test/labels-reconcile.test.sh | 75 ++++++++++++++++++++++++--- 6 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 changelog.d/232.md create mode 100644 test/attention.test.sh diff --git a/LABELS.md b/LABELS.md index af7ccf3..0f6acd4 100644 --- a/LABELS.md +++ b/LABELS.md @@ -175,9 +175,11 @@ take. It is hand-set: the machine never sets `attention`, never assigns anyone to receive one, and never decides that one has been answered — the assignee's removal is the only ack. It writes the label in exactly one place, the derived `claimed` → `post-merge` transition below, and nowhere -else; where it reads the flag it reads it to diagnose, and a diagnosis is a -comment that leaves the label alone. No reconciler enforces the assignee -requirement either: an unassigned flag may be reported, never repaired. +else; where it reads the flag it reads it to diagnose. The PR sweep comments +when `attention` is put on a pull request, and the issue sweep comments when +it is put on an issue with no assignee. Both diagnoses leave the label and +assignees alone; the machine never infers the claim issue, decides that the +demand was answered, or repairs either malformed shape. An `attention` issue without an assignee is therefore a board bug, not a demand; anyone may assign it or remove the flag. It never composes with `post-merge`, whose released claim has no assignee to answer the demand. The diff --git a/changelog.d/232.md b/changelog.d/232.md new file mode 100644 index 0000000..6785136 --- /dev/null +++ b/changelog.d/232.md @@ -0,0 +1,5 @@ +### Added + +- The label and issue-flow sweeps now comment once per episode when + `attention` targets a pull request or an unassigned issue, without + retargeting the demand or changing labels or assignees (#232). diff --git a/lib/attention.sh b/lib/attention.sh index e2e2e6a..736493a 100644 --- a/lib/attention.sh +++ b/lib/attention.sh @@ -85,12 +85,12 @@ reconcile_attention() { # $1 item, $2 pr|issue, $3 assignees, $4 suppression This pull request carries \`attention\`, but that label is issue-only. Put the label on the assigned issue that owns the claim. The sweep cannot infer which issue that is, so it reports the malformed target without removing or -retargeting the label (heavy-duty/ceremony#232)." ;; +retargeting the label." ;; MALFORMED_UNASSIGNED) body="$marker This issue carries \`attention\` but has no assignee to receive the demand. Assign the intended builder or remove the flag. The sweep reports the board -bug without assigning anyone or changing the label (heavy-duty/ceremony#232)." ;; +bug without assigning anyone or changing the label." ;; esac run gh issue comment "$n" -R "$REPO" --body "$body" >/dev/null log "#$n: malformed attention ($surface) — commented; no label or assignee changed" diff --git a/test/attention.test.sh b/test/attention.test.sh new file mode 100644 index 0000000..410c572 --- /dev/null +++ b/test/attention.test.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +source "$ROOT/test/harness.sh" +# shellcheck source=lib/attention.sh +source "$ROOT/lib/attention.sh" + +check "attention on an unassigned PR is malformed" 0 "MALFORMED_PR" \ + attention_target_decision pr 0 +check "attention on an assigned PR is still malformed" 0 "MALFORMED_PR" \ + attention_target_decision pr 1 +check "attention on an unassigned issue is malformed" 0 "MALFORMED_UNASSIGNED" \ + attention_target_decision issue 0 +check "attention on an assigned issue is healthy" 0 "KEEP" \ + attention_target_decision issue 1 +check "an unknown surface is rejected" 2 "" attention_target_decision discussion 0 + +check "a malformed PR target is commented" 0 "POST" \ + attention_comment_decision MALFORMED_PR "" +check "an unassigned issue target is commented without precedence" 0 "POST" \ + attention_comment_decision MALFORMED_UNASSIGNED "" +check "claimed-unassigned precedence suppresses the second comment" 0 "SUPPRESS" \ + attention_comment_decision MALFORMED_UNASSIGNED claimed-unassigned +check "post-merge-assigned precedence suppresses the second comment" 0 "SUPPRESS" \ + attention_comment_decision MALFORMED_UNASSIGNED post-merge-assigned +check "a healthy target stays silent" 0 "KEEP" \ + attention_comment_decision KEEP "" + +check "the newest labeled event defines the episode" 0 "2026-08-03T12:00:00Z" \ + attention_newest_flag <<'EOF' +2026-08-03T10:00:00Z +2026-08-03T12:00:00Z +2026-08-03T11:00:00Z +EOF +check "the marker names the label episode" 0 \ + '' \ + attention_episode_marker 2026-08-03T12:00:00Z + +# Diagnosis is the only write this library may own. Pin the absence of every +# label/assignee mutation spelling so a later refactor cannot quietly turn a +# report into a repair (#229 D2). +check "the attention library contains no issue/PR edit mutation" 1 "" \ + grep -E 'gh (issue|pr) edit|--(add|remove)-(label|assignee)' "$ROOT/lib/attention.sh" + +summary diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index e36adda..c89b986 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -393,6 +393,93 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 false|closing|refs, $5 m tfix() { printf '%s/repos_owner_repo_issues_%s_timeline.json' "$TMP" "$1"; } cfix() { printf '%s/repos_owner_repo_issues_%s_comments.json' "$TMP" "$1"; } +# -- malformed attention targets are diagnosed, never repaired (#232) ------- +attention_episode() { # $1 issue, $2 labeled timestamp + jq -n --arg at "$2" \ + '[{"event":"labeled","label":{"name":"attention"},"actor":{"login":"setter"},"created_at":$at}]' \ + >"$(tfix "$1")" + printf '[]\n' >"$(cfix "$1")" +} + +: >"$TMP/issue-edits" +attention_edits_before="$(wc -l <"$TMP/issue-edits")" +for n in 61 62 63; do + attention_episode "$n" "$(iso_at $((INOW - 60)))" +done +# The assignment event is the claim clock's own activity fact. The live issue +# has since lost its assignee, which is exactly the claimed-unassigned shape. +jq --arg at "$(iso_at $((INOW - 60)))" \ + '. + [{"event":"assigned","created_at":$at}]' \ + "$(tfix 63)" >"$(tfix 63).tmp" && mv "$(tfix 63).tmp" "$(tfix 63)" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_999.json" + +issue_probe 61 $'ready\nattention' 0 >/dev/null +check "unassigned attention under ready is diagnosed once" 0 "1" \ + grep -cF '' "$TMP/posted-63" +check "the suppressed attention detection remains in the log" 0 "1" \ + grep -cF 'comment suppressed by claimed-unassigned precedence' <<<"$claimed_attention" + +attention_episode 64 "$(iso_at $((INOW - 60)))" +issue_probe 64 $'ready\nattention' 1 >/dev/null +check "assigned attention under ready is healthy" 1 "" test -f "$TMP/posted-64" +attention_episode 65 "$(iso_at $((INOW - 60)))" +issue_probe 65 $'claimed\nattention' 1 true >/dev/null +check "assigned attention under claimed is healthy" 1 "" test -f "$TMP/posted-65" +attention_episode 66 "$(iso_at $((INOW - 60)))" +issue_probe 66 $'blocked\nattention' 1 false "" 'Blocked by #999' >/dev/null +check "assigned attention under blocked is healthy" 1 "" test -f "$TMP/posted-66" + +attention_episode 67 "$(iso_at $((INOW - 60)))" +post_merge_attention="$(issue_probe 67 $'post-merge\nattention' 0)" +check "post-merge precedence leaves exactly its existing comment" 0 "1" \ + grep -c -- '^----$' "$TMP/posted-67" +check "post-merge's existing diagnostic wins" 0 "1" \ + grep -cF '' "$TMP/posted-67" +check "the post-merge suppression remains in the log" 0 "1" \ + grep -cF 'comment suppressed by post-merge-assigned precedence' <<<"$post_merge_attention" + +attention_episode 68 "$(iso_at $((INOW - 120)))" +issue_probe 68 $'ready\nattention' 0 >/dev/null +issue_probe 68 $'ready\nattention' 0 >/dev/null +check "two sweeps in one malformed episode post once" 0 "1" \ + grep -cF '' "$TMP/posted-35" + grep -cF "" "$TMP/posted-35" changed_echo="$(issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91, #92.")" check "a body edit that changes the set draws exactly one new echo" 0 "1" \ - grep -cF '' "$TMP/posted-35" + grep -cF "" "$TMP/posted-35" check "...naming the new set" 0 "" \ grep -qF 'parse to: {#90, #91, #92}' "$TMP/posted-35" check "...and saying so in the sweep log" 0 \ "issueflow: #35: blocked declarations parse to {#90, #91, #92}" \ printf '%s\n' "$changed_echo" check "...and leaving the first echo alone" 0 "1" \ - grep -cF '' "$TMP/posted-35" + grep -cF "" "$TMP/posted-35" # shellcheck disable=SC2016 # positional parameters belong to bash -c check "no label write comes from the echo path" 0 "" \ bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ "$echo_edits_before" "$TMP/issue-edits" @@ -728,6 +745,32 @@ check "an empty parse is echoed as the empty set" 0 "" \ check "...and blocked-unparseable still fires beside it" 0 "" \ grep -qF '' "$TMP/posted-37" +# The collision the round found, replayed through the sweep: two declarations +# whose parsed sets differ but whose slugs do not. Keyed on the slug, the +# second edit found the first echo's marker and posted nothing — the machine +# silently gating on `acme-widgets#9` while the thread said `acme/widgets#9`, +# which is the readable-but-wrong shape this whole change exists to surface. +# Asserted end-to-end, so it is the second echo landing that is observed. +printf '[]\n' >"$(cfix 38)" +issue_probe 38 blocked 1 false "" 'Blocked by acme/widgets#9.' >/dev/null +check "a qualified cross-repo declaration is echoed" 0 "1" \ + grep -cF 'parse to: {acme/widgets#9}' "$TMP/posted-38" +collision_edits_before="$(wc -l <"$TMP/issue-edits")" +issue_probe 38 blocked 1 false "" 'Blocked by acme-widgets#9.' >/dev/null +check "a slug-colliding edit still draws its own echo" 0 "1" \ + grep -cF 'parse to: {acme-widgets#9}' "$TMP/posted-38" +check "...under a marker of its own" 0 "1" \ + grep -cF "" "$TMP/posted-38" +check "...leaving the colliding first echo alone" 0 "1" \ + grep -cF "" "$TMP/posted-38" +# The cross-repo flag is marker-constant across both parses, so it stays at one +# while the echo moves: what spoke on the second sweep was the changed set. +check "...and not re-flagging cross-repo, which did not change" 0 "1" \ + grep -cF '' "$TMP/posted-38" +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "no label write comes from the colliding-edit path either" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ "$collision_edits_before" "$TMP/issue-edits" + # -- an already-applied stale heals off, and no edit names the flag ---------- jq -n --arg l "$(iso_at $((INOW - 3600)))" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' >"$(tfix 23)" From 195c49b8e100e20f768b6ce77dfca083eca21cc8 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:07:23 +0000 Subject: [PATCH 042/162] test(issueflow): the marker's collision test is pairwise, not through one form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchoring every pair on the `/` spelling passed under a fix that only taught the slug about `/` — and that fix still collapses `acme-widgets#9`, `acme_widgets#9` and `acme.widgets#9` onto one marker. Found by mutating the implementation to that cheap fix and watching the suite stay green on the cases that matter. The contract is that no two distinct parses collide, so the assertion is now every pair. Refs #252 --- test/issueflow-reconcile.test.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index b0098cc..14a38b3 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -264,10 +264,20 @@ check "the marker survives a cross-repo reference's punctuation" 0 \ check "the slug alone cannot separate a qualified ref from a hyphenated one" 0 "" \ test "$(printf '%s' '{acme/widgets#9}' | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" \ = "$(printf '%s' '{acme-widgets#9}' | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" -for collides_with_acme_widgets in '{acme-widgets#9}' '{acme_widgets#9}' '{acme.widgets#9}'; do - check "...but the marker does, against $collides_with_acme_widgets" 1 "" test \ - "$(blocked_parse_marker '{acme/widgets#9}')" \ - = "$(blocked_parse_marker "$collides_with_acme_widgets")" +# Pairwise, and deliberately so. Anchoring every pair on the `/` spelling +# would pass under a fix that only taught the slug about `/` — and that fix +# leaves `{acme-widgets#9}`, `{acme_widgets#9}` and `{acme.widgets#9}` sharing +# one marker. The contract is that no two distinct parses collide, so the test +# is every pair, not every pair through one representative. +marker_family=( + '{acme/widgets#9}' '{acme-widgets#9}' '{acme_widgets#9}' '{acme.widgets#9}' +) +for left in "${marker_family[@]}"; do + for right in "${marker_family[@]}"; do + [ "$left" != "$right" ] || continue + check "...but the marker does: $left vs $right" 1 "" test \ + "$(blocked_parse_marker "$left")" = "$(blocked_parse_marker "$right")" + done done check "the qualifier's punctuation reaches the marker's identity" 1 "" test \ "$(blocked_parse_marker '{#12, heavy-duty/box#9}')" \ From dcf72a9af8cfe95f127e516c5df6908962a42283 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:29:51 +0000 Subject: [PATCH 043/162] feat: add refs-not-closing guard core --- .github/workflows/refs-guard.yml | 18 +++ actions/refs-not-closing/action.yml | 58 ++++++++++ actions/refs-not-closing/refs-not-closing.sh | 116 +++++++++++++++++++ test/refs-not-closing.test.sh | 94 +++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 .github/workflows/refs-guard.yml create mode 100644 actions/refs-not-closing/action.yml create mode 100755 actions/refs-not-closing/refs-not-closing.sh create mode 100755 test/refs-not-closing.test.sh diff --git a/.github/workflows/refs-guard.yml b/.github/workflows/refs-guard.yml new file mode 100644 index 0000000..ef4964e --- /dev/null +++ b/.github/workflows/refs-guard.yml @@ -0,0 +1,18 @@ +name: Refs guard + +on: + # Body edits are load-bearing: #200 gained its accidental closing keyword + # after the PR opened, with no new commit to wake ordinary CI (#218). + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + pull-requests: read + +jobs: + refs-not-closing: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: ./actions/refs-not-closing diff --git a/actions/refs-not-closing/action.yml b/actions/refs-not-closing/action.yml new file mode 100644 index 0000000..22c1f3f --- /dev/null +++ b/actions/refs-not-closing/action.yml @@ -0,0 +1,58 @@ +name: Refs not closing +description: >- + Refuse a pull request whose `Refs #N` promise contradicts GitHub's + closing-issue graph (#218). GitHub recognizes closing keywords anywhere + in a PR body, including ordinary prose and code spans; the action reads + the graph once and lets a pure script decide whether any Refs target is + already scheduled to close. +runs: + using: composite + steps: + - name: refs targets are not closing + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + owner="${GITHUB_REPOSITORY%%/*}" + name="${GITHUB_REPOSITORY#*/}" + [ -n "$PR_NUMBER" ] || { + echo "refs-not-closing: pull request number is unavailable" >&2 + exit 1 + } + + facts="$(gh api graphql \ + -f query='query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + body + closingIssuesReferences(first: 100) { + nodes { number } + pageInfo { hasNextPage } + } + } + } + }' \ + -F owner="$owner" -F name="$name" -F number="$PR_NUMBER")" + + body_file="$(mktemp)" + closing_file="$(mktemp)" + trap 'rm -f "$body_file" "$closing_file"' EXIT + jq -er ' + .data.repository.pullRequest + | if . == null then error("pull request was not returned") else .body // "" end + ' <<<"$facts" >"$body_file" + jq -er ' + .data.repository.pullRequest.closingIssuesReferences + | if .pageInfo.hasNextPage then + error("more than 100 closing issue references; refusing a partial verdict") + else + .nodes[].number + end + ' <<<"$facts" >"$closing_file" + + mapfile -t closing_issues <"$closing_file" + bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \ + "$body_file" "${closing_issues[@]}" diff --git a/actions/refs-not-closing/refs-not-closing.sh b/actions/refs-not-closing/refs-not-closing.sh new file mode 100755 index 0000000..946a6c8 --- /dev/null +++ b/actions/refs-not-closing/refs-not-closing.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +# refs-not-closing.sh [ ...] — compare the +# issues a PR promises merely to reference with GitHub's closing-issue graph +# (#218). The graph is authoritative because it includes both closing +# keywords and sidebar links. The body still matters: only an issue named by +# `Ref #N` or `Refs #N` is protected, so an ordinary `Closes #N` PR remains +# untouched. +# +# This decision stays network-free so test/refs-not-closing.test.sh can drive +# the incident matrix offline. The composite action gathers both facts in one +# GraphQL read and passes them here. A failed or partial read never reaches +# this script: action.yml refuses it before asking for a verdict. + +body_file="${1:-}" +shift || true + +[ -n "$body_file" ] && [ -f "$body_file" ] || { + echo "refs-not-closing: body file is missing or unreadable: ${body_file:-}" >&2 + exit 1 +} + +declare -A closing=() +for issue in "$@"; do + case "$issue" in + ''|*[!0-9]*) + echo "refs-not-closing: invalid closing issue number: '$issue'" >&2 + exit 1 + ;; + esac + closing["$issue"]=1 +done + +mapfile -t refs_targets < <( + awk ' + { + rest = tolower($0) + while (match(rest, /(^|[^[:alnum:]_])refs?[[:space:]]+#[0-9]+/)) { + token = substr(rest, RSTART, RLENGTH) + sub(/^.*#/, "", token) + print token + 0 + rest = substr(rest, RSTART + RLENGTH) + } + } + ' "$body_file" | sort -nu +) + +intersections=() +for issue in "${refs_targets[@]}"; do + if [ -n "${closing[$issue]:-}" ]; then + intersections+=("$issue") + fi +done + +if [ "${#intersections[@]}" -eq 0 ]; then + echo "refs-not-closing: no Refs target appears in GitHub's closing-issue graph" + exit 0 +fi + +sentence_for_issue() { + local issue="$1" mode="$2" + awk -v issue="$issue" -v mode="$mode" ' + { + text = text separator $0 + separator = "\n" + } + END { + count = split(text, sentence, /[.!?][[:space:]]+|\n+/) + if (mode == "closing") { + needle = "(^|[^[:alnum:]_])(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)" + } else { + needle = "(^|[^[:alnum:]_])refs?[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)" + } + for (i = 1; i <= count; i++) { + lower = tolower(sentence[i]) + if (match(lower, needle)) { + matched = substr(sentence[i], RSTART, RLENGTH) + sub(/^[^[:alnum:]_]*/, "", matched) + sub(/[^0-9]*$/, "", matched) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", sentence[i]) + printf "%s\t%s\n", matched, sentence[i] + exit + } + } + } + ' "$body_file" +} + +{ + printf 'refs-not-closing: Refs target(s) also scheduled to close:' + printf ' #%s' "${intersections[@]}" + printf '\n' + + for issue in "${intersections[@]}"; do + detail="$(sentence_for_issue "$issue" closing)" + if [ -z "$detail" ]; then + detail="$(sentence_for_issue "$issue" refs)" + printf " #%s: GitHub reports a closing reference; no adjacent closing keyword was found, so inspect the Development sidebar link.\n" "$issue" + fi + if [ -n "$detail" ]; then + matched="${detail%%$'\t'*}" + sentence="${detail#*$'\t'}" + printf ' matched: %s\n' "$matched" + printf ' sentence: %s\n' "$sentence" + fi + done + + cat <<'EOF' + A `Refs #N` PR must not close N. Remove the sidebar closing link or rewrite + an adjacent closing-keyword sentence so the number comes first (`#N is + closed by hand`) or the number is omitted (`triage closes the issue by + hand`). Backticks do not protect a closing keyword from GitHub's parser. +EOF +} >&2 +exit 1 diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh new file mode 100755 index 0000000..71696d7 --- /dev/null +++ b/test/refs-not-closing.test.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Contract tests for actions/refs-not-closing (issue #218). Bodies and +# closing-reference sets are fixtures: no network and no pull request are +# involved. set -u, not -e: failures are behavior for the harness to inspect. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +. "$ROOT/test/harness.sh" + +SCRIPT="$ROOT/actions/refs-not-closing/refs-not-closing.sh" +ACTION="$ROOT/actions/refs-not-closing/action.yml" +WORKFLOW="$ROOT/.github/workflows/refs-guard.yml" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +body() { + local name="$1" + shift + printf '%s\n' "$@" >"$TMP/$name.md" +} + +guard() { + local name="$1" + shift + bash "$SCRIPT" "$TMP/$name.md" "$@" +} + +body ref-5 'Refs #5' +check "Refs target with empty closing set passes" 0 "no Refs target" guard ref-5 +check "Refs target with itself closing fails" 1 "#5" guard ref-5 5 +check "Refs target with another issue closing passes" 0 "no Refs target" guard ref-5 9 + +body ordinary 'Closes #5' +check "ordinary Closes PR remains green" 0 "no Refs target" guard ordinary 5 + +body mixed 'Refs #5' '' 'This PR legitimately Closes #9.' +check "Refs #5 plus Closes #9 remains green" 0 "no Refs target" guard mixed 9 + +body prose 'Refs #5' '' 'Triage closes #5 by hand after the live proof.' +check "closing prose for a Refs target fails" 1 "closes #5" guard prose 5 +check "failure prints the surrounding sentence" 1 \ + "sentence: Triage closes #5 by hand after the live proof" guard prose 5 +check "failure offers number-first rewrite" 1 "#N is" guard prose 5 +check "failure offers number-free rewrite" 1 "closes the issue" guard prose 5 + +body code-span 'Refs #5' '' 'The body must not contain `Closes #5` anywhere.' +check "backticked closing keyword still fails" 1 "Closes #5" guard code-span 5 +check "backtick failure explains that code spans do not protect" 1 \ + "Backticks do not protect" guard code-span 5 + +body adjacency 'Refs #5' '' 'Triage closes #9 and #5 after the proof.' +check "non-adjacent #5 does not join closing set #9" 0 "no Refs target" \ + guard adjacency 9 + +body empty '' +check "empty body remains green" 0 "no Refs target" guard empty 5 + +body incidents-211 'Refs #209' 'Triage closes #209 by hand.' +check "#211 incident replays red" 1 "#209" guard incidents-211 209 +body incidents-214 'Refs #212' 'Triage closes #212 and #209 on that evidence.' +check "#214 incident replays red" 1 "#212" guard incidents-214 212 +body incidents-200 'Refs #199' 'A later edit added `Closes #199`.' +check "#200 incident replays red" 1 "#199" guard incidents-200 199 + +for number in 207 191 190 176 165 164; do + body "incident-$number" "Refs #$number" + check "#$number incident replays green" 0 "no Refs target" \ + guard "incident-$number" +done + +check "missing body is a loud failure" 1 "missing or unreadable" \ + bash "$SCRIPT" "$TMP/missing.md" +check "invalid closing set is a loud failure" 1 "invalid closing issue" \ + guard ref-5 nope + +# The action owns the network boundary. These structural assertions keep a +# future edit from suppressing a failed/partial GraphQL read or splitting the +# one authoritative query into several drifting reads. +check "action performs exactly one GraphQL read" 0 "1" \ + bash -c 'test "$(grep -c "gh api graphql" "$1")" -eq 1; printf 1' _ "$ACTION" +check "action refuses a partial closing-reference page" 0 "hasNextPage" \ + grep -F "hasNextPage" "$ACTION" +check "action does not suppress GraphQL failure" 1 "" \ + grep -E 'gh api graphql.*(\|\| true|\| true)' "$ACTION" + +check "workflow wakes on body edits" 0 "types: [opened, edited, reopened, synchronize]" \ + grep -F "types: [opened, edited, reopened, synchronize]" "$WORKFLOW" +check "workflow is pull_request-only" 1 "" grep -E '^ (push|pull_request_target|workflow_dispatch):' "$WORKFLOW" +check "workflow grants read-only pull request access" 0 "pull-requests: read" \ + grep -F "pull-requests: read" "$WORKFLOW" + +summary From 66b136efc039380d8895948f187ebeaba9937f35 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:31:34 +0000 Subject: [PATCH 044/162] docs: wire refs guard into ceremony flow --- .github/labeler.yml | 3 +++ BUILDER.md | 7 ++++++ REVIEWER.md | 7 +++++- actions/refs-not-closing/action.yml | 6 ++++-- changelog.d/218.md | 4 ++++ docs/CONSUMERS.md | 33 +++++++++++++++++++++++++++-- test/refs-not-closing.test.sh | 4 ++++ 7 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 changelog.d/218.md diff --git a/.github/labeler.yml b/.github/labeler.yml index 1859654..aa1a7aa 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -32,9 +32,12 @@ scope:guards: - actions/changelog-armed/** - actions/changelog-monotonic/** - actions/drill-recorded/** + - actions/refs-not-closing/** + - .github/workflows/refs-guard.yml - test/changelog-armed.test.sh - test/changelog-monotonic.test.sh - test/drill-recorded.test.sh + - test/refs-not-closing.test.sh scope:labels: - changed-files: - any-glob-to-any-file: diff --git a/BUILDER.md b/BUILDER.md index 9066716..d8376ea 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -182,6 +182,13 @@ triage bug, and the move is to say so on the issue, not to guess. absent that instruction `Closes #N` remains the default. The exception was bought the hard way: #143 carried `Closes #137` as doctrine then required, and the merge closed #137 with its post-merge criterion unmet (#151). + On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, + `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`) + immediately before `#N` anywhere in the body — including the sentence + explaining why the PR does not close it. GitHub reads the whole body by + adjacency, not intent. Put the number first (`#N is closed by hand`) or + omit it (`triage closes the issue by hand`). A code span does not protect + the phrase: a backticked `Closes #199` still closed #199 (#200, #218). 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 diff --git a/REVIEWER.md b/REVIEWER.md index f41565b..7d8e74e 100644 --- a/REVIEWER.md +++ b/REVIEWER.md @@ -34,7 +34,12 @@ In order of authority: not a defect: the issue directs it, triage owns that close, and a request-changes on the "missing" keyword enforces the bug the shape exists to fix — `Closes #137` closed its issue with a post-merge - criterion unmet (#151). Check every + criterion unmet (#151). For a `Refs #N` body, also verify that no closing + keyword immediately precedes `#N` anywhere in the body, even in prose + explaining the hand close or inside a code span: GitHub used those exact + shapes to close #209, #212 and #199 (#200, #218). The safe forms put the + number first (`#N is closed by hand`) or omit it (`triage closes the issue + by hand`). 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 diff --git a/actions/refs-not-closing/action.yml b/actions/refs-not-closing/action.yml index 22c1f3f..7dc795a 100644 --- a/actions/refs-not-closing/action.yml +++ b/actions/refs-not-closing/action.yml @@ -44,9 +44,11 @@ runs: .data.repository.pullRequest | if . == null then error("pull request was not returned") else .body // "" end ' <<<"$facts" >"$body_file" - jq -er ' + jq -r ' .data.repository.pullRequest.closingIssuesReferences - | if .pageInfo.hasNextPage then + | if . == null then + error("closing issue references were not returned") + elif .pageInfo.hasNextPage then error("more than 100 closing issue references; refusing a partial verdict") else .nodes[].number diff --git a/changelog.d/218.md b/changelog.d/218.md new file mode 100644 index 0000000..cdbdc46 --- /dev/null +++ b/changelog.d/218.md @@ -0,0 +1,4 @@ +### Added + +- Pull requests that promise `Refs #N` now fail a read-only, body-edit-aware + guard if GitHub would close N through a keyword or sidebar link (#218). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 437d64b..fc12b4c 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -124,14 +124,41 @@ the machinery at all: consumer. In particular, `0.1.0` carries `changelog-armed`, `changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not `changelog-assembled` or `runner-isolated`. -6. **Labels automation** (optional but recommended): the two callers from +6. **`.github/workflows/refs-guard.yml`** — the body-aware guard is its own + caller because `edited` is load-bearing: #200 gained its accidental + closing keyword after the PR opened, with no push to wake ordinary CI. + It costs the consumer one read-only workflow file and no other machinery: + + ```yaml + name: Refs guard + + on: + pull_request: + types: [opened, edited, reopened, synchronize] + + permissions: + contents: read + pull-requests: read + + jobs: + refs-not-closing: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: heavy-duty/ceremony/actions/refs-not-closing@ + ``` + + `refs-not-closing` is **unreleased** until the first tag carrying #218. + Adopt this caller with that ordinary pin bump; never point only this file + at a moving or newer ref. +7. **Labels automation** (optional but recommended): the two callers from [Labels automation](#labels-automation) — the event-facing labels caller and the sweep caller (#209) — plus `.github/labels.conf` (panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the path→scope globs). Run the sweep caller's `workflow_dispatch` once — **this bootstraps the taxonomy, `release` label included** — and use it again whenever an operator needs a full-board sweep immediately. -7. **The artifact hook** (optional): `.github/actions/release-artifact/` +8. **The artifact hook** (optional): `.github/actions/release-artifact/` per [The artifact hook](#the-artifact-hook). No hook → the source tarball is the package. @@ -156,6 +183,8 @@ precisely so the machinery is safe to work on sibling `push:` silently kills a door (rig's review catch). - [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in the bootstrap list above (with `fetch-depth: 0` on the checkout). +- [ ] Add `refs-guard.yml` from the bootstrap list with the same ceremony + pin as the release caller and CI guard steps. - [ ] Replace `labels.yml` with the caller from [Labels automation](#labels-automation) and add the sweep caller `labels-sweep.yml` beside it (#209); extract diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh index 71696d7..04a12c6 100755 --- a/test/refs-not-closing.test.sh +++ b/test/refs-not-closing.test.sh @@ -64,6 +64,10 @@ check "#214 incident replays red" 1 "#212" guard incidents-214 212 body incidents-200 'Refs #199' 'A later edit added `Closes #199`.' check "#200 incident replays red" 1 "#199" guard incidents-200 199 +body multiple 'Refs #5 and Refs #7.' 'Triage closes #5 and fixes #7 by hand.' +check "failure names every intersecting issue" 1 \ + "scheduled to close: #5 #7" guard multiple 5 7 + for number in 207 191 190 176 165 164; do body "incident-$number" "Refs #$number" check "#$number incident replays green" 0 "no Refs target" \ From 869d05bf8531dc8ede00fb26bea71b4dbd222fb1 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:34:35 +0000 Subject: [PATCH 045/162] fix: satisfy CI shellcheck gate --- actions/refs-not-closing/refs-not-closing.sh | 4 ++-- test/refs-not-closing.test.sh | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/actions/refs-not-closing/refs-not-closing.sh b/actions/refs-not-closing/refs-not-closing.sh index 946a6c8..8e54cac 100755 --- a/actions/refs-not-closing/refs-not-closing.sh +++ b/actions/refs-not-closing/refs-not-closing.sh @@ -16,10 +16,10 @@ set -euo pipefail body_file="${1:-}" shift || true -[ -n "$body_file" ] && [ -f "$body_file" ] || { +if [ -z "$body_file" ] || [ ! -f "$body_file" ]; then echo "refs-not-closing: body file is missing or unreadable: ${body_file:-}" >&2 exit 1 -} +fi declare -A closing=() for issue in "$@"; do diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh index 04a12c6..184e384 100755 --- a/test/refs-not-closing.test.sh +++ b/test/refs-not-closing.test.sh @@ -45,7 +45,7 @@ check "failure prints the surrounding sentence" 1 \ check "failure offers number-first rewrite" 1 "#N is" guard prose 5 check "failure offers number-free rewrite" 1 "closes the issue" guard prose 5 -body code-span 'Refs #5' '' 'The body must not contain `Closes #5` anywhere.' +body code-span 'Refs #5' '' "The body must not contain \`Closes #5\` anywhere." check "backticked closing keyword still fails" 1 "Closes #5" guard code-span 5 check "backtick failure explains that code spans do not protect" 1 \ "Backticks do not protect" guard code-span 5 @@ -61,7 +61,7 @@ body incidents-211 'Refs #209' 'Triage closes #209 by hand.' check "#211 incident replays red" 1 "#209" guard incidents-211 209 body incidents-214 'Refs #212' 'Triage closes #212 and #209 on that evidence.' check "#214 incident replays red" 1 "#212" guard incidents-214 212 -body incidents-200 'Refs #199' 'A later edit added `Closes #199`.' +body incidents-200 'Refs #199' "A later edit added \`Closes #199\`." check "#200 incident replays red" 1 "#199" guard incidents-200 199 body multiple 'Refs #5 and Refs #7.' 'Triage closes #5 and fixes #7 by hand.' @@ -82,8 +82,13 @@ check "invalid closing set is a loud failure" 1 "invalid closing issue" \ # The action owns the network boundary. These structural assertions keep a # future edit from suppressing a failed/partial GraphQL read or splitting the # one authoritative query into several drifting reads. +one_graphql_read() { + [ "$(grep -c "gh api graphql" "$ACTION")" -eq 1 ] + printf '1\n' +} + check "action performs exactly one GraphQL read" 0 "1" \ - bash -c 'test "$(grep -c "gh api graphql" "$1")" -eq 1; printf 1' _ "$ACTION" + one_graphql_read check "action refuses a partial closing-reference page" 0 "hasNextPage" \ grep -F "hasNextPage" "$ACTION" check "action does not suppress GraphQL failure" 1 "" \ From 04bdde7b1e4cdb85abe893cbd2ba89a68e00e9c2 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:35:10 +0000 Subject: [PATCH 046/162] fix(issueflow): the parse echo is idempotent against the last echo, not the history ensure_comment's any-occurrence grep answers "have I ever said this", which is right for a flag like blocked-unparseable and wrong for a value that changes. A -> B -> A found A's own first echo and stayed silent, leaving the thread's newest echo asserting B while the sweep gated on A: a stale parse presented as the current one, and the third edit did change the parsed set, so the criterion says it speaks. blocked_parse_echo_needed compares this parse's marker against the LAST blockers-parsed-* marker on the thread. The read stays inside guarded_read / skip_issue, so an unreadable history still fails closed (#247 D1) rather than answering "nothing echoed yet" and re-posting. ensure_comment is untouched for every other caller. Refs #252 --- .../issueflow-reconcile.sh | 49 ++++++++++++++----- test/issueflow-reconcile.test.sh | 29 +++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index accda04..329a3b6 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -331,26 +331,48 @@ blocked_parse_set() { # $1 local refs, $2 cross refs -> "{#7, #12}" | "{}" } blocked_parse_marker() { # $1 rendered set -> the echo's idempotency marker - # Scoped to the SET's value, not to the issue and not to the sweep: an - # unchanged parse finds its own marker and stays quiet on a 15-minute cron, - # and a changed one cannot find it, so the change is what speaks. One - # consequence is deliberate: a declaration edited back to a set already - # echoed stays quiet too, because the thread already carries that echo. + # Scoped to the SET's value, not to the issue and not to the sweep: the + # marker names WHAT was echoed, and blocked_parse_echo_needed decides whether + # it is still what the thread is saying. # # The identity is the DIGEST, not the slug beside it. Slugging is many-to-one # — `{acme/widgets#9}` and `{acme-widgets#9}` are both parses this reconciler # accepts, and both slug to `acme-widgets-9` — so a slug-keyed marker lets a # changed set find the old marker and say nothing, silence in precisely the # case the echo exists to speak about. Distinguishing `/` would close that - # pair and leave the class: `-`, `_` and `.` are all legal in a qualifier and - # all collapse the same way. The slug stays in front so a human reading the - # raw comment can still see which set it belongs to; it decides nothing. + # pair and leave the class: `-`, `_` and `.` are legal in a qualifier token + # and all collapse the same way. The slug stays in front so a human reading + # the raw comment can still see which set it belongs to; it decides nothing. local slug digest slug="$(printf '%s' "$1" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" digest="$(printf '%s' "$1" | sha256sum | cut -c1-12)" printf 'blockers-parsed-%s-%s\n' "${slug:-none}" "$digest" } +blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 quiet + # Idempotency for the parse echo is against the LAST parse echo on the + # thread, not against any historical one. ensure_comment's any-occurrence + # grep is right for a flag like `blocked-unparseable`, whose question is + # "have I ever said this"; it is wrong for a value that changes, whose + # question is "is this still what I am saying". The difference is A -> B -> A: + # under an any-occurrence search the return to A finds A's own first echo and + # stays silent, leaving the thread's most recent echo asserting B while the + # sweep gates on A. A stale parse presented as the current one is the exact + # failure #252 exists to kill, and the third edit changed the parsed set, so + # the criterion says it speaks. + # + # Comparing markers rather than re-rendering the last set keeps the digest as + # the only identity: two sets are the same here iff blocked_parse_marker says + # so, the same rule the marker itself is built on. + local bodies last + guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ + || skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")" + # The read fails closed above (#247 D1): an unreadable history skips the + # issue rather than answering "nothing echoed yet" and re-posting. + last="$(grep -o '' <<<"$bodies" | tail -n 1)" + [ "$last" != "" ] +} + blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs local refs="$1" states="$2" cross_refs="${3:-}" if [ -n "$cross_refs" ]; then echo FLAG_CROSS_REPO @@ -487,7 +509,7 @@ last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read reconcile_issue() { local n="$1" decision refs cross_refs states age created assignees open_pr=false label owners - local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" + local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" local unchecked="" remove_claimed=claimed decision="$(queue_decision <<<"$ISSUE_LABELS")" case "$decision" in @@ -597,8 +619,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in # detect because the machine cannot judge what a human meant — only state # what it read, and let the human see the divergence in one sweep. parsed_set="$(blocked_parse_set "$refs" "$cross_refs")" - ensure_comment "$n" "$(blocked_parse_marker "$parsed_set")" \ - "This issue's \`Blocked by\` declarations parse to: $parsed_set + parse_marker="$(blocked_parse_marker "$parsed_set")" + if blocked_parse_echo_needed "$n" "$parse_marker"; then + run gh issue comment "$n" -R "$REPO" --body " +This issue's \`Blocked by\` declarations parse to: $parsed_set That is the exact set this sweep gates on — what the machine read, never a judgment about whether it is what you meant. The parse unions every clause it @@ -610,7 +634,8 @@ omits something you did, edit the declaration — the next sweep echoes the correction. *Comment only: nothing on this path writes a label. The marker carries the set -itself, so an unchanged parse never re-posts.*" +itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null + fi log "#$n: blocked declarations parse to $parsed_set" states="$(reference_states <<<"$refs")" decision="$(blocked_decision "$refs" "$states" "$cross_refs")" diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 14a38b3..748bf46 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -781,6 +781,35 @@ check "...and not re-flagging cross-repo, which did not change" 0 "1" \ check "no label write comes from the colliding-edit path either" 0 "" \ bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ "$collision_edits_before" "$TMP/issue-edits" +# A -> B -> A. The marker names the SET, so the return to A is a marker this +# thread has carried before; the question the echo has to answer is not "have I +# ever said this" but "is this still what I am saying". Searching the whole +# history answers the first, and the return went silent while the thread's +# newest echo asserted B and the sweep gated on A — a stale parse presented as +# the current one, which is the readable-but-wrong shape #252 exists to kill. +# All four sweeps are driven, because the bug is only visible as a sequence. +printf '[]\n' >"$(cfix 52)" +issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null +issue_probe 52 blocked 1 false "" 'Blocked by #90, #91, #92.' >/dev/null +return_edits_before="$(wc -l <"$TMP/issue-edits")" +issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null +check "a set edited back to a previously echoed one speaks again" 0 "3" \ + grep -cF '" "$TMP/posted-52" +# The assertion the silence used to fail: it is the NEWEST echo that has to +# name what the sweep gates on, not merely some echo somewhere in the thread. +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "...leaving the newest echo naming the set the sweep now gates on" 0 \ + "parse to: {#90, #91}" \ + bash -c 'grep -o "parse to: {[^}]*}" "$1" | tail -n 1' _ "$TMP/posted-52" +issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null +check "an unchanged sweep after the return still draws nothing" 0 "3" \ + grep -cF '" "$TMP/posted-35" +# AC-1's other input, and it is not the sweep above. A re-sweep of a +# BYTE-IDENTICAL body is quiet under both spellings of the decision — the one +# that keys on the parse and the one that keys on the body — so it cannot tell +# them apart. Only an edit that changes the prose and preserves the parse can: +# the refs are reordered and sentences are added on either side, and the set is +# still {#90, #91}. What this pins is that the marker is a function of the +# PARSE and not of the prose around it, which is the property the echo's whole +# idempotency rests on and which nothing else in the suite states. +preserved_edits_before="$(wc -l <"$TMP/issue-edits")" +issue_probe 35 blocked 1 false "" \ + "Some new prose here. Blocked by #91, #90. And more text." >/dev/null +check "a body edit that preserves the parse draws nothing" 0 "1" \ + grep -cF "" "$TMP/posted-35" +check "...and adds no echo under any other marker either" 0 "1" \ + grep -cF '" "$TMP/posted-35" From ba55d1d5529f0f6184038028deaff04d4648d027 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:39:02 +0000 Subject: [PATCH 053/162] feat: declare the release door path --- .github/scripts/release-path.sh | 15 ++++ test/release-path.test.sh | 120 ++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100755 .github/scripts/release-path.sh create mode 100755 test/release-path.test.sh diff --git a/.github/scripts/release-path.sh b/.github/scripts/release-path.sh new file mode 100755 index 0000000..0068f62 --- /dev/null +++ b/.github/scripts/release-path.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# The release doors' executable path (discussion #217; issue #237). The +# 0.5.0 record had to explain why lib/ruling.sh changed without changing a +# door. Keep this list executable so a doors-unchanged record measures the +# workflow and only the scripts it actually runs, while the test catches a +# new or removed dependency before a record can silently omit it. +set -euo pipefail + +printf '%s\n' \ + .github/workflows/release.yml \ + bin/ \ + lib/version.sh \ + lib/decide.sh \ + lib/facts.sh \ + lib/changelog.sh diff --git a/test/release-path.test.sh b/test/release-path.test.sh new file mode 100755 index 0000000..9fa5db2 --- /dev/null +++ b/test/release-path.test.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Contract tests for the release-door path manifest (issue #237). The list +# is evidence for skipping a live drill, so drift in either direction must +# fail before a release record can make an incomplete claim. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +. "$ROOT/test/harness.sh" + +PATH_SCRIPT="$ROOT/.github/scripts/release-path.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# derive_path — print the workflow, bin/ when a bin command sources a +# door library, and the workflow's direct + transitive lib dependencies. +derive_path() { + local tree="$1" workflow="$tree/.github/workflows/release.yml" + local pending seen=" " lib file refs ref bin_uses_lib=no + + printf '%s\n' .github/workflows/release.yml + pending="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$workflow" | sort -u)" + + while [ -n "$pending" ]; do + lib="$(printf '%s\n' "$pending" | sed -n '1p')" + pending="$(printf '%s\n' "$pending" | sed '1d')" + case "$seen" in + *" $lib "*) continue ;; + esac + seen="$seen$lib " + printf '%s\n' "$lib" + file="$tree/$lib" + [ -f "$file" ] || continue + refs="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$file" | sort -u)" + if [ -n "$refs" ]; then + pending="$(printf '%s\n%s\n' "$pending" "$refs" | sed '/^$/d' | sort -u)" + fi + done + + if [ -d "$tree/bin" ]; then + for file in "$tree"/bin/*; do + [ -f "$file" ] || continue + refs="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$file")" + for ref in $refs; do + case "$seen" in + *" $ref "*) bin_uses_lib=yes ;; + esac + done + done + fi + [ "$bin_uses_lib" = no ] || printf '%s\n' bin/ +} + +declared_path() { + bash "$1/.github/scripts/release-path.sh" +} + +path_check() { + local tree="$1" declared derived missing extra + declared="$(declared_path "$tree" | sort -u)" + derived="$(derive_path "$tree" | sort -u)" + missing="$(comm -13 <(printf '%s\n' "$declared") <(printf '%s\n' "$derived"))" + extra="$(comm -23 <(printf '%s\n' "$declared") <(printf '%s\n' "$derived"))" + if [ -n "$missing" ]; then + printf 'release-path: missing dependency: %s\n' "$missing" >&2 + fi + if [ -n "$extra" ]; then + printf 'release-path: stale path: %s\n' "$extra" >&2 + fi + [ -z "$missing" ] && [ -z "$extra" ] +} + +fixture() { + local name="$1" tree + tree="$TMP/$name" + mkdir -p "$tree/.github/scripts" "$tree/.github/workflows" "$tree/lib" "$tree/bin" + cp "$PATH_SCRIPT" "$tree/.github/scripts/release-path.sh" + printf '#!/usr/bin/env bash\n. "$ROOT/lib/changelog.sh"\n' >"$tree/bin/assemble" + printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh" + printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh" + printf '#!/usr/bin/env bash\n. "$ROOT/lib/version.sh"\n' >"$tree/lib/facts.sh" + printf '#!/usr/bin/env bash\n' >"$tree/lib/version.sh" + printf '%s\n' "$tree" +} + +# Exact output is the record author's copy-paste source. +check "manifest prints the specified ordered release path" 0 \ + $'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/facts.sh\nlib/changelog.sh' \ + bash "$PATH_SCRIPT" +check "real workflow and transitive dependencies match the manifest" 0 "" \ + path_check "$ROOT" + +# A door growing a dependency must name the missing path (#237 D7). +tree="$(fixture missing)" +printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\nrun: . "$CEREMONY_DIR/lib/version.sh"\nrun: . "$CEREMONY_DIR/lib/ruling.sh"\n' \ + >"$tree/.github/workflows/release.yml" +printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" +check "a new workflow library fails with its missing path" 1 \ + "missing dependency: lib/ruling.sh" path_check "$tree" + +# A manifest may not rot into a safe-looking superset. +tree="$(fixture extra)" +printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\n' \ + >"$tree/.github/workflows/release.yml" +sed -i 's| lib/changelog.sh$| lib/changelog.sh \\|' \ + "$tree/.github/scripts/release-path.sh" +printf ' lib/ruling.sh\n' >>"$tree/.github/scripts/release-path.sh" +printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" +check "a path no door reads fails as stale" 1 "stale path: lib/ruling.sh" \ + path_check "$tree" + +# Transitive sourcing is part of the derivation, not decoration. +tree="$(fixture transitive)" +printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\n' \ + >"$tree/.github/workflows/release.yml" +: >"$tree/lib/facts.sh" +check "removing facts' version source fails as a stale path" 1 \ + "stale path: lib/version.sh" path_check "$tree" + +summary From 75d85df20c7c51a6cb2aaf9b12f2f1db1cf4e131 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:41:34 +0000 Subject: [PATCH 054/162] docs: define doors-unchanged drill records --- changelog.d/237.md | 5 +++++ drills/README.md | 40 +++++++++++++++++++++++++++++++++++++++ test/release-path.test.sh | 22 +++++++++++++++------ 3 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 changelog.d/237.md diff --git a/changelog.d/237.md b/changelog.d/237.md new file mode 100644 index 0000000..bfe9cd3 --- /dev/null +++ b/changelog.d/237.md @@ -0,0 +1,5 @@ +### Changed + +- Define the doors-unchanged drill record and an executable release-path list, + so a release may reuse live evidence only when its door bytes are unchanged + since the last rehearsed tag. (#237) diff --git a/drills/README.md b/drills/README.md index f59e961..e25f97f 100644 --- a/drills/README.md +++ b/drills/README.md @@ -66,6 +66,46 @@ record is the only thing that survives the drill, and 0.2.0's record shipped its first draft asserting a cleanup that had not happened (#135) — false evidence in the one file whose job is to be evidence. +A record has one of three shapes. A **rehearsal** records the disposable-repo +run above. **Doors unchanged** records the mechanically checked claim below +when a new rehearsal would execute the same bytes as the last one. **WAIVED** +records a maintainer's judgement under the standing paragraph below. If the +doors-unchanged conditions do not all hold, the release owes a rehearsal or a +waiver; the narrower shape is never a substitute for either. + +## Doors unchanged + +The builder may assert that no disposable-repo rehearsal is owed only when +all three conditions below hold at the candidate head. The release PR's panel +verifies the claim like any other evidence, and if any reviewer rules a full +drill owed, that verdict wins. + +1. `git diff ..HEAD -- ` contains no change + except the `CEREMONY_SELF_REF` pin line in + `.github/workflows/release.yml`. +2. The release path is exactly the output of + `.github/scripts/release-path.sh`: `.github/workflows/release.yml`, `bin/`, + `lib/version.sh`, `lib/decide.sh`, `lib/facts.sh`, and + `lib/changelog.sh`. The script is the record author's copy-paste source; + its contract test keeps this inline list and the workflow's direct and + transitive dependencies in agreement. +3. The last rehearsed tag's own record is a full rehearsal, its release is + published, and `main` was re-armed to `-dev` after it. + +The baseline is the last **rehearsed** tag, never merely the previous tag. A +previous-tag baseline could chain one doors-unchanged assertion from another +while the doors drift a small diff at a time; the last-rehearsed anchor makes +any accumulated release-path change force a new rehearsal. + +The record carries all three measurements as observed at its candidate head, +never copied from an earlier record. `drills/0.4.1.md` and +`drills/0.5.0.md` are the worked examples; the latter's amendment from a +predicted empty `lib/` diff to the observed `lib/ruling.sh` delta is why each +candidate is measured afresh (#233). Re-running its stricter baseline now is +also the path-enumeration proof: `git diff 0.4.0 0.5.0 -- ` is +only the `CEREMONY_SELF_REF` pin, while adding `lib/ruling.sh` makes the diff +non-empty even though neither release door reads that file (#217, #237). + `actions/drill-recorded` refuses any bare-version tree whose record is missing or blank. A waived drill is still a record: the file says WAIVED and why — a maintainer's call, visible and reviewable in the release PR's diff, diff --git a/test/release-path.test.sh b/test/release-path.test.sh index 9fa5db2..99bee54 100755 --- a/test/release-path.test.sh +++ b/test/release-path.test.sh @@ -15,8 +15,9 @@ trap 'rm -rf "$TMP"' EXIT # derive_path — print the workflow, bin/ when a bin command sources a # door library, and the workflow's direct + transitive lib dependencies. derive_path() { - local tree="$1" workflow="$tree/.github/workflows/release.yml" + local tree="$1" workflow local pending seen=" " lib file refs ref bin_uses_lib=no + workflow="$tree/.github/workflows/release.yml" printf '%s\n' .github/workflows/release.yml pending="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$workflow" | sort -u)" @@ -75,10 +76,12 @@ fixture() { tree="$TMP/$name" mkdir -p "$tree/.github/scripts" "$tree/.github/workflows" "$tree/lib" "$tree/bin" cp "$PATH_SCRIPT" "$tree/.github/scripts/release-path.sh" - printf '#!/usr/bin/env bash\n. "$ROOT/lib/changelog.sh"\n' >"$tree/bin/assemble" + printf '#!/usr/bin/env bash\n. "%s"\n' \ + "\$ROOT/lib/changelog.sh" >"$tree/bin/assemble" printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh" - printf '#!/usr/bin/env bash\n. "$ROOT/lib/version.sh"\n' >"$tree/lib/facts.sh" + printf '#!/usr/bin/env bash\n. "%s"\n' \ + "\$ROOT/lib/version.sh" >"$tree/lib/facts.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/version.sh" printf '%s\n' "$tree" } @@ -92,7 +95,10 @@ check "real workflow and transitive dependencies match the manifest" 0 "" \ # A door growing a dependency must name the missing path (#237 D7). tree="$(fixture missing)" -printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\nrun: . "$CEREMONY_DIR/lib/version.sh"\nrun: . "$CEREMONY_DIR/lib/ruling.sh"\n' \ +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \ + "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ + "\$CEREMONY_DIR/lib/changelog.sh" "\$CEREMONY_DIR/lib/version.sh" \ + "\$CEREMONY_DIR/lib/ruling.sh" \ >"$tree/.github/workflows/release.yml" printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" check "a new workflow library fails with its missing path" 1 \ @@ -100,7 +106,9 @@ check "a new workflow library fails with its missing path" 1 \ # A manifest may not rot into a safe-looking superset. tree="$(fixture extra)" -printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\n' \ +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ + "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ + "\$CEREMONY_DIR/lib/changelog.sh" \ >"$tree/.github/workflows/release.yml" sed -i 's| lib/changelog.sh$| lib/changelog.sh \\|' \ "$tree/.github/scripts/release-path.sh" @@ -111,7 +119,9 @@ check "a path no door reads fails as stale" 1 "stale path: lib/ruling.sh" \ # Transitive sourcing is part of the derivation, not decoration. tree="$(fixture transitive)" -printf 'run: bash "$CEREMONY_DIR/lib/facts.sh"\nrun: bash "$CEREMONY_DIR/lib/decide.sh"\nrun: . "$CEREMONY_DIR/lib/changelog.sh"\n' \ +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ + "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ + "\$CEREMONY_DIR/lib/changelog.sh" \ >"$tree/.github/workflows/release.yml" : >"$tree/lib/facts.sh" check "removing facts' version source fails as a stale path" 1 \ From e5a87201f8fa5d4c9304716972cc7952417b1b1d Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:43:23 +0000 Subject: [PATCH 055/162] test: derive release path from executable lines --- test/release-path.test.sh | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/test/release-path.test.sh b/test/release-path.test.sh index 99bee54..6d030c4 100755 --- a/test/release-path.test.sh +++ b/test/release-path.test.sh @@ -12,6 +12,19 @@ PATH_SCRIPT="$ROOT/.github/scripts/release-path.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT +library_refs() { + awk ' + /^[[:space:]]*#/ { next } + { + line = $0 + while (match(line, /lib\/[[:alnum:]_.-]+\.sh/)) { + print substr(line, RSTART, RLENGTH) + line = substr(line, RSTART + RLENGTH) + } + } + ' "$1" +} + # derive_path — print the workflow, bin/ when a bin command sources a # door library, and the workflow's direct + transitive lib dependencies. derive_path() { @@ -20,7 +33,7 @@ derive_path() { workflow="$tree/.github/workflows/release.yml" printf '%s\n' .github/workflows/release.yml - pending="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$workflow" | sort -u)" + pending="$(library_refs "$workflow" | sort -u)" while [ -n "$pending" ]; do lib="$(printf '%s\n' "$pending" | sed -n '1p')" @@ -32,7 +45,7 @@ derive_path() { printf '%s\n' "$lib" file="$tree/$lib" [ -f "$file" ] || continue - refs="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$file" | sort -u)" + refs="$(library_refs "$file" | sort -u)" if [ -n "$refs" ]; then pending="$(printf '%s\n%s\n' "$pending" "$refs" | sed '/^$/d' | sort -u)" fi @@ -41,7 +54,7 @@ derive_path() { if [ -d "$tree/bin" ]; then for file in "$tree"/bin/*; do [ -f "$file" ] || continue - refs="$(sed -n 's|.*\(lib/[[:alnum:]_.-]*\.sh\).*|\1|p' "$file")" + refs="$(library_refs "$file")" for ref in $refs; do case "$seen" in *" $ref "*) bin_uses_lib=yes ;; From 6634a517bc744b1b9fccbaaba1b169ba9ab1073c Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:49:11 +0000 Subject: [PATCH 056/162] docs(builder): a fix round may ride a draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUILDER.md's review round assumed ready-throughout, so a builder or reviewer meeting a mid-round draft found behaviour the doctrine never described. Three points, doctrine not mechanism: the draft phase stays the builder's through a fix round, ready-for-review is the builder's own act and no engine's, and where the draft suppressed CI green is proven at the flip with the request following it — step 1's rule at a stated moment, not a second rule. REVIEWER.md gains the reading that keeps a reviewer from misfiling it: a draft carrying state:addressing is a fix round in progress. Refs #258 --- BUILDER.md | 34 ++++++++++++++++++++++++++++++++++ REVIEWER.md | 5 +++++ changelog.d/258.md | 7 +++++++ 3 files changed, 46 insertions(+) create mode 100644 changelog.d/258.md diff --git a/BUILDER.md b/BUILDER.md index d8376ea..d1b4df3 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -297,6 +297,40 @@ CONTRIBUTING; the shared flow lives here and is not restated there.) is one kind of human-owned decision; use the ruling ask below ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). +**A fix round may ride a draft.** An engine may convert a PR back to draft +when a round closes, and crew#139's does: the 15-minute cadence makes a +builder's fix-round pushes *saves* rather than proposals, and 41 of 106 +commits across crew's last 25 PRs — 39% — were fired at CI as if they were +proposals. Ceremony implements no such conversion and this passage specifies +none; it is written down because a builder or a reviewer who meets a mid-round +draft has to find a state the doctrine describes. What it means is what a +draft already meant during the build, extended and not changed: the draft +phase is yours, the panel cannot see it, and the checkpoint discipline +(Building, above) runs through the round unaltered. Whose ball it is does not +change either — the round outranks the draft flag, which is what +[LABELS.md](LABELS.md)'s `state:building` row already says in the machine's +voice: a draft carrying a standing non-approving verdict is a fix round and +reads `state:addressing` (#205). + +**Ready-for-review is the act that ends the round, and it is the builder's +alone.** No engine marks a PR ready. The flip asserts that the round was +answered whole, and that assertion is the one judgement about a round its +author cannot delegate to a machine: an engine may draft a PR, and crew#139's +does, but only the builder undrafts it. + +**Where a draft suppressed the checks, green is proven at the flip and the +request still follows it.** Step 1's precondition is the whole rule and this +adds no second one — it says only *when* the head answers: marking ready is +what runs the checks the draft held back, so the order is flip, let the head +answer, then request, and the argued exception stays the only way past a red +one. Waiting there is compliance, not a stall, and the machine reads it that +way too: `blocker:unrequested` does not fire while a head's checks are pending +or red, because the one blocker that demands an act has to know when the act +is permitted (#236 — crew#318 carried it at ~12:44Z on 2026-08-03 while its +head's run was still in progress, which is the label flagging a builder for +obeying this section). A head with no checks configured has nothing to wait +for and is requested straight away, the same reading that sweep gives it. + ## The ruling ask Set `needs-ruling` whenever a decision belongs to a human: org policy, diff --git a/REVIEWER.md b/REVIEWER.md index 7d8e74e..754691d 100644 --- a/REVIEWER.md +++ b/REVIEWER.md @@ -141,6 +141,11 @@ saw Y" outranks one that says "this looks like it might". - 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). +- A **draft carrying `state:addressing` is a fix round in progress**, not + abandonment: an engine may convert a PR back to draft at round close so the + builder's checkpoint pushes stop firing CI, and the flip back to ready is + the builder's own act announcing the round is answered + ([BUILDER.md](BUILDER.md#the-review-round)). - 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. diff --git a/changelog.d/258.md b/changelog.d/258.md new file mode 100644 index 0000000..b03d4a0 --- /dev/null +++ b/changelog.d/258.md @@ -0,0 +1,7 @@ +### Added + +- BUILDER.md now describes a fix round that rides a draft: the draft phase + stays the builder's, ready-for-review is the builder's own act, and where a + draft suppressed the checks green is proven at the flip (#258). +- REVIEWER.md now reads a draft carrying `state:addressing` as a fix round in + progress rather than abandonment (#258). From dbbdbdb6ddae40573e665e199d168416f2175aec Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:51:25 +0000 Subject: [PATCH 057/162] docs(builder): cite what this file carries, not what crew's engine does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first paragraph referred to a 15-minute cadence and a checkpoint discipline, neither of which BUILDER.md states — the cadence is crew's engine rule and the pointer sent a reader to a section that says nothing about it. Attribute the measurement to crew#139 and point at what Building actually says. The LABELS.md sentence stops restating the state:building row's condition and points at it instead: one rule in two voices, per #258's test plan. Refs #258 --- BUILDER.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index d1b4df3..59ca1a7 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -298,19 +298,18 @@ CONTRIBUTING; the shared flow lives here and is not restated there.) ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). **A fix round may ride a draft.** An engine may convert a PR back to draft -when a round closes, and crew#139's does: the 15-minute cadence makes a -builder's fix-round pushes *saves* rather than proposals, and 41 of 106 -commits across crew's last 25 PRs — 39% — were fired at CI as if they were -proposals. Ceremony implements no such conversion and this passage specifies -none; it is written down because a builder or a reviewer who meets a mid-round -draft has to find a state the doctrine describes. What it means is what a -draft already meant during the build, extended and not changed: the draft -phase is yours, the panel cannot see it, and the checkpoint discipline -(Building, above) runs through the round unaltered. Whose ball it is does not -change either — the round outranks the draft flag, which is what -[LABELS.md](LABELS.md)'s `state:building` row already says in the machine's -voice: a draft carrying a standing non-approving verdict is a fix round and -reads `state:addressing` (#205). +when a round closes, and crew#139's does — an engine whose own rules make a +builder's mid-round pushes *saves* rather than proposals fires CI at every one +of them otherwise, 41 of 106 commits across crew's last 25 PRs by that +issue's measurement. Ceremony implements no such conversion and this passage +specifies none; it is written down because a builder or a reviewer who meets a +mid-round draft has to find a state the doctrine describes. What it means is +what a draft already meant while you were building, extended and not changed: +the draft phase is yours and the panel cannot see it (Building, above). Whose +ball it is does not change either — the round outranks the draft, so you still +owe it whole, the fixes and the reply and the flip. The label axis says the +same thing in the machine's voice rather than in this one, and +[LABELS.md](LABELS.md)'s `state:building` row is where to read it (#205). **Ready-for-review is the act that ends the round, and it is the builder's alone.** No engine marks a PR ready. The flip asserts that the round was From 7948b99acfee28238eaf2ea1b895101b7ed2c7a2 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:51:48 +0000 Subject: [PATCH 058/162] docs(builder): unknot the opening sentence Refs #258 --- BUILDER.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index 59ca1a7..639cb48 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -298,10 +298,9 @@ CONTRIBUTING; the shared flow lives here and is not restated there.) ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). **A fix round may ride a draft.** An engine may convert a PR back to draft -when a round closes, and crew#139's does — an engine whose own rules make a -builder's mid-round pushes *saves* rather than proposals fires CI at every one -of them otherwise, 41 of 106 commits across crew's last 25 PRs by that -issue's measurement. Ceremony implements no such conversion and this passage +when a round closes, and crew#139's does: where an engine's own rules make a +builder's mid-round pushes *saves* rather than proposals, every save fires CI +— 41 of 106 commits across crew's last 25 PRs, by that issue's measurement. Ceremony implements no such conversion and this passage specifies none; it is written down because a builder or a reviewer who meets a mid-round draft has to find a state the doctrine describes. What it means is what a draft already meant while you were building, extended and not changed: From 3367cae4d4745e99dde7e5989ebf3b79fce5c294 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:52:44 +0000 Subject: [PATCH 059/162] =?UTF-8?q?docs(reviewer):=20one=20vocabulary=20ac?= =?UTF-8?q?ross=20the=20two=20files=20=E2=80=94=20mid-round=20saves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #258 --- REVIEWER.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/REVIEWER.md b/REVIEWER.md index 754691d..17e8b77 100644 --- a/REVIEWER.md +++ b/REVIEWER.md @@ -143,8 +143,8 @@ saw Y" outranks one that says "this looks like it might". reviews mid-address just churn the target). - A **draft carrying `state:addressing` is a fix round in progress**, not abandonment: an engine may convert a PR back to draft at round close so the - builder's checkpoint pushes stop firing CI, and the flip back to ready is - the builder's own act announcing the round is answered + builder's mid-round saves stop firing CI, and the flip back to ready is the + builder's own act announcing the round is answered ([BUILDER.md](BUILDER.md#the-review-round)). - Convergence = every panel verdict approves the current head, no `blocker:*` standing. Then the builder hands off (`state:needs-human`) and From e728612ea1b54c13ac706e125444cbbbfeb7f68e Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:12:49 +0000 Subject: [PATCH 060/162] test: resolve sibling release dependencies --- test/release-path.test.sh | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/test/release-path.test.sh b/test/release-path.test.sh index 6d030c4..572574c 100755 --- a/test/release-path.test.sh +++ b/test/release-path.test.sh @@ -13,7 +13,11 @@ TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT library_refs() { - awk ' + local sibling_refs=no + case "$1" in + */lib/*.sh) sibling_refs=yes ;; + esac + awk -v sibling_refs="$sibling_refs" ' /^[[:space:]]*#/ { next } { line = $0 @@ -21,6 +25,15 @@ library_refs() { print substr(line, RSTART, RLENGTH) line = substr(line, RSTART + RLENGTH) } + # Door libraries source siblings through their own BASH_SOURCE dirname, + # so the executable line ends in /name.sh without a literal lib/ (#237). + if (sibling_refs == "yes" && $0 ~ /^[[:space:]]*(\.|source)[[:space:]]/) { + line = $0 + while (match(line, /\/[[:alnum:]_.-]+\.sh/)) { + print "lib" substr(line, RSTART, RLENGTH) + line = substr(line, RSTART + RLENGTH) + } + } } ' "$1" } @@ -93,8 +106,9 @@ fixture() { "\$ROOT/lib/changelog.sh" >"$tree/bin/assemble" printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh" - printf '#!/usr/bin/env bash\n. "%s"\n' \ - "\$ROOT/lib/version.sh" >"$tree/lib/facts.sh" + printf '#!/usr/bin/env bash\n# shellcheck source=lib/version.sh\n. "%s"\n' \ + "\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/version.sh" \ + >"$tree/lib/facts.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/version.sh" printf '%s\n' "$tree" } @@ -117,6 +131,20 @@ printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" check "a new workflow library fails with its missing path" 1 \ "missing dependency: lib/ruling.sh" path_check "$tree" +# A library growing a sibling dependency in the production idiom must also +# name the missing path; a literal lib/ marker in a comment is not evidence. +tree="$(fixture missing-transitive)" +printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ + "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ + "\$CEREMONY_DIR/lib/changelog.sh" \ + >"$tree/.github/workflows/release.yml" +printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \ + "\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \ + >>"$tree/lib/facts.sh" +printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" +check "a new sibling library fails with its missing path" 1 \ + "missing dependency: lib/ruling.sh" path_check "$tree" + # A manifest may not rot into a safe-looking superset. tree="$(fixture extra)" printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ From 349fb4d964c732a764b1707ccc392ddf51d2397f Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:27:42 +0000 Subject: [PATCH 061/162] docs(builder): crew#139 is an open proposal, and the CI cost is what it would end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both occurrences said crew's engine already converts a PR back to draft at round close; crew#139 is OPEN, blocked and unassigned, so the passage was describing an unshipped mechanism as live (codex, kimi). The same sentence also read as if the conversion caused the CI spend — it is the status quo the conversion would end, so the counterfactual is now explicit. Rewrapped the paragraph at 80 columns; 7948b99 had left one line at 133. --- BUILDER.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index 639cb48..d1ed4b4 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -298,23 +298,27 @@ CONTRIBUTING; the shared flow lives here and is not restated there.) ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). **A fix round may ride a draft.** An engine may convert a PR back to draft -when a round closes, and crew#139's does: where an engine's own rules make a -builder's mid-round pushes *saves* rather than proposals, every save fires CI -— 41 of 106 commits across crew's last 25 PRs, by that issue's measurement. Ceremony implements no such conversion and this passage -specifies none; it is written down because a builder or a reviewer who meets a -mid-round draft has to find a state the doctrine describes. What it means is -what a draft already meant while you were building, extended and not changed: -the draft phase is yours and the panel cannot see it (Building, above). Whose -ball it is does not change either — the round outranks the draft, so you still -owe it whole, the fixes and the reply and the flip. The label axis says the -same thing in the machine's voice rather than in this one, and -[LABELS.md](LABELS.md)'s `state:building` row is where to read it (#205). +when a round closes; crew#139 proposes exactly that, and is still an open +proposal. What it names is the status quo without it: where an engine's own +rules make a builder's mid-round pushes *saves* rather than proposals, every +one of those saves fires CI while the PR sits ready — 41 of 106 commits +across crew's last 25 PRs, by that issue's measurement — and converting back +to draft is what would stop them. Ceremony implements no such conversion and +this passage specifies none; it is written down because a builder or a +reviewer who meets a mid-round draft has to find a state the doctrine +describes. What it means is what a draft already meant while you were +building, extended and not changed: the draft phase is yours and the panel +cannot see it (Building, above). Whose ball it is does not change either — +the round outranks the draft, so you still owe it whole, the fixes and the +reply and the flip. The label axis says the same thing in the machine's +voice rather than in this one, and [LABELS.md](LABELS.md)'s `state:building` +row is where to read it (#205). **Ready-for-review is the act that ends the round, and it is the builder's alone.** No engine marks a PR ready. The flip asserts that the round was answered whole, and that assertion is the one judgement about a round its -author cannot delegate to a machine: an engine may draft a PR, and crew#139's -does, but only the builder undrafts it. +author cannot delegate to a machine: an engine may draft a PR, which is what +crew#139 proposes engines do, but only the builder undrafts it. **Where a draft suppressed the checks, green is proven at the flip and the request still follows it.** Step 1's precondition is the whole rule and this From 461c25b08e6cf8cd23f9b6250f521b281058600d Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:47:55 +0000 Subject: [PATCH 062/162] wip: add unreleased marker guard --- .github/scripts/marker-check.sh | 112 ++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100755 .github/scripts/marker-check.sh diff --git a/.github/scripts/marker-check.sh b/.github/scripts/marker-check.sh new file mode 100755 index 0000000..65c1be6 --- /dev/null +++ b/.github/scripts/marker-check.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Availability-marker guard (issue #238). Five of five markers found by #221 +# outlived the releases that shipped their machinery. A release candidate must +# therefore reject a marker its assembled changelog makes false, while every +# tree rejects an untraceable marker. Cross-repo citations are traceable but +# are not compared with this repository's changelog. +# +# Usage: marker-check.sh [tree-dir] (default: the repository root) +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tree="${1:-$ROOT}" + +fail() { + printf '%s\n' "$@" >&2 + exit 1 +} + +if ! git -C "$tree" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + fail "marker-check: $tree is not a Git work tree; tracked Markdown cannot be determined." +fi + +marker_records="$(mktemp)" +trap 'rm -f "$marker_records"' EXIT + +mapfile -d '' markdown_files < <(git -C "$tree" ls-files -z -- '*.md') +for relative in "${markdown_files[@]}"; do + case "$relative" in + changelog.d/*) continue ;; + esac + + if ! awk -v file="$relative" ' + { lines[NR] = $0 } + END { + token = "**unreleased**" + citation_re = "^[[:space:]]*\\((([[:alnum:]_.-]+/)?[[:alnum:]_.-]+)?#[0-9]+\\)" + bad = 0 + + for (line_no = 1; line_no <= NR; line_no++) { + remaining = lines[line_no] + offset = 0 + while ((at = index(remaining, token)) != 0) { + rest = substr(remaining, at + length(token)) + candidate = rest + next_line = line_no + 1 + while (candidate ~ /^[[:space:]]*$/ && next_line <= NR) { + candidate = candidate " " lines[next_line] + next_line++ + } + + if (match(candidate, citation_re)) { + citation = substr(candidate, RSTART, RLENGTH) + sub(/^[[:space:]]*\(/, "", citation) + sub(/\)$/, "", citation) + printf "%s\t%d\t%s\n", file, line_no, citation + } else { + printf "marker-check: %s:%d: %s\n", file, line_no, lines[line_no] > "/dev/stderr" + printf "marker-check: every **unreleased** marker must be immediately followed by an issue citation such as (#238), (crew#293), or (owner/repo#293).\n" > "/dev/stderr" + bad = 1 + } + + offset += at + length(token) - 1 + remaining = substr(lines[line_no], offset + 1) + } + } + exit bad + } + ' "$tree/$relative" >>"$marker_records"; then + exit 1 + fi +done + +version="" +if [ -f "$tree/VERSION" ]; then + IFS= read -r version <"$tree/VERSION" || true +fi + +if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + [ -f "$tree/CHANGELOG.md" ] || \ + fail "marker-check: bare VERSION '$version' requires CHANGELOG.md for the release-marker check." + + shipped_issues="$(awk ' + $1 == "##" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ { + if (in_section) exit + in_section = 1 + next + } + in_section && /^##[[:space:]]/ { exit } + in_section { + text = $0 + while (match(text, /(^|[^[:alnum:]_./-])#[0-9]+/)) { + issue = substr(text, RSTART, RLENGTH) + sub(/^.*#/, "", issue) + print issue + text = substr(text, RSTART + RLENGTH) + } + } + ' "$tree/CHANGELOG.md" | sort -u)" + + while IFS=$'\t' read -r file line citation; do + case "$citation" in + \#*) + issue="${citation#\#}" + if printf '%s\n' "$shipped_issues" | grep -qxF "$issue"; then + fail "marker-check: $file:$line: **unreleased** (#$issue) is false on release candidate $version; CHANGELOG.md's top release section cites #$issue, so clear the marker in this release PR." + fi + ;; + esac + done <"$marker_records" +fi + +echo "marker-check: availability markers agree with the tree." From dca8e7220c0e8a636c4acb363ef9d33211a96d97 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:49:22 +0000 Subject: [PATCH 063/162] test: exercise documentation marker guard --- .github/workflows/ci.yml | 4 ++ changelog.d/238.md | 4 ++ test/marker-check.test.sh | 94 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 changelog.d/238.md create mode 100644 test/marker-check.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37a6882..c17c424 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,10 @@ jobs: # The pin rules (issue #9; #1 D3): a stale CEREMONY_SELF_REF fails # CI here, not a consumer's release. run: bash .github/scripts/self-ref-check.sh + - name: Documentation availability markers + # Five stale markers survived the tags that shipped their machinery + # (#221); #238 makes the release candidate reject that drift. + run: bash .github/scripts/marker-check.sh - name: Tests env: # The npm-backed version_write case may skip locally when npm is diff --git a/changelog.d/238.md b/changelog.d/238.md new file mode 100644 index 0000000..6805c0b --- /dev/null +++ b/changelog.d/238.md @@ -0,0 +1,4 @@ +### Added + +- Guard documentation availability markers against missing issue citations + and release candidates that already ship the cited work (#238). diff --git a/test/marker-check.test.sh b/test/marker-check.test.sh new file mode 100644 index 0000000..7017e84 --- /dev/null +++ b/test/marker-check.test.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Contract tests for .github/scripts/marker-check.sh (issue #238). The guard +# is driven against tracked fixture trees; set -u, not -e, because failures +# are behavior for the harness to inspect. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +. "$ROOT/test/harness.sh" + +CHECK="$ROOT/.github/scripts/marker-check.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +fixture() { + local name="$1" version="$2" + mkdir -p "$TMP/$name/docs" "$TMP/$name/changelog.d" + git -C "$TMP/$name" init -q + printf '%s\n' "$version" >"$TMP/$name/VERSION" + printf '# Changelog\n\n## 0.5.0 — 2026-08-03\n\n- Shipped (#221).\n' \ + >"$TMP/$name/CHANGELOG.md" +} + +run_check() { + git -C "$TMP/$1" add . + bash "$CHECK" "$TMP/$1" +} + +fixture wrapped 0.6.0-dev +cat >"$TMP/wrapped/docs/CONSUMERS.md" <<'EOF' +The new guard remains **unreleased** +(#238) until the next tag. +EOF +check "a wrapped local citation is accepted" 0 "agree with the tree" \ + run_check wrapped + +fixture uncited 0.6.0-dev +printf 'The new guard remains **unreleased** for now.\n' \ + >"$TMP/uncited/docs/CONSUMERS.md" +check "an uncited marker fails on a dev tree with file and line" 1 \ + "docs/CONSUMERS.md:1" run_check uncited + +fixture shipped 0.6.0 +printf 'The new guard remains **unreleased** (#224).\n' \ + >"$TMP/shipped/docs/CONSUMERS.md" +cat >"$TMP/shipped/CHANGELOG.md" <<'EOF' +# Changelog + +## 0.6.0 — 2026-08-03 + +- The guard shipped (#224). + +## 0.5.0 — 2026-08-02 + +- Older work (#999). +EOF +check "a release rejects a marker cited by its top section" 1 \ + "docs/CONSUMERS.md:1: **unreleased** (#224)" run_check shipped + +fixture not-shipped 0.6.0 +printf 'Future work remains **unreleased** (#999).\n' \ + >"$TMP/not-shipped/docs/CONSUMERS.md" +cp "$TMP/shipped/CHANGELOG.md" "$TMP/not-shipped/CHANGELOG.md" +check "a release keeps a marker absent from its top section" 0 \ + "agree with the tree" run_check not-shipped + +fixture dev-shipped 0.6.0-dev +printf 'Future work remains **unreleased** (#224).\n' \ + >"$TMP/dev-shipped/docs/CONSUMERS.md" +cp "$TMP/shipped/CHANGELOG.md" "$TMP/dev-shipped/CHANGELOG.md" +check "a dev tree does not compare markers with shipped sections" 0 \ + "agree with the tree" run_check dev-shipped + +fixture cross-repo 0.6.0 +printf 'Crew work remains **unreleased** (crew#293).\n' \ + >"$TMP/cross-repo/docs/CONSUMERS.md" +cat >"$TMP/cross-repo/CHANGELOG.md" <<'EOF' +# Changelog + +## 0.6.0 — 2026-08-03 + +- Local work shipped (#293). +EOF +check "a cross-repo citation is valid and ignored by release comparison" 0 \ + "agree with the tree" run_check cross-repo + +fixture exclusions 0.6.0-dev +printf '# Notes\n\n## Unreleased\n' >"$TMP/exclusions/NOTES.md" +printf -- '- A fragment may say **unreleased** without being documentation.\n' \ + >"$TMP/exclusions/changelog.d/999.md" +check "headings and changelog fragments do not trip the guard" 0 \ + "agree with the tree" run_check exclusions + +summary From 7200c8da49f8b00a25c458fad2b42bd17d4fa9d5 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:51:17 +0000 Subject: [PATCH 064/162] docs: define traceable availability markers --- docs/CONSUMERS.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index fc12b4c..38c9fc0 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -113,12 +113,17 @@ the machinery at all: self-hosted runner still wants it: the guard's value is the day somebody adds one. - This guide documents `main`. New machinery is marked **unreleased** - here until a release tag ships it — and the release PR that ships the - machinery clears, in that same PR, every marker its own assembled - section makes false: the section cites its issues, each marker cites - the same issue, and the release PR's diff is the one place both - halves are visible at once (#221). If an action does not exist at the + This guide documents `main`. New machinery is marked with the lowercase + word `unreleased` in bold, immediately followed by its issue citation + (for example, `(#238)`); whitespace between them may include a line break. + A citation is mandatory, because a marker the guard cannot trace is a + marker it cannot prove false. Cross-repo citations such as `(crew#293)` + satisfy that traceability rule but are not compared with this repository's + release section. The ceremony-only `marker-check.sh` guard enforces both + rules. The release PR that ships the machinery clears, in that same PR, + every marker its own assembled section makes false: the section cites its + issues, each marker cites the same issue, and the release PR's diff is the + one place both halves are visible at once (#221). If an action does not exist at the consumer's pinned tag, adopt it with the pin bump to the first tag that carries it; never mix a moving or newer ref into an otherwise exact-pin consumer. In particular, `0.1.0` carries `changelog-armed`, From d87d76d64bcb913581f686dc0df5f46c17310d7f Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:56:02 +0000 Subject: [PATCH 065/162] fix: align marker guard with release oracle --- .github/scripts/marker-check.sh | 26 ++++++++++++++++++++------ docs/CONSUMERS.md | 20 ++++++++++---------- test/marker-check.test.sh | 30 ++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/.github/scripts/marker-check.sh b/.github/scripts/marker-check.sh index 65c1be6..b973f8f 100755 --- a/.github/scripts/marker-check.sh +++ b/.github/scripts/marker-check.sh @@ -3,7 +3,10 @@ # outlived the releases that shipped their machinery. A release candidate must # therefore reject a marker its assembled changelog makes false, while every # tree rejects an untraceable marker. Cross-repo citations are traceable but -# are not compared with this repository's changelog. +# are not compared with this repository's changelog; a marker for this repo's +# own issue uses bare #N, never a self-qualified repository citation (#238 D8). +# CHANGELOG.md is the release oracle and immutable shipped prose, so it and the +# fragments that feed it are excluded from the documentation scan (#238 D5). # # Usage: marker-check.sh [tree-dir] (default: the repository root) set -euo pipefail @@ -26,25 +29,36 @@ trap 'rm -f "$marker_records"' EXIT mapfile -d '' markdown_files < <(git -C "$tree" ls-files -z -- '*.md') for relative in "${markdown_files[@]}"; do case "$relative" in - changelog.d/*) continue ;; + CHANGELOG.md|changelog.d/*) continue ;; esac if ! awk -v file="$relative" ' - { lines[NR] = $0 } + function without_inline_code(text, before, after) { + while (match(text, /`[^`]*`/)) { + before = substr(text, 1, RSTART - 1) + after = substr(text, RSTART + RLENGTH) + text = before after + } + return text + } + { + lines[NR] = $0 + scan_lines[NR] = without_inline_code($0) + } END { token = "**unreleased**" citation_re = "^[[:space:]]*\\((([[:alnum:]_.-]+/)?[[:alnum:]_.-]+)?#[0-9]+\\)" bad = 0 for (line_no = 1; line_no <= NR; line_no++) { - remaining = lines[line_no] + remaining = scan_lines[line_no] offset = 0 while ((at = index(remaining, token)) != 0) { rest = substr(remaining, at + length(token)) candidate = rest next_line = line_no + 1 while (candidate ~ /^[[:space:]]*$/ && next_line <= NR) { - candidate = candidate " " lines[next_line] + candidate = candidate " " scan_lines[next_line] next_line++ } @@ -60,7 +74,7 @@ for relative in "${markdown_files[@]}"; do } offset += at + length(token) - 1 - remaining = substr(lines[line_no], offset + 1) + remaining = substr(scan_lines[line_no], offset + 1) } } exit bad diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 38c9fc0..cb3f30b 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -113,14 +113,14 @@ the machinery at all: self-hosted runner still wants it: the guard's value is the day somebody adds one. - This guide documents `main`. New machinery is marked with the lowercase - word `unreleased` in bold, immediately followed by its issue citation - (for example, `(#238)`); whitespace between them may include a line break. - A citation is mandatory, because a marker the guard cannot trace is a - marker it cannot prove false. Cross-repo citations such as `(crew#293)` - satisfy that traceability rule but are not compared with this repository's - release section. The ceremony-only `marker-check.sh` guard enforces both - rules. The release PR that ships the machinery clears, in that same PR, + This guide documents `main`. A marker is the literal token + `**unreleased**` immediately followed by its issue citation (for example, + `(#238)`); whitespace between them may include a line break. A citation is + mandatory, because a marker the guard cannot trace is a marker it cannot + prove false. A marker for this repository's own issue uses bare `#N`. + Cross-repo citations such as `(crew#293)` satisfy the traceability rule but + are not compared with this repository's release section. The ceremony-only + `marker-check.sh` guard enforces these rules. The release PR that ships the machinery clears, in that same PR, every marker its own assembled section makes false: the section cites its issues, each marker cites the same issue, and the release PR's diff is the one place both halves are visible at once (#221). If an action does not exist at the @@ -153,7 +153,7 @@ the machinery at all: - uses: heavy-duty/ceremony/actions/refs-not-closing@ ``` - `refs-not-closing` is **unreleased** until the first tag carrying #218. + `refs-not-closing` is **unreleased** (#218) until the first tag carrying it. Adopt this caller with that ordinary pin bump; never point only this file at a moving or newer ref. 7. **Labels automation** (optional but recommended): the two callers from @@ -592,7 +592,7 @@ mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale pin goes red instead of quietly governing. `RELEASES.md` joins that mirror with the first tag carrying ceremony#248. -It is **unreleased** until that tag exists: consumers add +It is **unreleased** (#248) until that tag exists: consumers add `.ceremony/RELEASES.md` only with the ordinary pin bump and re-sync, never by copying it ahead of their pinned doctrine set. diff --git a/test/marker-check.test.sh b/test/marker-check.test.sh index 7017e84..ca373fd 100644 --- a/test/marker-check.test.sh +++ b/test/marker-check.test.sh @@ -84,11 +84,37 @@ EOF check "a cross-repo citation is valid and ignored by release comparison" 0 \ "agree with the tree" run_check cross-repo +fixture self-qualified 0.6.0 +printf 'Ceremony work remains **unreleased** (ceremony#248).\n' \ + >"$TMP/self-qualified/docs/CONSUMERS.md" +cat >"$TMP/self-qualified/CHANGELOG.md" <<'EOF' +# Changelog + +## 0.6.0 — 2026-08-03 + +- Ceremony work shipped (#248). +EOF +check "a self-qualified citation is ignored; local markers must use bare #N" 0 \ + "agree with the tree" run_check self-qualified + fixture exclusions 0.6.0-dev -printf '# Notes\n\n## Unreleased\n' >"$TMP/exclusions/NOTES.md" +cat >"$TMP/exclusions/NOTES.md" <<'EOF' +# Notes + +## Unreleased + +The marker token is `**unreleased**`. +EOF printf -- '- A fragment may say **unreleased** without being documentation.\n' \ >"$TMP/exclusions/changelog.d/999.md" -check "headings and changelog fragments do not trip the guard" 0 \ +cat >"$TMP/exclusions/CHANGELOG.md" <<'EOF' +# Changelog + +## 0.5.0 — 2026-08-03 + +- Shipped prose may discuss **unreleased** markers without becoming one. +EOF +check "headings, inline mentions, changelog entries, and fragments are excluded" 0 \ "agree with the tree" run_check exclusions summary From 63351bd425cbbf45851ec50cef3f5d96b93dd1b4 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:03:53 +0000 Subject: [PATCH 066/162] feat(issueflow): post-merge evidence nudge fires on 7 quiet days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP checkpoint: the nudge itself, tests still owed. Reuses ruling_nudge_decision so the 7-day rule keeps one spelling, addresses the triage actor (post-merge is triage's completion queue), and carries no idempotency marker — the comment is itself activity, so it self-rate-limits. Refs #254 --- .../issueflow-reconcile.sh | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 7c3bda2..1c15f8b 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -606,12 +606,49 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in fi elif has_issue_label post-merge; then assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" + # The evidence nudge's clock is read BEFORE any comment this branch + # posts. `ensure_comment` below is itself activity, so reading after it + # would let the assigned-flag comment silence the nudge for another 7 + # days — the same self-silencing the ruling nudge avoids by reading its + # facts once, at the top of the pass. + created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" + guarded_read age last_issue_activity "$n" "$created" \ + || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" if [ "$assignees" -gt 0 ] || has_issue_label attention; then ensure_comment "$n" post-merge-assigned \ 'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.' log "#$n: assigned or attention-bearing post-merge issue flagged" fi attention_suppression=post-merge-assigned + # ---- the post-merge evidence nudge (#254), the ruling nudge's twin ---- + # A `post-merge` item waits on named evidence with a named owner, and + # nothing nudged when the wait went quiet: crew#181's real-host criterion + # starved four separate times across two releases, crew#240/#264 sat + # until an operator happened to run the right read. Same 7-day rule, same + # constant, same no-marker property — `ruling_nudge_decision` is the one + # spelling of all three (lib/ruling.sh), and a second `7 * 24 * 3600` + # here is the drift that file exists to prevent. + # + # The addressee is the triage actor, not `HUMAN_REVIEWER`: `post-merge` + # is triage's completion queue by contract (TRIAGE.md), so a starving + # wake condition is triage's to answer, and routing it to the operator + # asks the wrong party for a move it does not owe. `triage-actors=` is + # mandatory config — `load_issueflow_config` refuses to run without it — + # so there is nothing to fall back to, and a silent fallback is exactly + # how the wrong addressee comes back. + if [ "$(ruling_nudge_decision "$NOW" "$age")" = NUDGE ]; then + local quiet_days=$(((NOW - age) / 86400)) + run gh issue comment "$n" -R "$REPO" --body "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no activity for ${quiet_days} days: https://github.com/$REPO/issues/$n + +Its wake evidence is still owed. \`post-merge\` means the merge landed and +triage owns completion — judge the remaining criteria against the evidence +and close the issue, or say what is still outstanding and who owes it. The +sweep names no criterion: which one starved is prose, and the machine never +judges prose (the link is the payload). + +*This nudge is comment-only and carries no idempotency marker on purpose: the comment itself is activity, so posting it resets the 7-day window and the rule self-rate-limits to one nudge per 7 quiet days. Do not add a marker.*" >/dev/null + log "#$n: post-merge evidence nudge (${quiet_days}d quiet — triage owes the wake evidence)" + fi elif has_issue_label blocked; then refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" From 2a1f8a012bea31fa77dca4a630a7a70a92413565 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:05:05 +0000 Subject: [PATCH 067/162] test: pin inline marker mention boundary --- .github/scripts/marker-check.sh | 2 ++ docs/CONSUMERS.md | 8 +++++--- test/marker-check.test.sh | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/scripts/marker-check.sh b/.github/scripts/marker-check.sh index b973f8f..851ed6a 100755 --- a/.github/scripts/marker-check.sh +++ b/.github/scripts/marker-check.sh @@ -7,6 +7,8 @@ # own issue uses bare #N, never a self-qualified repository citation (#238 D8). # CHANGELOG.md is the release oracle and immutable shipped prose, so it and the # fragments that feed it are excluded from the documentation scan (#238 D5). +# A token inside inline code is a mention, not a marker; spans are stripped +# individually so unrelated backticks cannot hide a real marker (#238 D9). # # Usage: marker-check.sh [tree-dir] (default: the repository root) set -euo pipefail diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index cb3f30b..413385f 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -117,9 +117,11 @@ the machinery at all: `**unreleased**` immediately followed by its issue citation (for example, `(#238)`); whitespace between them may include a line break. A citation is mandatory, because a marker the guard cannot trace is a marker it cannot - prove false. A marker for this repository's own issue uses bare `#N`. - Cross-repo citations such as `(crew#293)` satisfy the traceability rule but - are not compared with this repository's release section. The ceremony-only + prove false. A token inside an inline-code span is a mention, not a marker; + spans are ignored individually, so unrelated inline code cannot hide one. + A marker for this repository's own issue uses bare `#N`. Cross-repo + citations such as `(crew#293)` satisfy the traceability rule but are not + compared with this repository's release section. The ceremony-only `marker-check.sh` guard enforces these rules. The release PR that ships the machinery clears, in that same PR, every marker its own assembled section makes false: the section cites its issues, each marker cites the same issue, and the release PR's diff is the diff --git a/test/marker-check.test.sh b/test/marker-check.test.sh index ca373fd..0310c8c 100644 --- a/test/marker-check.test.sh +++ b/test/marker-check.test.sh @@ -40,6 +40,26 @@ printf 'The new guard remains **unreleased** for now.\n' \ check "an uncited marker fails on a dev tree with file and line" 1 \ "docs/CONSUMERS.md:1" run_check uncited +fixture inline-mention 0.6.0-dev +cat >"$TMP/inline-mention/docs/CONSUMERS.md" <<'EOF' +The marker token is `**unreleased**`. +EOF +check "an inline-code token is a mention and needs no citation" 0 \ + "agree with the tree" run_check inline-mention + +fixture inline-bare 0.6.0-dev +printf 'The marker token is **unreleased**.\n' \ + >"$TMP/inline-bare/docs/CONSUMERS.md" +check "removing the backticks exposes the uncited marker" 1 \ + "docs/CONSUMERS.md:1" run_check inline-bare + +fixture inline-neighbor 0.6.0-dev +cat >"$TMP/inline-neighbor/docs/CONSUMERS.md" <<'EOF' +The `new guard` remains **unreleased** until its tag. +EOF +check "unrelated inline code cannot hide an uncited marker on the same line" 1 \ + "docs/CONSUMERS.md:1" run_check inline-neighbor + fixture shipped 0.6.0 printf 'The new guard remains **unreleased** (#224).\n' \ >"$TMP/shipped/docs/CONSUMERS.md" From b3fd26b21b4093187e8b0362d4ebbca1caf0b4fd Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:06:47 +0000 Subject: [PATCH 068/162] =?UTF-8?q?test(issueflow):=20the=20evidence=20nud?= =?UTF-8?q?ge's=20fixtures=20=E2=80=94=20window,=20addressee,=20no=20marke?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the must-nudge pair (8 quiet days; post-merge + needs-ruling both speak), the must-not set (6 days, fresh comment, every other queue state), self-rate-limiting proven by sweeping again a day later rather than by asserting a marker's absence, and zero writes across every probe. Two existing probes move: #36's 'no comment' assertion described the starvation this issue ends, and #67 gets recent activity so its precedence count stays the assertion doing the work. Refs #254 --- test/issueflow-reconcile.test.sh | 132 ++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 2 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index ec9e83b..3ea95c7 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -431,7 +431,11 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 false|closing|refs, $5 m local assignees="${3:-1}" open_pr="${4:-false}" merged_ref_prs="${5:-}" local body="${6:-}" assignee_json='[]' open_pr_records="" spec pr merged_at [ "$assignees" -eq 0 ] || assignee_json='[{"login":"owner-bot"}]' - REPO=owner/repo NOW="$INOW" + # `PROBE_NOW` moves the sweep's clock without moving the fixtures — the + # only way to prove a rule that self-rate-limits on its own comment's + # timestamp (#254): sweep, then sweep again a day later and watch the + # nudge stay silent because the comment it posted is now the activity. + REPO=owner/repo NOW="${PROBE_NOW:-$INOW}" ISSUE_LABELS="$2" ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ --argjson assignees "$assignee_json" --arg body "$body" \ @@ -517,6 +521,13 @@ check "...drawing the #252 parse echo and nothing else" 0 "1" \ grep -c -- '^----$' "$TMP/posted-66" attention_episode 67 "$(iso_at $((INOW - 60)))" +# Recent activity keeps the evidence nudge (#254) off this probe: it is a +# precedence case, and "exactly one comment" is the assertion doing the work. +# The nudge's own coexistence with the post-merge diagnostic is pinned in its +# section below, on a probe that is quiet on purpose. +jq -n --arg at "$(iso_at $((INOW - 60)))" \ + '[{"user":{"login":"triage-one"},"created_at":$at,"html_url":"https://x/c67","body":"still waiting on the tag"}]' \ + >"$(cfix 67)" post_merge_attention="$(issue_probe 67 $'post-merge\nattention' 0)" check "post-merge precedence leaves exactly its existing comment" 0 "1" \ grep -c -- '^----$' "$TMP/posted-67" @@ -617,7 +628,13 @@ printf '[]\n' >"$(cfix 36)" post_merge_quiet="$(issue_probe 36 post-merge 0)" check "quiet unassigned post-merge work is not reclaimed" 1 "" \ grep -q 'reclaimed' <<<"$post_merge_quiet" -check "...and causes no comment or edit" 1 "" test -f "$TMP/posted-36" +# The quiet itself is now visible (#254) — this probe is 10 days old with no +# activity, so it draws the evidence nudge and nothing else. It used to +# assert no comment at all; that assertion described the starvation this +# issue exists to end, and the edit half of it is what still matters. +check "...and causes no edit" 1 "" grep -qF -- 'issue edit 36' "$TMP/issue-edits" +check "...only the evidence nudge speaks" 0 "1" \ + grep -c -- '^----$' "$TMP/posted-36" printf '[]\n' >"$(cfix 37)" issue_probe 37 post-merge 1 >/dev/null @@ -625,6 +642,117 @@ check "assigned post-merge is flagged" 0 "" \ grep -qF '' "$TMP/posted-37" check "...and the hand-assignment is not repaired" 1 "" \ grep -qF -- 'issue edit 37' "$TMP/issue-edits" +# The flag and the nudge answer different questions — a board bug and a +# starved wake condition — so neither suppresses the other (#254). +check "...and the evidence nudge rides beside it, neither suppressed" 0 "2" \ + grep -c -- '^----$' "$TMP/posted-37" + +# -- the post-merge evidence nudge (#254), the ruling nudge's twin ---------- +# The ruling nudge solved "a wait goes quiet and nobody is told" for +# `needs-ruling`; `post-merge` had no equivalent, and crew#181's real-host +# criterion starved four times across two releases for want of one. Same +# 7-day constant (`ruling_nudge_decision`, reused not mirrored), same +# deliberate absence of an idempotency marker, and — unlike the ruling +# nudge — addressed to the triage actor, because `post-merge` is triage's +# completion queue and the operator owes nothing here (#254 D1). +nudge_edits_before="$(wc -l <"$TMP/issue-edits")" + +quiet_comment() { # $1 issue, $2 seconds of quiet — one ordinary comment, then silence + jq -n --arg at "$(iso_at $((INOW - $2)))" \ + '[{"user":{"login":"triage-one"},"created_at":$at,"html_url":"https://x/c","body":"evidence pending"}]' \ + >"$(cfix "$1")" + printf '[]\n' >"$(tfix "$1")" +} + +quiet_comment 80 $((8 * 86400)) +nudged="$(issue_probe 80 post-merge 0)" +check "8 quiet days on a post-merge item draws the evidence nudge" 0 "" \ + grep -q 'post-merge evidence nudge' <<<"$nudged" +# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process +check "...addressed to the triage actor, never the human reviewer" 0 "" \ + bash -c 'grep -qF "@triage-one" "$1" && ! grep -qF "@danmt" "$1"' _ "$TMP/posted-80" +check "...with the issue link as the payload" 0 "" \ + grep -qF 'https://github.com/owner/repo/issues/80' "$TMP/posted-80" +check "...carrying the do-not-add-a-marker warning in the comment" 0 "" \ + grep -qF 'Do not add a marker.' "$TMP/posted-80" +# Asserted directly, not merely omitted: a marker would turn "once per 7 +# quiet days" into "once per issue, forever" — the exact "fix" lib/ruling.sh's +# header records as the thing that breaks this rule. +check "...and no idempotency marker on the path" 1 "" \ + grep -qF '\nrung"}, + {"user":{"login":"sweep-bot"},"created_at":$r24,"html_url":"https://x/r24","body":"\nrung"}]' \ + >"$(cfix 84)" +both="$(issue_probe 84 $'post-merge\nneeds-ruling' 0)" +check "a quiet post-merge item under a pending ruling nudges both waits" 0 "" \ + grep -q 'post-merge evidence nudge' <<<"$both" +check "...and the ruling nudge is not suppressed by it" 0 "" \ + grep -q 'ruling nudge' <<<"$both" +# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process +check "...each addressing its own party" 0 "" \ + bash -c 'grep -qF "@triage-one" "$1" && grep -qF "@danmt" "$1"' _ "$TMP/posted-84" + +# Every other queue state: the nudge is `post-merge`'s alone. Each is equally +# quiet, and `claimed` carries an open PR so its own reclaim clock — the one +# other 10-day rule on this path — stays out of the way. +non_post_merge=(85:ready:0:false 86:claimed:1:true 87:blocked:0:false 88:epic:0:false 89:needs-triage:0:false) +for spec in "${non_post_merge[@]}"; do + IFS=: read -r n state probe_assignees probe_pr <<<"$spec" + printf '[]\n' >"$(cfix "$n")" + printf '[]\n' >"$(tfix "$n")" + check "a 10-day-quiet $state issue draws no evidence nudge" 1 "" \ + grep -q 'post-merge evidence nudge' \ + <<<"$(issue_probe "$n" "$state" "$probe_assignees" "$probe_pr")" +done + +# `post-merge` is the one queue state whose whole meaning is that the machine +# owes nothing (LABELS.md: the sweep never reclaims it). This issue makes the +# quiet visible; it must never make it actionable. +# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell +check "the evidence-nudge probes perform no issue edits" 0 "$nudge_edits_before" \ + bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" # -- non-triggers stay byte-for-byte outside the transition ------------------ recent_timeline() { From b18168b4f3728515deaa08e50824c8bc11af7935 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:08:11 +0000 Subject: [PATCH 069/162] docs(labels): quiet post-merge is visible, still never reclaimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LABELS.md said the sweep never reclaims post-merge and stopped there, which now reads as 'the machine says nothing' — after this change it says one thing, once per 7 quiet days. Plus the fragment. Refs #254 --- LABELS.md | 8 +++++++- changelog.d/254.md | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 changelog.d/254.md diff --git a/LABELS.md b/LABELS.md index 0f6acd4..7f62a58 100644 --- a/LABELS.md +++ b/LABELS.md @@ -68,7 +68,13 @@ assignee, and comments with the remaining criteria verbatim. The comment says that the claim is released and that triage owes a follow-up naming the owner and wake condition for completion. Triage writes that full transition comment in the same tick when it or the operator makes the move by hand. The sweep -never reclaims `post-merge`: weeks of quiet can be the state working. +never reclaims `post-merge`: weeks of quiet can be the state working. It does +make the quiet visible — after 7 days without a comment, a review or a commit +the sweep posts one nudge naming the triage actor, saying the wake evidence is +owed and linking the item. Which criterion starved is prose the machine never +judges; the link is the payload. Like the ruling nudge it carries no +idempotency marker on purpose — the comment is itself activity, so the rule +self-rate-limits to one nudge per 7 quiet days — and it writes no label. `post-merge` never composes with `blocked`; the transition comment carries the wait. It never composes with `attention`, because releasing the claim clears diff --git a/changelog.d/254.md b/changelog.d/254.md new file mode 100644 index 0000000..4b0761e --- /dev/null +++ b/changelog.d/254.md @@ -0,0 +1,14 @@ +### Added + +- A `post-merge` item with no comment, review or commit for 7 days now draws + one nudge from the issue sweep: the wake evidence is owed. A starving + criterion used to be found only when someone happened to run the right read + (#254). +- The nudge names the triage actor from `triage-actors=`, not the human + reviewer: `post-merge` is triage's completion queue, so the starved wake + condition is triage's to answer (#254). +- It links the item and parses nothing from the body — which criterion + starved is prose, and the machine never judges prose (#254). +- Like the ruling nudge it carries no idempotency marker on purpose: the + comment is itself activity, so the rule self-rate-limits to one nudge per 7 + quiet days. Comment-only — no path here writes a label (#254). From 37d138fecfbbf28d0b0319e8688e3c87c79eb126 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:10:42 +0000 Subject: [PATCH 070/162] test(issueflow): prose is never judged, and the constant has one spelling The two 'must fail loudly' cases from the plan: an unparseable body still nudges and the nudge quotes none of it, and a grep-level pin that no second 7-day constant appears in the sweep. Refs #254 --- test/issueflow-reconcile.test.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 3ea95c7..eec84b1 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -747,6 +747,28 @@ for spec in "${non_post_merge[@]}"; do <<<"$(issue_probe "$n" "$state" "$probe_assignees" "$probe_pr")" done +# The machine never judges prose: which criterion starved is not a fact the +# sweep reads, so a body it could not parse if it tried still nudges. +quiet_comment 90 $((8 * 86400)) +unparseable_body="$(issue_probe 90 post-merge 0 false "" '¯\_(ツ)_/¯ wake: ask danmt sometime')" +check "an unparseable body still nudges — the link is the payload" 0 "" \ + grep -q 'post-merge evidence nudge' <<<"$unparseable_body" +check "...and the nudge quotes none of it" 1 "" \ + grep -qF 'ask danmt sometime' "$TMP/posted-90" + +# One spelling of the 7-day rule. `lib/ruling.sh` exists because this family +# already paid for two copies of a constant; a second one here is the drift, +# and it is cheap to pin at the grep level. +# Code lines only: the branch's comment names the constant it must not +# respell, which is the sentence a future reader needs and not a second copy. +check "the 7-day rule is not respelled in the sweep" 1 "" \ + grep -nE '^[^#]*(7 \* 24 \* 3600|604800)' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # the call site is asserted as a literal +check "...it is reused from lib/ruling.sh" 0 "" \ + grep -qF 'ruling_nudge_decision "$NOW" "$age"' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" + # `post-merge` is the one queue state whose whole meaning is that the machine # owes nothing (LABELS.md: the sweep never reclaims it). This issue makes the # quiet visible; it must never make it actionable. From d4a82707f2d55eca00d28a7f30302163985cfccb Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:38:53 +0000 Subject: [PATCH 071/162] fix(issueflow): the evidence clock is not the claim clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nudge rode `last_issue_activity`, which counts `assigned` timeline events because assignment is the claim the 48-hour reclaim protects. `post-merge` has no claim: an assignee there is the invalid composition the flag beside it reports, so counting the assignment let a broken board buy the item another 7 days of silence — this issue's failure direction taken backwards. One computation, two clocks over it: `issue_activity_at` is the body, `last_issue_activity` keeps the reclaim and ruling clocks byte-identical, and `last_issue_comment_activity` is the evidence clock. Both clocks are read before this branch posts anything, the ruling one included — read after, it would date the issue by the evidence nudge's own comment and silence the ruling nudge, which is the self-silencing the branch already guarded against in the other direction. Refs #254 --- .../issueflow-reconcile.sh | 64 +++++++++++++++--- test/issueflow-reconcile.test.sh | 66 ++++++++++++++++++- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 1c15f8b..40292b8 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -491,7 +491,11 @@ offsite_timeline() { # unreadable timelines are deliberately silent gh api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1 } -last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read failed +issue_activity_at() { # $1 issue, $2 created_at, $3 with-assignment|comments-only + # One body, two clocks over it — a second activity computation is the drift + # the reuse exists to prevent, and the two callers below are the whole + # difference between them. + # # Both reads are checked, and a failure reports rather than answering an age # (#247 D1). Swallowed, the comments read falls back to `created_at`, and a # `claimed` issue created months ago but commented on seconds earlier is @@ -499,19 +503,42 @@ last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read # hours of silence. `needs-triage` is cheap to remove; that is not. # gh's stderr is left to flow to this function's own, where the caller's # guarded_read captures it for the reason line. - local n="$1" created="$2" comments timeline latest + local n="$1" created="$2" mode="$3" comments timeline="" latest comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at')" \ || return 1 - # Assignment is the claim itself. Ignoring it would let an old issue be - # reclaimed in the seconds between assignment and its required draft PR. - timeline="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \ - --jq '.[] | select(.event == "assigned") | .created_at')" || return 1 + if [ "$mode" = with-assignment ]; then + timeline="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \ + --jq '.[] | select(.event == "assigned") | .created_at')" || return 1 + fi latest="$(printf '%s\n%s\n%s\n' "$created" "$comments" "$timeline" | sort | tail -n1)" date -d "$latest" +%s } +last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read failed + # The claim clock, and the ruling clock with it. Assignment is the claim + # itself. Ignoring it would let an old issue be reclaimed in the seconds + # between assignment and its required draft PR. + issue_activity_at "$1" "$2" with-assignment +} + +last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on a failed read + # The evidence nudge's clock (#254). Same computation, one input fewer, and + # the input it drops is the one that would starve the criterion: on + # `post-merge` there is no claim for an assignment to protect, and an + # assignee there is the invalid composition the `post-merge-assigned` flag + # reports. Counting it would let a broken board buy the item another 7 days + # of silence — the failure direction of #254 taken backwards. + # + # A comment the sweep itself wrote is still activity here, deliberately: + # the nudge carries no marker, so its own comment is what rate-limits it, + # and no machine comment can be exempted without exempting that one too. + # Reading authorship back into the clock would mean a body read this issue + # forbids. + issue_activity_at "$1" "$2" comments-only +} + reconcile_issue() { - local n="$1" decision refs cross_refs states age created assignees open_pr=false label owners + local n="$1" decision refs cross_refs states age evidence_age created assignees open_pr=false label owners local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" local unchecked="" remove_claimed=claimed local attention_active=true attention_suppression="" @@ -611,9 +638,26 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in # would let the assigned-flag comment silence the nudge for another 7 # days — the same self-silencing the ruling nudge avoids by reading its # facts once, at the top of the pass. + # + # Its own variable, not `age`: the ruling block below reuses `age` when + # it is already set, and the evidence clock is deliberately narrower than + # the ruling clock. Leaking it there would silently change what a ruling + # nudge means depending on which queue label the issue sits under. created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" - guarded_read age last_issue_activity "$n" "$created" \ + guarded_read evidence_age last_issue_comment_activity "$n" "$created" \ || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" + # The ruling clock is read HERE, not in the ruling block, for the same + # reason the evidence clock is: that block reads only when `age` is + # unset, and by the time it runs this branch may have posted the + # evidence nudge — so its read would date the issue by this sweep's own + # comment and silence the ruling nudge. Both waits are answered from + # facts that predate anything this pass writes. The cost is one extra + # comments read on `post-merge` + `needs-ruling`, and only there: an + # ordinary `post-merge` issue reads once. + if has_issue_label needs-ruling; then + guarded_read age last_issue_activity "$n" "$created" \ + || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" + fi if [ "$assignees" -gt 0 ] || has_issue_label attention; then ensure_comment "$n" post-merge-assigned \ 'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.' @@ -636,8 +680,8 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in # mandatory config — `load_issueflow_config` refuses to run without it — # so there is nothing to fall back to, and a silent fallback is exactly # how the wrong addressee comes back. - if [ "$(ruling_nudge_decision "$NOW" "$age")" = NUDGE ]; then - local quiet_days=$(((NOW - age) / 86400)) + if [ "$(ruling_nudge_decision "$NOW" "$evidence_age")" = NUDGE ]; then + local quiet_days=$(((NOW - evidence_age) / 86400)) run gh issue comment "$n" -R "$REPO" --body "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no activity for ${quiet_days} days: https://github.com/$REPO/issues/$n Its wake evidence is still owed. \`post-merge\` means the merge landed and diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index eec84b1..7c9fde9 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -637,6 +637,14 @@ check "...only the evidence nudge speaks" 0 "1" \ grep -c -- '^----$' "$TMP/posted-36" printf '[]\n' >"$(cfix 37)" +# The live shape of an assigned `post-merge` issue: the assignee in the issue +# payload AND the `assigned` event that put it there in the timeline. With an +# empty timeline this fixture could not see the defect it exists to guard — +# the evidence clock counting that hour-old assignment as activity and +# silencing the nudge for another 7 days, on the one board state where an +# assignee is itself the bug being reported. +jq -n --arg at "$(iso_at $((INOW - 3600)))" \ + '[{"event":"assigned","created_at":$at}]' >"$(tfix 37)" issue_probe 37 post-merge 1 >/dev/null check "assigned post-merge is flagged" 0 "" \ grep -qF '' "$TMP/posted-37" @@ -711,6 +719,40 @@ fresh="$(issue_probe 83 post-merge 0)" check "an hour-old comment does reset it" 1 "" \ grep -q 'post-merge evidence nudge' <<<"$fresh" +# Neither is an assignment. That event is the *claim* clock's activity fact — +# 48 hours of silence must not include the seconds between a claim and its +# required draft PR — and `post-merge` has no claim for it to protect: an +# assignee here is the invalid composition the flag above reports. Counting +# it would let a broken board buy the item another 7 days of quiet, which is +# this issue's failure direction taken backwards. No current assignee on this +# probe, so nothing stands between the clock rule and the nudge. +quiet_comment 93 $((8 * 86400)) +jq -n --arg at "$(iso_at $((INOW - 3600)))" \ + '[{"event":"assigned","created_at":$at},{"event":"unassigned","created_at":$at}]' >"$(tfix 93)" +assigned_clock="$(issue_probe 93 post-merge 0)" +check "an hour-old assignment does not reset the evidence clock either" 0 "" \ + grep -q 'post-merge evidence nudge' <<<"$assigned_clock" + +# The two clocks over the one computation, asserted directly rather than +# through a probe: same fixture, one input's difference, and the reclaim +# clock is pinned unmoved by the split. +jq -n --arg at "$(iso_at $((INOW - 5 * 86400)))" \ + '[{"user":{"login":"triage-one"},"created_at":$at,"html_url":"https://x/c95","body":"evidence pending"}]' \ + >"$(cfix 95)" +jq -n --arg at "$(iso_at $((INOW - 3600)))" \ + '[{"event":"assigned","created_at":$at}]' >"$(tfix 95)" +two_clocks="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; } + printf '%s %s\n' \ + "$(last_issue_activity 95 "$(iso_at $((INOW - 10 * 86400)))")" \ + "$(last_issue_comment_activity 95 "$(iso_at $((INOW - 10 * 86400)))")") )" +check "the claim clock counts the assignment, the evidence clock the comment" 0 \ + "$((INOW - 3600)) $((INOW - 5 * 86400))" printf '%s\n' "$two_clocks" +# One body, two callers: a second activity computation is the drift the +# reuse exists to prevent, so the timeline read has exactly one spelling. +check "the timeline read is not respelled for the evidence clock" 0 "1" \ + grep -c 'issues/\$n/timeline' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" + # Both waits are quiet, both are owed, and to different parties: suppressing # one because the other spoke is a starved criterion, which is the failure # this nudge exists to remove. @@ -766,7 +808,7 @@ check "the 7-day rule is not respelled in the sweep" 1 "" \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # shellcheck disable=SC2016 # the call site is asserted as a literal check "...it is reused from lib/ruling.sh" 0 "" \ - grep -qF 'ruling_nudge_decision "$NOW" "$age"' \ + grep -qF 'ruling_nudge_decision "$NOW" "$evidence_age"' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" # `post-merge` is the one queue state whose whole meaning is that the machine @@ -1188,6 +1230,28 @@ check "...no unassign, no label swap, and no reclaim comment" 0 "" \ bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \ "$claim_edits_before" "$TMP/issue-edits" "$TMP/posted-50" +# -- the quiet diagnostic: a 504 on a post-merge activity read (#254) -------- +# The guarded read is unconditional at the top of the branch, so a +# `post-merge` issue can be skipped where before this change it never could — +# and the assigned flag, which needed no read at all, goes quiet with it. +# That is #247 D1's direction (a whole pass or none of it, never a verdict +# derived from a read that did not answer) and the trade `claimed`, `blocked` +# and `needs-ruling` already make. It is still a new way for that diagnostic +# to fall silent, so it is pinned here rather than left to inspection. +jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ + '[{"user":{"login":"triage-one"},"created_at":$at,"html_url":"https://x/c94","body":"evidence pending"}]' \ + >"$(cfix 94)" +printf '%s\n' "$GH_STUB_ERROR_BODY" >"$(cfix 94).http-error" +post_merge_skip_edits="$(wc -l <"$TMP/issue-edits")" +check "a 504 on a post-merge activity read skips the issue" \ + 3 "#94: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ + issue_probe 94 post-merge 1 +check "...so neither the nudge nor the assigned flag speaks" 1 "" test -f "$TMP/posted-94" +# shellcheck disable=SC2016 # positional parameters belong to bash -c +check "...and the skipped pass edits nothing" 0 "" \ + bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ \ + "$post_merge_skip_edits" "$TMP/issue-edits" + # -- the suppressed comment: a 504 on the marker read ----------------------- # The marker is on the issue. Read as "no marker", a failed read re-posts the # comment the marker exists to suppress — every sweep, forever. From fd9da98abf2661323a38fbe5b02ca002493d13a4 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:40:48 +0000 Subject: [PATCH 072/162] docs(labels): the evidence clock names what the sweep actually reads "a comment, a review or a commit" is the ruling nudge's house phrasing and false on the issue surface twice over: there is no review or commit fact in what the sweep reads, and an assignment is no longer counted here. Say what is read, and say what does not buy another 7 days of silence. Refs #254 --- LABELS.md | 15 +++++++++------ changelog.d/254.md | 10 ++++++---- test/issueflow-reconcile.test.sh | 1 + 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/LABELS.md b/LABELS.md index 7f62a58..406455c 100644 --- a/LABELS.md +++ b/LABELS.md @@ -69,12 +69,15 @@ that the claim is released and that triage owes a follow-up naming the owner and wake condition for completion. Triage writes that full transition comment in the same tick when it or the operator makes the move by hand. The sweep never reclaims `post-merge`: weeks of quiet can be the state working. It does -make the quiet visible — after 7 days without a comment, a review or a commit -the sweep posts one nudge naming the triage actor, saying the wake evidence is -owed and linking the item. Which criterion starved is prose the machine never -judges; the link is the payload. Like the ruling nudge it carries no -idempotency marker on purpose — the comment is itself activity, so the rule -self-rate-limits to one nudge per 7 quiet days — and it writes no label. +make the quiet visible — after 7 days with no comment on the issue, the sweep +posts one nudge naming the triage actor, saying the wake evidence is owed and +linking the item. Only a comment resets that clock: label churn does not, and +neither does an assignment, which is the claim clock's fact and on this queue +state is the invalid composition flagged below. Which criterion starved is +prose the machine never judges; the link is the payload. Like the ruling nudge +it carries no idempotency marker on purpose — the comment is itself activity, +so the rule self-rate-limits to one nudge per 7 quiet days — and it writes no +label. `post-merge` never composes with `blocked`; the transition comment carries the wait. It never composes with `attention`, because releasing the claim clears diff --git a/changelog.d/254.md b/changelog.d/254.md index 4b0761e..92ca2d5 100644 --- a/changelog.d/254.md +++ b/changelog.d/254.md @@ -1,9 +1,11 @@ ### Added -- A `post-merge` item with no comment, review or commit for 7 days now draws - one nudge from the issue sweep: the wake evidence is owed. A starving - criterion used to be found only when someone happened to run the right read - (#254). +- A `post-merge` item with no comment for 7 days now draws one nudge from the + issue sweep: the wake evidence is owed. A starving criterion used to be + found only when someone happened to run the right read (#254). +- Label churn does not reset that clock, and neither does an assignment: on + `post-merge` an assignee is an invalid composition, not activity, and it + must not buy the item another 7 days of silence (#254). - The nudge names the triage actor from `triage-actors=`, not the human reviewer: `post-merge` is triage's completion queue, so the starved wake condition is triage's to answer (#254). diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 7c9fde9..a1b9486 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -749,6 +749,7 @@ check "the claim clock counts the assignment, the evidence clock the comment" 0 "$((INOW - 3600)) $((INOW - 5 * 86400))" printf '%s\n' "$two_clocks" # One body, two callers: a second activity computation is the drift the # reuse exists to prevent, so the timeline read has exactly one spelling. +# shellcheck disable=SC2016 # the read is asserted as a literal, unexpanded check "the timeline read is not respelled for the evidence clock" 0 "1" \ grep -c 'issues/\$n/timeline' \ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" From 3fffb8d84937bfce8a51d5c6f0cb41134e9843b3 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:42:19 +0000 Subject: [PATCH 073/162] fix(issueflow): the nudge says what it measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "no activity for N days" was true of the old clock and is now imprecise: an assignment no longer counts, so an item assigned yesterday would read a nudge claiming nine days of nothing. It says "no comment" instead — the fact the sweep actually read. Refs #254 --- actions/issueflow-reconcile/issueflow-reconcile.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 40292b8..ab331bd 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -682,7 +682,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in # how the wrong addressee comes back. if [ "$(ruling_nudge_decision "$NOW" "$evidence_age")" = NUDGE ]; then local quiet_days=$(((NOW - evidence_age) / 86400)) - run gh issue comment "$n" -R "$REPO" --body "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no activity for ${quiet_days} days: https://github.com/$REPO/issues/$n + run gh issue comment "$n" -R "$REPO" --body "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no comment for ${quiet_days} days: https://github.com/$REPO/issues/$n Its wake evidence is still owed. \`post-merge\` means the merge landed and triage owns completion — judge the remaining criteria against the evidence From 806e99e1cb9f2d464ef289e4c964da3c5cbe0caf Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 3 Aug 2026 23:44:43 +0000 Subject: [PATCH 074/162] docs(builder): green is read from conclusion, and the checkless head is the third ruled case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruled-term paragraph now names its field — a check carrying a terminal conclusion is green or not-green by that conclusion whatever its status reports — and rules the head with no checks configured: nothing to wait for, request straight away, no argued exception owed. The draft-round restatement comes out so the file states the rule once. Closes #260, closes #272 via the PR. Co-Authored-By: Claude Fable 5 --- BUILDER.md | 36 ++++++++++++++++++++++++++---------- changelog.d/260.md | 6 ++++++ changelog.d/272.md | 6 ++++++ 3 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 changelog.d/260.md create mode 100644 changelog.d/272.md diff --git a/BUILDER.md b/BUILDER.md index d1ed4b4..44d3316 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -252,14 +252,31 @@ CONTRIBUTING; the shared flow lives here and is not restated there.) explicitly and names the evidence (e.g. "the same job fails identically on `origin/main` at ``"). 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. + *Green* is a ruled term (operator, 2026-07-27), and it is read from + each check's **`conclusion`**, never its `status`: a check carrying a + terminal conclusion is green or not-green by that conclusion whatever + its `status` field still reports — the two can disagree, and on #259 a + finished job's `status` lagged its own `conclusion: success` at the + head. A check with no conclusion at all is neither class: a configured + run still in progress is not green, and waiting for it is compliance, + not a stall. A **cancelled or stale** check is not a green head — + *stale* means a check belonging to a superseded head, which the + head-scoped rollup does not show anyway, so what survives there is + same-head cancellation, never a same-head node whose `status` lags its + conclusion — 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. And a head + with **no checks configured** is the third ruled case, not an argued + exception: nothing is configured, so there is nothing to wait for — + the precondition is satisfied and the request goes out straight away, + no evidence or explanation owed, because the argued-exception path + above exists for a check that ran and came up red. This rules + nothing-configured, never nothing-answered-yet: a pending run has an + owner, CI, and is waited on as above. The machine partitions the same + way — `blocker:unrequested` admits the ask on `SUCCESS` and on `NONE` + alike (#236) — so doctrine and gate state one rule and each points at + the other. 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 covering every point and stating what changed and what was verified. That reply is the written round record: the engine mirrors it under the @@ -330,8 +347,7 @@ way too: `blocker:unrequested` does not fire while a head's checks are pending or red, because the one blocker that demands an act has to know when the act is permitted (#236 — crew#318 carried it at ~12:44Z on 2026-08-03 while its head's run was still in progress, which is the label flagging a builder for -obeying this section). A head with no checks configured has nothing to wait -for and is requested straight away, the same reading that sweep gives it. +obeying this section). ## The ruling ask diff --git a/changelog.d/260.md b/changelog.d/260.md new file mode 100644 index 0000000..d32662f --- /dev/null +++ b/changelog.d/260.md @@ -0,0 +1,6 @@ +### Changed + +- BUILDER.md's green ruled term now names its field: greenness is read from + each check's `conclusion`, never its `status`, and *stale* means a check + of a superseded head — not a same-head node whose `status` lags its own + conclusion (#260). diff --git a/changelog.d/272.md b/changelog.d/272.md new file mode 100644 index 0000000..adf20be --- /dev/null +++ b/changelog.d/272.md @@ -0,0 +1,6 @@ +### Changed + +- BUILDER.md's step 1 now rules the checkless head: no checks configured is + nothing to wait for, and the request goes out straight away — stated once, + in the ruled-term paragraph, with the draft-round restatement removed + (#272). From e98020b4321ef02ee4dad8d8744755cfa7dc5c42 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:57:51 +0000 Subject: [PATCH 075/162] test: document marker fixture incidents --- test/marker-check.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/marker-check.test.sh b/test/marker-check.test.sh index 0310c8c..9df9b65 100644 --- a/test/marker-check.test.sh +++ b/test/marker-check.test.sh @@ -60,6 +60,7 @@ EOF check "unrelated inline code cannot hide an uncited marker on the same line" 1 \ "docs/CONSUMERS.md:1" run_check inline-neighbor +# This release comparison would have caught all five of #221's stale markers. fixture shipped 0.6.0 printf 'The new guard remains **unreleased** (#224).\n' \ >"$TMP/shipped/docs/CONSUMERS.md" @@ -117,6 +118,7 @@ EOF check "a self-qualified citation is ignored; local markers must use bare #N" 0 \ "agree with the tree" run_check self-qualified +# CHANGELOG.md:38 on main is the live bold-token entry prose this exclusion models. fixture exclusions 0.6.0-dev cat >"$TMP/exclusions/NOTES.md" <<'EOF' # Notes From 0760d0c21f506a24084d23de53b4d11e9583dd00 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:59:18 +0000 Subject: [PATCH 076/162] feat(guards): the vendored manifest guards ceremony own tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/VENDORED.txt is already machine-authoritative on the consumer side — actions/docs-sync reads it as the sole declaration and enforces "manifest union .ceremony/, nothing else". Nothing enforced the other end: a doctrine file landing at ceremony every root with nobody adding it to the manifest is a SILENT miss, because docs-sync only asserts byte-identity for the files the manifest names. Two directions, two mechanisms (#251 D2): manifest -> tree is a scan (regular, non-empty, tracked, no symlink, no directory, no .. escape); tree -> manifest is a closed-world rule over root *.md with a short in-script exemption list, because nothing in the tree answers "which files are vendorable". Refs #251 --- .github/scripts/vendored-check.sh | 221 ++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100755 .github/scripts/vendored-check.sh diff --git a/.github/scripts/vendored-check.sh b/.github/scripts/vendored-check.sh new file mode 100755 index 0000000..126c067 --- /dev/null +++ b/.github/scripts/vendored-check.sh @@ -0,0 +1,221 @@ +#!/usr/bin/env bash +# The vendored-manifest self-guard (issue #251; #248's near-miss). Consumers +# mirror the agent-facing doc set declared by docs/VENDORED.txt, and +# actions/docs-sync already enforces "manifest ∪ .ceremony/, nothing else" +# on the CONSUMER side. Nothing enforced the other end: a new doctrine file +# could land at ceremony's root and nobody add it to the manifest, and the +# miss is SILENT — docs-sync asserts byte-identity for the files the +# manifest names, so a doc it omits is never checked and every consumer +# drifts doctrine-blind with green guards. #248 (RELEASES.md) nearly shipped +# that way, caught only by a hand-written `grep -Fx RELEASES.md` row in +# test/docs-sync.test.sh — the hardcoded list this guard abolishes, one +# layer down. That row is deleted; this script carries its intent. +# +# Two directions, two mechanisms, because only one of them can be a scan +# (#251 D2): +# +# * MANIFEST → TREE is a scan: every entry resolves to a regular, +# non-empty, tracked file at the declared path — no symlink (PR #43: +# a symlink read as doctrine while staying invisible), no directory, +# no `../` escape. +# * TREE → MANIFEST cannot scan, because nothing in the tree answers +# "which files are vendorable" — the manifest is the only +# machine-readable notion of it. So it gets a CLOSED-WORLD RULE +# instead: every `*.md` at the repository ROOT is either in the +# manifest or in the exemption list below. Adding a root doc then +# forces a one-line decision — vendor it or exempt it — and the +# refusal names the file and both fixes. +# +# The rule is ROOT-LEVEL `*.md` ONLY. It does not walk docs/, actions/ or +# drills/: those hold no agent-facing doctrine, and a recursive version +# would grow the exemption list past the length at which a reviewer still +# reads it — which is the failure this rule is shaped against. +# +# The exemption list lives HERE, in the script. CONTRIBUTING.md's +# vendored-set sentence is documentation, never an input: two declarations +# of the same set is the drift the manifest exists to prevent. +# +# Usage: vendored-check.sh [tree-dir] (default: the repo root — the CI +# step; tests point it at fixture trees) +set -euo pipefail + +MANIFEST="docs/VENDORED.txt" + +tree="${1:-.}" + +# The root docs that are deliberately ceremony-only. Each carries the reason +# it is not vendored, because the reason is what lets the next reviewer +# judge the next addition. Prints the reason and returns 0 when exempt. +exempt_reason() { + case "$1" in + README.md) + echo "ceremony's own front page — a consumer's router is AGENTS.md, not this repo's README" + ;; + CONTRIBUTING.md) + echo "repo-specific facts (this repo's roster, scopes and conventions); every governed repo writes its own" + ;; + CHANGELOG.md) + echo "ceremony's own release history; a consumer keeps its own" + ;; + FLEET.md) + echo "the operator's fleet map — about running the fleet, not about how a governed repo works" + ;; + *) return 1 ;; + esac +} + +die() { + printf 'vendored-check: %s\n' "$@" >&2 + exit 1 +} + +manifest_file="$tree/$MANIFEST" +[ -f "$manifest_file" ] || die \ + "no $MANIFEST under $tree — the manifest is the sole declaration of the" \ + " vendored doc set, and this guard has nothing to guard without it." + +# Blank lines are skipped, exactly as actions/docs-sync reads it: the guard +# and the tool must accept the same file, or one of them is the bug. +mapfile -t manifest < <(grep -v '^[[:space:]]*$' "$manifest_file" || true) +[ "${#manifest[@]}" -gt 0 ] || die \ + "$MANIFEST is empty — an empty doctrine set is a ceremony bug, not a repo" \ + " with no rules." + +# Whether the tracked-file assertion can bind: only when the tree IS a git +# work tree root. Fixture trees are plain directories, and asserting +# tracked-ness against an enclosing repository would be asserting about the +# wrong tree. +tracked_check=no +if command -v git >/dev/null 2>&1; then + toplevel="$(git -C "$tree" rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$toplevel" ] && [ "$toplevel" = "$(cd "$tree" && pwd -P)" ]; then + tracked_check=yes + fi +fi + +# Every refusal is collected and reported together, one multi-line string +# per offending file: a guard that stops at the first problem makes a +# builder pay one CI round per file. +problems=() + +# --- manifest → tree --------------------------------------------------------- + +for entry in "${manifest[@]}"; do + case "$entry" in + /* | *..*) + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry' — an absolute path or a '..' escape. The" \ + " mirror writes only inside a consumer's .ceremony/, so a path that" \ + " leaves it is never vendorable." \ + " Fix: name the path relative to the repository root, with no '..'." + )") + continue + ;; + esac + + path="$tree/$entry" + + # -L before -f: `[ -f ]` follows the link, so a symlink to a real file + # would otherwise pass as a regular one. + if [ -L "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is a SYMLINK. A symlink vendors as" \ + " doctrine while its content lives somewhere the mirror never checks" \ + " (PR #43's round: it read as doctrine and stayed invisible)." \ + " Fix: make '$entry' a regular file, or drop the entry from $MANIFEST." + )") + continue + fi + if [ -d "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is a DIRECTORY. The manifest declares" \ + " files, one per line — a directory entry vendors nothing." \ + " Fix: name each file under '$entry' on its own line, or drop the entry." + )") + continue + fi + if [ ! -f "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry' but the tree has no such file. Every consumer" \ + " mirroring this ref would fail on it." \ + " Fix: add '$entry' to the tree, or remove it from $MANIFEST." + )") + continue + fi + if [ ! -s "$path" ]; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is EMPTY. An empty file vendors as" \ + " doctrine that says nothing, and is read as doctrine anyway." \ + " Fix: write '$entry', or remove it from $MANIFEST." + )") + continue + fi + if [ "$tracked_check" = yes ] && + ! git -C "$tree" ls-files --error-unmatch -- "$entry" >/dev/null 2>&1; then + problems+=("$( + printf '%s\n' \ + "$MANIFEST names '$entry', which is not TRACKED. A file absent from the" \ + " tag's tree cannot be fetched by a consumer syncing at that tag," \ + " however present it is on this machine." \ + " Fix: git add '$entry', or remove it from $MANIFEST." + )") + continue + fi +done + +# --- tree → manifest: the closed world over root `*.md` ---------------------- + +in_manifest() { + local p + for p in "${manifest[@]}"; do + [ "$p" = "$1" ] && return 0 + done + return 1 +} + +shopt -s nullglob +exempted=() +vendored=() +for path in "$tree"/*.md; do + doc="${path##*/}" + if in_manifest "$doc"; then + vendored+=("$doc") + continue + fi + if reason="$(exempt_reason "$doc")"; then + exempted+=("$doc — $reason") + continue + fi + problems+=("$( + printf '%s\n' \ + "'$doc' is a root doc in NEITHER list. Every root *.md is either vendored" \ + " doctrine — mirrored into every governed repo at .ceremony/ — or" \ + " deliberately ceremony-only, and nothing in the tree says which, so the" \ + " decision has to be written down. Fix, one of:" \ + " * add '$doc' to $MANIFEST, if it is agent-facing doctrine that every" \ + " governed repo must carry;" \ + " * add '$doc' to the exemption list in" \ + " .github/scripts/vendored-check.sh, with the reason it stays" \ + " ceremony-only." + )") +done +shopt -u nullglob + +if [ "${#problems[@]}" -gt 0 ]; then + { + printf 'vendored-check: %d problem(s) — docs/VENDORED.txt and the tree disagree.\n\n' \ + "${#problems[@]}" + printf '%s\n\n' "${problems[@]}" + } >&2 + exit 1 +fi + +printf 'vendored-check: %d manifest entries resolve; %d root docs vendored, %d exempt.\n' \ + "${#manifest[@]}" "${#vendored[@]}" "${#exempted[@]}" +[ "${#vendored[@]}" -eq 0 ] || printf ' vendored: %s\n' "${vendored[@]}" +[ "${#exempted[@]}" -eq 0 ] || printf ' exempt: %s\n' "${exempted[@]}" From 5677710b2c806882a573c55f61495c6006c42ee5 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:01:39 +0000 Subject: [PATCH 077/162] test(guards): the manifest guard drives the whole test plan One CI step beside the self-ref pin, and test/vendored.test.sh covering both directions: the manifest -> tree scan (missing, symlink, directory, empty, ../ escape, absolute, untracked) and the closed-world root rule (neither list, vendored, exempted, prose is not an input, no recursion below the root), plus the real tree unmodified and the RELEASES.md regression both ways. The one-off `grep -Fx RELEASES.md` row at test/docs-sync.test.sh is deleted (#251 D4): two spellings of "the manifest is right" is the drift the manifest exists to prevent. Its intent is now a guard case, which the next doctrine file inherits for free. Refs #251 --- .github/workflows/ci.yml | 5 + test/docs-sync.test.sh | 12 ++- test/vendored.test.sh | 228 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 test/vendored.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c17c424..e082bb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,11 @@ jobs: # Five stale markers survived the tags that shipped their machinery # (#221); #238 makes the release candidate reject that drift. run: bash .github/scripts/marker-check.sh + - name: Vendored manifest + # The manifest rules (issue #251; #248's near-miss): a doctrine file + # at the root that nobody added to docs/VENDORED.txt is invisible to + # every consumer's docs-sync, so it fails CI here instead. + run: bash .github/scripts/vendored-check.sh - name: Tests env: # The npm-backed version_write case may skip locally when npm is diff --git a/test/docs-sync.test.sh b/test/docs-sync.test.sh index f5ecae9..ff0f5b0 100644 --- a/test/docs-sync.test.sh +++ b/test/docs-sync.test.sh @@ -18,11 +18,13 @@ SCRIPT="$ROOT/actions/docs-sync/docs-sync.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT -# RELEASES.md's consumer-availability promise is true only when the real -# manifest carries it (#248's review round). The fixture cases below prove -# manifest-driven behavior; this row binds that behavior to the promised file. -check "real manifest includes the release doctrine" 0 "RELEASES.md" \ - grep -Fx RELEASES.md "$ROOT/docs/VENDORED.txt" +# The real manifest is asserted by test/vendored.test.sh, not here (#251 D4). +# A `grep -Fx RELEASES.md` row lived at this spot from #248's review round, +# binding the promise to the one file that had nearly been missed. It was the +# hardcoded list the manifest exists to abolish, one layer down: two spellings +# of "the manifest is right" is exactly the drift it prevents. Its intent — +# every root doctrine file is declared, RELEASES.md included — is now a +# closed-world guard case, which the next file inherits for free. # --- fixture builders -------------------------------------------------------- diff --git a/test/vendored.test.sh b/test/vendored.test.sh new file mode 100644 index 0000000..e8030a4 --- /dev/null +++ b/test/vendored.test.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# Contract tests for .github/scripts/vendored-check.sh (issue #251) — the +# self-guard that makes docs/VENDORED.txt authoritative over ceremony's OWN +# tree. Driven against constructed fixture trees plus the real one; the CI +# step runs the same script against the real tree. +# +# The fixture doc set is deliberately NOT ceremony's real six: a guard that +# hardcodes the vendored list instead of reading the manifest fails these +# rows, which is the failure the whole issue is about. +# +# set -u, not -e: failing commands are behavior for the harness to inspect. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +. "$ROOT/test/harness.sh" + +CHECK="$ROOT/.github/scripts/vendored-check.sh" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# --- fixture builders -------------------------------------------------------- + +# tree — a fixture tree carrying only the manifest. +tree() { + local dir="$TMP/$1" + shift + rm -rf "$dir" + mkdir -p "$dir/docs" + printf '%s\n' "$@" >"$dir/docs/VENDORED.txt" +} + +# doc [content] — a regular file in a fixture tree. +doc() { + local path="$TMP/$1/$2" + mkdir -p "$(dirname "$path")" + printf '%s\n' "${3:-# a doc}" >"$path" +} + +# real_copy — the real tree reduced to what the guard reads: the +# manifest, every path the manifest names, and every root *.md. Cases mutate +# the copy, so the guard's verdict on ceremony's actual doc set is proven +# without touching the working tree. +real_copy() { + local dir="$TMP/$1" entry + rm -rf "$dir" + mkdir -p "$dir/docs" + cp "$ROOT/docs/VENDORED.txt" "$dir/docs/VENDORED.txt" + cp "$ROOT"/*.md "$dir/" + while IFS= read -r entry; do + [ -n "$entry" ] || continue + mkdir -p "$dir/$(dirname "$entry")" + cp "$ROOT/$entry" "$dir/$entry" + done <"$ROOT/docs/VENDORED.txt" +} + +run_check() { + bash "$CHECK" "$TMP/$1" +} + +# --- the happy tree ---------------------------------------------------------- + +# One manifest entry lives in a subdirectory: the manifest is PATHS, not +# filenames (docs-sync's fixtures prove the same), and the closed-world rule +# over the root must not regress that to root-only. +tree ok AGENTS.md RULES.md guide/DEEP.md +doc ok AGENTS.md +doc ok RULES.md +doc ok guide/DEEP.md +check "a tree whose root docs are all declared passes" 0 "3 manifest entries resolve" \ + run_check ok + +tree blanks AGENTS.md '' RULES.md +doc blanks AGENTS.md +doc blanks RULES.md +check "blank manifest lines are skipped, as docs-sync skips them" 0 "2 manifest entries" \ + run_check blanks + +# --- the closed world: a root doc in neither list ---------------------------- + +# The #248 near-miss, replayed as a test: a new doctrine file lands at the +# root and nobody adds it to the manifest. +tree newdoc AGENTS.md +doc newdoc AGENTS.md +doc newdoc NEWDOC.md +check "a root doc in neither list reds" 1 "'NEWDOC.md' is a root doc in NEITHER list" \ + run_check newdoc +check "...and the refusal names the manifest fix" 1 "add 'NEWDOC.md' to docs/VENDORED.txt" \ + run_check newdoc +check "...and the refusal names the exemption fix" 1 "add 'NEWDOC.md' to the exemption list" \ + run_check newdoc + +# The decision the guard forces, taken each way: vendor it… +tree newdoc-vendored AGENTS.md NEWDOC.md +doc newdoc-vendored AGENTS.md +doc newdoc-vendored NEWDOC.md +check "a root doc added to the manifest passes" 0 "2 manifest entries" \ + run_check newdoc-vendored + +# …or exempt it. The exemption list is in the script and carries a reason; +# README.md is one of the four ceremony-only root docs it names. +tree exempted AGENTS.md +doc exempted AGENTS.md +doc exempted README.md +check "a root doc on the exemption list passes, with its reason" 0 "exempt: README.md" \ + run_check exempted + +# The exemption list is NEVER read from prose. CONTRIBUTING.md's vendored-set +# sentence is documentation; two declarations of the same set is the drift +# the manifest exists to prevent (#251 D2's second must-fail). +tree prose AGENTS.md +doc prose AGENTS.md +doc prose EXTRA.md +doc prose CONTRIBUTING.md "The vendored set is AGENTS.md and EXTRA.md." +check "a doc declared only in prose still reds" 1 "'EXTRA.md' is a root doc in NEITHER list" \ + run_check prose + +# --- the rule is ROOT-level only --------------------------------------------- + +# A guard that walked the tree would need an exemption list long enough that +# nobody reads it — the exact failure the root-only rule is shaped against. +# So an undeclared *.md under docs/, actions/ or drills/ must stay GREEN. +tree subdirs AGENTS.md +doc subdirs AGENTS.md +doc subdirs docs/CONSUMERS.md +doc subdirs actions/thing/README.md +doc subdirs drills/2026-07-01.md +check "undeclared *.md below the root stays green (no recursion)" 0 "1 manifest entries" \ + run_check subdirs + +# --- manifest → tree: the scan ----------------------------------------------- + +tree missing AGENTS.md GONE.md +doc missing AGENTS.md +check "a manifest entry with no file reds, naming it" 1 "names 'GONE.md' but the tree has no such file" \ + run_check missing + +tree symlinked AGENTS.md LINK.md +doc symlinked AGENTS.md +ln -s AGENTS.md "$TMP/symlinked/LINK.md" +check "a manifest entry pointing at a symlink reds" 1 "names 'LINK.md', which is a SYMLINK" \ + run_check symlinked + +tree dir-entry AGENTS.md guide +doc dir-entry AGENTS.md +mkdir -p "$TMP/dir-entry/guide" +check "a manifest entry pointing at a directory reds" 1 "names 'guide', which is a DIRECTORY" \ + run_check dir-entry + +tree empty-entry AGENTS.md HOLLOW.md +doc empty-entry AGENTS.md +: >"$TMP/empty-entry/HOLLOW.md" +check "a manifest entry pointing at an empty file reds" 1 "names 'HOLLOW.md', which is EMPTY" \ + run_check empty-entry + +# The escape case exists as a docs-sync fixture; ceremony's own manifest must +# not be the one place it goes unchecked. +tree escape AGENTS.md ../outside.md +doc escape AGENTS.md +check "a manifest entry escaping with ../ reds" 1 "names '../outside.md'" \ + run_check escape + +tree absolute AGENTS.md /etc/hosts +doc absolute AGENTS.md +check "an absolute manifest entry reds" 1 "names '/etc/hosts'" \ + run_check absolute + +# --- the manifest itself ----------------------------------------------------- + +rm -rf "$TMP/no-manifest" +mkdir -p "$TMP/no-manifest" +check "a tree with no manifest reds" 1 "no docs/VENDORED.txt under" \ + run_check no-manifest + +rm -rf "$TMP/empty-manifest" +mkdir -p "$TMP/empty-manifest/docs" +: >"$TMP/empty-manifest/docs/VENDORED.txt" +check "an empty manifest reds" 1 "is empty" run_check empty-manifest + +# --- tracked-ness ------------------------------------------------------------ + +# A file present on this machine but absent from the tag's tree cannot be +# fetched by a consumer syncing at that tag. The assertion binds only where +# it can: when the tree IS a git work tree root. +tree tracked AGENTS.md RULES.md +doc tracked AGENTS.md +doc tracked RULES.md +git init -q "$TMP/tracked" +git -C "$TMP/tracked" add docs/VENDORED.txt AGENTS.md RULES.md +check "a git tree whose manifest entries are all tracked passes" 0 "2 manifest entries" \ + run_check tracked + +tree untracked AGENTS.md RULES.md +doc untracked AGENTS.md +doc untracked RULES.md +git init -q "$TMP/untracked" +git -C "$TMP/untracked" add docs/VENDORED.txt AGENTS.md +check "a git tree with an untracked manifest entry reds" 1 "names 'RULES.md', which is not TRACKED" \ + run_check untracked + +# --- the real tree ----------------------------------------------------------- + +check "this tree, unmodified, is green" 0 "manifest entries resolve" bash "$CHECK" "$ROOT" + +# The #248 near-miss on the REAL doc set: a scratch root doc nobody declared. +real_copy scratch +doc scratch SCRATCHDOC.md +check "a scratch root doc on the real tree reds, naming it" 1 "'SCRATCHDOC.md' is a root doc in NEITHER list" \ + run_check scratch + +# RELEASES.md stays listed — the regression criterion #248's review round +# bought, now asserted BY THE GUARD rather than by a hardcoded `grep -Fx` row +# in test/docs-sync.test.sh (#251 D1, D4). The closed world holds in both +# directions: dropping it from the manifest alone reds… +real_copy releases-dropped +grep -v '^RELEASES\.md$' "$ROOT/docs/VENDORED.txt" >"$TMP/releases-dropped/docs/VENDORED.txt" +check "dropping RELEASES.md from the manifest alone reds" 1 "'RELEASES.md' is a root doc in NEITHER list" \ + run_check releases-dropped + +# …and it is green only when the file leaves the root in the same breath. +real_copy releases-gone +grep -v '^RELEASES\.md$' "$ROOT/docs/VENDORED.txt" >"$TMP/releases-gone/docs/VENDORED.txt" +rm -f "$TMP/releases-gone/RELEASES.md" +check "dropping RELEASES.md from the manifest AND the root is green" 0 "manifest entries resolve" \ + run_check releases-gone + +summary From 6949f8cbdcb881fc305e29544831d7fdb83a171c Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:17:30 +0000 Subject: [PATCH 078/162] docs: define doctrine conventions --- CONTRIBUTING.md | 22 ++++++++++++++++++++++ changelog.d/280.md | 5 +++++ 2 files changed, 27 insertions(+) create mode 100644 changelog.d/280.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c8a7b7..92985a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,28 @@ table repeats it (#104). - Whole-version matching everywhere: `0.7.0` never matches `0.7.0-rc1`. - Shellcheck- and actionlint-clean is a CI gate, not a suggestion. +## Doctrine conventions + +The vendored role files — `AGENTS.md`, `TRIAGE.md`, `BUILDER.md`, +`REVIEWER.md`, `LABELS.md`, and `RELEASES.md` — state each normative rule +completely, keep at most one sentence of why, and cite its record only with a +bare parenthetical such as `(#N)`, `(#N D3)`, or `(#N, #M)`. Incident +narrative — timestamps, actors, quoted comments, measured counts, and links to +specific comments — belongs in that record. If a rule cannot be followed +without chasing its cite, the rule is under-stated: fix the statement, not the +citation. (#280) + +Normative text in those files does not cite issues from other repositories. +Consumers read the vendored bytes outside this organization's context, and a +cited repository may not be public. A repo-boundary deferral remains allowed: +it names another component as the owner of a fact rather than citing one of +that component's issues. (#280) + +This is distinct from the code-comment convention above: a code comment is +read by a maintainer inside the organization while standing in the file, +whereas vendored doctrine is read by any agent in any governed repository on +every session. (#280) + ## How the other repos use this Two consumption modes, split by what has a runtime: diff --git a/changelog.d/280.md b/changelog.d/280.md new file mode 100644 index 0000000..d4f1b2d --- /dev/null +++ b/changelog.d/280.md @@ -0,0 +1,5 @@ +### Changed + +- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule, + retain at most one sentence of why, cite the local record bare, and leave the + incident narrative in that record. (#280) From 7909383ca0564462f81fba68b065c0a415231df3 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:04:02 +0000 Subject: [PATCH 079/162] docs(consumers): read the pin manifest, never a copy of it Re-vendor tooling and docs-sync equivalents read the pin docs/VENDORED.txt (available at 0.5.0 and later) instead of naming the doc set themselves, so a new doctrine file reaches every consumer at its next ordinary pin bump with zero list edits. A hardcoded list propagates nothing and its staleness is silent: docs-sync --check asserts byte-identity for the files the list names and says nothing about one it omits. What makes reading the manifest sufficient rather than merely better is the self-guard this PR adds, tagged unreleased until the first tag carries it, per the RELEASES.md paragraph above it. Refs #251 --- changelog.d/251.md | 14 ++++++++++++++ docs/CONSUMERS.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 changelog.d/251.md diff --git a/changelog.d/251.md b/changelog.d/251.md new file mode 100644 index 0000000..7853b80 --- /dev/null +++ b/changelog.d/251.md @@ -0,0 +1,14 @@ +### Added + +- CI now refuses a root `*.md` declared in neither `docs/VENDORED.txt` nor the + guard's short exemption list, so a new doctrine file can no longer reach a + tag undeclared and stay invisible to every consumer's `docs-sync` (#251). +- The same guard reads the manifest the other way: every entry must resolve to + a regular, non-empty, tracked file — no symlink, no directory, no `../` + escape (#251). + +### Changed + +- Consumer guidance: re-vendor tooling reads the pin's `docs/VENDORED.txt`, + never a hardcoded list, so a new doctrine file propagates at the next + ordinary pin bump with zero list edits (#251). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 413385f..b684e12 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -598,6 +598,37 @@ It is **unreleased** (#248) until that tag exists: consumers add `.ceremony/RELEASES.md` only with the ordinary pin bump and re-sync, never by copying it ahead of their pinned doctrine set. +### Read the manifest, never a copy of it + +Anything on the consumer's side that needs to know *which* documents are +vendored — a re-vendor script, a `docs-sync` equivalent, the task list of a +conversion issue — reads **the pin's `docs/VENDORED.txt`** and never names +the files itself. The manifest is available at the pinned ref from `0.5.0` +and later (ceremony#251); it is one path per line, relative to ceremony's +root, and blank lines are ignored: + +```sh +# the vendored doc set at the ref this repo is pinned to +curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony//docs/VENDORED.txt" +``` + +That is the whole benefit: a doctrine file added in ceremony — `RELEASES.md` +was the last, ceremony#248 — reaches every consumer at its next **ordinary +pin bump**, with **zero list edits** anywhere. A hardcoded list propagates +nothing, and its staleness is silent rather than red: `docs-sync --check` +asserts byte-identity for the files the list names and says nothing at all +about one it omits, so a consumer keeps a green guard while governing +itself with doctrine it no longer has. + +What makes reading the manifest *sufficient* — rather than merely better +than a copy — is that ceremony's CI now refuses a root doctrine file that is +declared in neither the manifest nor a short in-script exemption list +(`.github/scripts/vendored-check.sh`), so the manifest at a tag is the +complete set as of that tag. That guarantee is **unreleased** (#251) until +the first tag carrying it exists; the manifest is worth reading at every +earlier pin regardless, since it is what `actions/docs-sync` has always +mirrored. + The consumer's ci.yml gains the guard alongside the others: ```yaml From b9eedd8973f764afc91326fdc475c23cdea12e82 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:05:04 +0000 Subject: [PATCH 080/162] docs(consumers): cite the manifest availability to the tag that shipped it 0.5.0 availability is actions/docs-sync own arrival (#19); #251 is the guidance and the guarantee, not the file date. Refs #251 --- docs/CONSUMERS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index b684e12..17cbc13 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -604,8 +604,8 @@ Anything on the consumer's side that needs to know *which* documents are vendored — a re-vendor script, a `docs-sync` equivalent, the task list of a conversion issue — reads **the pin's `docs/VENDORED.txt`** and never names the files itself. The manifest is available at the pinned ref from `0.5.0` -and later (ceremony#251); it is one path per line, relative to ceremony's -root, and blank lines are ignored: +and later — it shipped with `actions/docs-sync` itself (ceremony#19) — and +it is one path per line, relative to ceremony's root, blank lines ignored: ```sh # the vendored doc set at the ref this repo is pinned to From 42b8fb685061add49c55b5edadcd03fcc3819995 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:29:44 +0000 Subject: [PATCH 081/162] fix(consumers): the manifest is readable at 0.1.0, not 0.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/VENDORED.txt and actions/docs-sync/docs-sync.sh entered the tree in the same commit and are byte-identical at every tag — blobs 10c20a3c and ba426479 at 0.1.0 through 0.5.0 — and 0.1.0's copy of the tool is already manifest-driven (MANIFEST="docs/VENDORED.txt", L75). Citing 0.5.0 told the 0.1.0-0.4.1 tail, which is exactly the population this section is written for, that the manifest was unavailable at its pin, so it would keep the hardcoded list: #251's failure mode reproduced by the document that exists to abolish it. The same file already said 0.1.0 at L131-L133. Also make the guard's tracked-ness skip announce itself. It degrades to "not asserted" wherever the tree is not a git work tree root, and doing that in silence is the shape this script's own header argues against, so the skip now prints on both output paths, green and red, with a test row each way. Round 1: claude blocking point, and claude nit 3. --- .github/scripts/vendored-check.sh | 13 +++++++++++++ docs/CONSUMERS.md | 7 ++++--- test/vendored.test.sh | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/scripts/vendored-check.sh b/.github/scripts/vendored-check.sh index 126c067..929e4fe 100755 --- a/.github/scripts/vendored-check.sh +++ b/.github/scripts/vendored-check.sh @@ -85,11 +85,22 @@ mapfile -t manifest < <(grep -v '^[[:space:]]*$' "$manifest_file" || true) # work tree root. Fixture trees are plain directories, and asserting # tracked-ness against an enclosing repository would be asserting about the # wrong tree. +# +# When it cannot bind, SAY SO. This guard's whole argument is that a silent +# miss is worse than a loud one, and a guard that quietly stops asserting one +# of its four properties is exactly that shape — so the skip is announced on +# every run, green or red, rather than inferred from the absence of a +# refusal (#251 round 1). tracked_check=no +tracked_note="tracked-ness NOT asserted: $tree is not a git work tree root, so + 'is this file in the tag's tree' cannot be answered about THIS tree. The + other three manifest assertions (regular file, non-empty, no + symlink/dir/escape) still bind." if command -v git >/dev/null 2>&1; then toplevel="$(git -C "$tree" rev-parse --show-toplevel 2>/dev/null || true)" if [ -n "$toplevel" ] && [ "$toplevel" = "$(cd "$tree" && pwd -P)" ]; then tracked_check=yes + tracked_note="" fi fi @@ -211,11 +222,13 @@ if [ "${#problems[@]}" -gt 0 ]; then printf 'vendored-check: %d problem(s) — docs/VENDORED.txt and the tree disagree.\n\n' \ "${#problems[@]}" printf '%s\n\n' "${problems[@]}" + [ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note" } >&2 exit 1 fi printf 'vendored-check: %d manifest entries resolve; %d root docs vendored, %d exempt.\n' \ "${#manifest[@]}" "${#vendored[@]}" "${#exempted[@]}" +[ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note" [ "${#vendored[@]}" -eq 0 ] || printf ' vendored: %s\n' "${vendored[@]}" [ "${#exempted[@]}" -eq 0 ] || printf ' exempt: %s\n' "${exempted[@]}" diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 17cbc13..bd68963 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -603,9 +603,10 @@ copying it ahead of their pinned doctrine set. Anything on the consumer's side that needs to know *which* documents are vendored — a re-vendor script, a `docs-sync` equivalent, the task list of a conversion issue — reads **the pin's `docs/VENDORED.txt`** and never names -the files itself. The manifest is available at the pinned ref from `0.5.0` -and later — it shipped with `actions/docs-sync` itself (ceremony#19) — and -it is one path per line, relative to ceremony's root, blank lines ignored: +the files itself. The manifest is available at the pinned ref from `0.1.0` +and later — it shipped with `actions/docs-sync` itself (ceremony#19), in the +same commit, and that tool has read it rather than a list since — and it is +one path per line, relative to ceremony's root, blank lines ignored: ```sh # the vendored doc set at the ref this repo is pinned to diff --git a/test/vendored.test.sh b/test/vendored.test.sh index e8030a4..50ee65d 100644 --- a/test/vendored.test.sh +++ b/test/vendored.test.sh @@ -199,6 +199,21 @@ git -C "$TMP/untracked" add docs/VENDORED.txt AGENTS.md check "a git tree with an untracked manifest entry reds" 1 "names 'RULES.md', which is not TRACKED" \ run_check untracked +# ...and where it cannot bind, the skip ANNOUNCES ITSELF rather than being +# inferred from the absence of a refusal (#251 round 1). A guard that quietly +# stops asserting one of its four properties is the silent miss this whole +# script argues against, so the degradation is visible on both output paths. +check "a non-git tree says tracked-ness was not asserted" 0 "tracked-ness NOT asserted" \ + run_check ok +check "...and says it on the red path too, beside the refusals" 1 "tracked-ness NOT asserted" \ + run_check newdoc + +# The converse, so the note is not simply always printed: where the tree IS a +# git work tree root the assertion bound, and nothing is announced. +no_skip_note() { ! run_check tracked 2>&1 | grep -qF "tracked-ness NOT asserted"; } +check "a git work tree root announces no skip — the assertion bound" 0 "" \ + no_skip_note + # --- the real tree ----------------------------------------------------------- check "this tree, unmodified, is green" 0 "manifest entries resolve" bash "$CHECK" "$ROOT" From 0b5185aa285cc59ad7f43895719fab4c48486e6a Mon Sep 17 00:00:00 2001 From: Daniel Marin Date: Tue, 4 Aug 2026 11:33:16 +0100 Subject: [PATCH 082/162] Update labels.conf --- .github/labels.conf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/labels.conf b/.github/labels.conf index 3a779e4..bfcb03f 100644 --- a/.github/labels.conf +++ b/.github/labels.conf @@ -1,4 +1,6 @@ panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl +panel[cndgrr]=codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl +panel[andriujoseba]=claude-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl triage-actors=dan-claude-bot scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded From 57ff445341c8b662a7ae907841ff888c71956afa Mon Sep 17 00:00:00 2001 From: Daniel Marin Date: Tue, 4 Aug 2026 11:38:54 +0100 Subject: [PATCH 083/162] Update labels.conf --- .github/labels.conf | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/labels.conf b/.github/labels.conf index bfcb03f..3a779e4 100644 --- a/.github/labels.conf +++ b/.github/labels.conf @@ -1,6 +1,4 @@ panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl -panel[cndgrr]=codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl -panel[andriujoseba]=claude-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl triage-actors=dan-claude-bot scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded From e3b4b2cdfda6f2a5b4562f08fd7108a5831e1ad5 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:48:55 +0000 Subject: [PATCH 084/162] feat: announce release initialization --- .../issueflow-reconcile.sh | 18 ++++++ changelog.d/253.md | 3 + test/issueflow-reconcile.test.sh | 63 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 changelog.d/253.md diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index ab331bd..b936f97 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -746,6 +746,24 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null log "#$n: blockers closed -> ready" ;; esac elif has_issue_label epic; then + if has_issue_label release; then + refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" + cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" + states="$(reference_states <<<"$refs")" + if [ "$(blocked_decision "$refs" "$states" "$cross_refs")" = READY ]; then + ensure_comment "$n" release-init-due \ + "This release epic's declared gate is open. Release initialization is due: + +1. Mint the window's members. +2. Graph hard dependencies and same-file clusters. +3. Write ordered waves and the progress task list. +4. Ask the operator to bless the order, then open the first wave. +5. Ship the release, close this epic, and trigger the next window. + +See [\`RELEASES.md\`](https://github.com/$REPO/blob/main/RELEASES.md). The operator blessing the order is the one step this chain never automates." + log "#$n: release-init due" + fi + fi refs="$(epic_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" states="$(reference_states <<<"$refs")" if [ "$(epic_decision "$refs" "$states")" = NUDGE ]; then diff --git a/changelog.d/253.md b/changelog.d/253.md new file mode 100644 index 0000000..3bb9f9c --- /dev/null +++ b/changelog.d/253.md @@ -0,0 +1,3 @@ +### Added + +- Release epics now announce release initialization when their declared dependency gates clear. (#253) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index a1b9486..fafa598 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -466,6 +466,69 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 false|closing|refs, $5 m tfix() { printf '%s/repos_owner_repo_issues_%s_timeline.json' "$TMP" "$1"; } cfix() { printf '%s/repos_owner_repo_issues_%s_comments.json' "$TMP" "$1"; } +# -- release epics announce an opened declared gate, comment-only (#253) ----- +printf '{"state":"closed"}\n' >"$TMP/repos_owner_repo_issues_201.json" +printf '{"state":"closed"}\n' >"$TMP/repos_owner_repo_issues_202.json" +printf '{"state":"open"}\n' >"$TMP/repos_owner_repo_issues_203.json" +printf '[]\n' >"$(cfix 53)" +: >"$TMP/issue-edits" +release_init_edits_before="$(wc -l <"$TMP/issue-edits")" +release_init_body=$'Blocked by #201, #202.\n\n## Task list\n- [ ] #203 later work' +release_init="$(issue_probe 53 $'epic\nrelease' 0 false "" "$release_init_body")" +check "a release epic with every declared blocker closed announces init" 0 "" \ + grep -qF '' "$TMP/posted-53" +check "the init announce names all five steps" 0 "5" \ + grep -cE '^[1-5]\. ' "$TMP/posted-53" +check "the init announce cites RELEASES.md" 0 "" \ + grep -qF 'RELEASES.md' "$TMP/posted-53" +check "the init announce names the never-automated operator blessing" 0 "" \ + grep -qF 'operator blessing the order is the one step this chain never automates' \ + "$TMP/posted-53" +check "the opened release gate is logged" 0 "" \ + grep -qF '#53: release-init due' <<<"$release_init" +issue_probe 53 $'epic\nrelease' 0 false "" "$release_init_body" >/dev/null +check "an unchanged opened gate announces only once" 0 "1" \ + grep -cF '' "$TMP/posted-53" + +printf '[]\n' >"$(cfix 54)" +issue_probe 54 $'epic\nrelease' 0 false "" \ + $'Blocked by #203.\n\n## Task list\n- [x] #201 complete' >/dev/null +check "an open declared blocker suppresses init despite a complete task list" 1 "" \ + grep -qF '' "$TMP/posted-54" +check "the independent epic-complete nudge still fires" 0 "" \ + grep -qF '' "$TMP/posted-54" + +printf '[]\n' >"$(cfix 55)" +issue_probe 55 $'epic\nrelease' 0 false "" \ + $'Blocked by #201.\n\n## Task list\n- [x] #202 complete' >/dev/null +check "release-init and epic-complete can coexist" 0 "2" \ + grep -c -- '^----$' "$TMP/posted-55" +check "the coexisting comments keep distinct markers" 0 "" \ + bash -c 'grep -qF "" "$1" && grep -qF "" "$1"' \ + _ "$TMP/posted-55" + +for n in 56 57 58 59; do printf '[]\n' >"$(cfix "$n")"; done +issue_probe 56 $'epic\nrelease' 0 false "" 'No dependency declaration.' >/dev/null +check "a release epic without a Blocked by declaration stays silent" 1 "" \ + test -f "$TMP/posted-56" +issue_probe 57 $'epic\nrelease' 0 false "" 'Blocked by heavy-duty/rig#9.' >/dev/null +check "a cross-repo release gate stays silent" 1 "" test -f "$TMP/posted-57" +issue_probe 58 $'epic\nrelease' 0 false "" 'Blocked by #204.' >/dev/null +check "an unreadable release gate stays silent" 1 "" test -f "$TMP/posted-58" +: >"$TMP/api-calls" +issue_probe 59 epic 0 false "" 'Blocked by #201.' >/dev/null +check "a plain epic does not parse or announce a release gate" 1 "" \ + test -f "$TMP/posted-59" +check "a plain epic pays no Blocked by reference read" 1 "" \ + grep -qF 'repos/owner/repo/issues/201' "$TMP/api-calls" + +printf '[]\n' >"$(cfix 60)" +issue_probe 60 $'ready\nrelease' 0 false "" 'Blocked by #201.' >/dev/null +check "a non-epic release issue does not announce init" 1 "" \ + test -f "$TMP/posted-60" +check "every release-init probe is comment-only" 0 "$release_init_edits_before" \ + bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" + # -- malformed attention targets are diagnosed, never repaired (#232) ------- attention_episode() { # $1 issue, $2 labeled timestamp jq -n --arg at "$2" \ From f300f60d6e9b7d3c2d650b504c92b94be50ec27c Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:50:55 +0000 Subject: [PATCH 085/162] test: keep release init probes shellcheck clean --- test/issueflow-reconcile.test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index fafa598..3097c7f 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -503,6 +503,7 @@ issue_probe 55 $'epic\nrelease' 0 false "" \ $'Blocked by #201.\n\n## Task list\n- [x] #202 complete' >/dev/null check "release-init and epic-complete can coexist" 0 "2" \ grep -c -- '^----$' "$TMP/posted-55" +# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell check "the coexisting comments keep distinct markers" 0 "" \ bash -c 'grep -qF "" "$1" && grep -qF "" "$1"' \ _ "$TMP/posted-55" @@ -526,6 +527,7 @@ printf '[]\n' >"$(cfix 60)" issue_probe 60 $'ready\nrelease' 0 false "" 'Blocked by #201.' >/dev/null check "a non-epic release issue does not announce init" 1 "" \ test -f "$TMP/posted-60" +# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell check "every release-init probe is comment-only" 0 "$release_init_edits_before" \ bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" From b2313beae87902e9e13ae88057858cc92a5f8019 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:58:59 +0000 Subject: [PATCH 086/162] docs(builder): a displaced predecessor is not the check's verdict Step 1 ruled what a rollup entry means and never said which entry to read. A job in a cancel-in-progress group displaces itself, so a head routinely carries a CANCELLED node beside the SUCCESS that replaced it, and read by class alone that head is not green while checks_state calls it SUCCESS. State the collapse ahead of the classes it feeds: newest entry by start time, and a CANCELLED entry is not the check's word while a non-cancelled sibling stands at the same head. All-cancelled and pending are untouched. Refs #276 --- BUILDER.md | 49 ++++++++++++++++++++++++++++++++++++++-------- changelog.d/276.md | 10 ++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 changelog.d/276.md diff --git a/BUILDER.md b/BUILDER.md index 44d3316..bdb7780 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -252,14 +252,47 @@ CONTRIBUTING; the shared flow lives here and is not restated there.) explicitly and names the evidence (e.g. "the same job fails identically on `origin/main` at ``"). 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), and it is read from - each check's **`conclusion`**, never its `status`: a check carrying a - terminal conclusion is green or not-green by that conclusion whatever - its `status` field still reports — the two can disagree, and on #259 a - finished job's `status` lagged its own `conclusion: success` at the - head. A check with no conclusion at all is neither class: a configured - run still in progress is not green, and waiting for it is compliance, - not a stall. A **cancelled or stale** check is not a green head — + *Green* is a ruled term (operator, 2026-07-27), and it is read in two + steps, because a head carries more rollup entries than it has checks: + first pick the entry that is a check's word at this head, then + classify that entry. **A check's word at a head is its newest entry + by start time, and a `CANCELLED` entry is not that word while the + same check carries a non-cancelled entry at the same head.** The + survivor is the verdict about these bytes; the entry it displaced + reported nothing about them. That shape is routine rather than + exotic: a job in a `cancel-in-progress` concurrency group displaces + *itself* whenever two events land inside one of its runs, so one job + appears twice on one sha — #275's head at `806e99e1` carried + `labels / scope` `CANCELLED` started at 23:45:19Z beside + `labels / scope` `SUCCESS` started at 23:45:31Z, and that head is + green, with no argued exception owed. Say **start** time and mean it: + a cancelled run does not stop the moment its replacement begins, so + the dead run's completion routinely postdates the live run's start, + and a reader who dates entries by completion picks the corpse. When + *every* entry a check has at the head is cancelled, nothing survives + to be its word: that check has not reported at all, and it stays + not-green by the classes below — the all-cancelled context is the + case this leaves exactly where it was. Nor is any of this a new + class. The 2026-07-27 gloss that what survives at a head is same-head + cancellation was written against supersession by a newer push, not + against a job displacing itself inside its own concurrency group, and + `checks_state`'s #139 carve-out has read it that way in the machine's + voice ever since — cancelled entries dropped only where the context + keeps a non-cancelled survivor, an all-cancelled context left intact + and still blocking — so doctrine and gate partition alike on a mixed + context. What the *machine* drops from the rollup before it grades + anything is a different question, and crew's to describe rather than + this file's. + Then classify that entry, and classify it from its **`conclusion`**, + never its `status`: a check carrying a terminal conclusion is green or + not-green by that conclusion whatever its `status` field still + reports — the two can disagree, and on #259 a finished job's `status` + lagged its own `conclusion: success` at the head. A check with no + conclusion at all is neither class: a configured run still in progress + is not green, and waiting for it is compliance, not a stall. Picking + the newest entry never settles a live one: where the survivor is the + run still going, the head is not green and you wait on it exactly as + you would have. A **cancelled or stale** check is not a green head — *stale* means a check belonging to a superseded head, which the head-scoped rollup does not show anyway, so what survives there is same-head cancellation, never a same-head node whose `status` lags its diff --git a/changelog.d/276.md b/changelog.d/276.md new file mode 100644 index 0000000..7d2dcdc --- /dev/null +++ b/changelog.d/276.md @@ -0,0 +1,10 @@ +### Changed + +- BUILDER.md's green ruled term now says which entry to read before it says + what an entry means: a check's word at a head is its newest entry by start + time, and a cancelled entry is not that word while the same check carries a + non-cancelled one at that head (#276). +- A check whose every entry at the head is cancelled is unchanged — nothing + survived to be its word, so it never reported and is not green — and the + collapse mirrors `checks_state`'s carve-out rather than adding a class + (#276). From e7df33b277b248da08363ff991f36bc80424260b Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:17:51 +0000 Subject: [PATCH 087/162] fix: make release init citation portable --- actions/issueflow-reconcile/issueflow-reconcile.sh | 4 ++-- test/issueflow-reconcile.test.sh | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index b936f97..27d03f8 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -746,7 +746,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null log "#$n: blockers closed -> ready" ;; esac elif has_issue_label epic; then - if has_issue_label release; then + if has_issue_label release && ! issue_comment_has_marker "$n" release-init-due; then refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" states="$(reference_states <<<"$refs")" @@ -760,7 +760,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null 4. Ask the operator to bless the order, then open the first wave. 5. Ship the release, close this epic, and trigger the next window. -See [\`RELEASES.md\`](https://github.com/$REPO/blob/main/RELEASES.md). The operator blessing the order is the one step this chain never automates." +See \`.ceremony/RELEASES.md\`. The operator blessing the order is the one step this chain never automates." log "#$n: release-init due" fi fi diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 3097c7f..3f10af3 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -479,16 +479,22 @@ check "a release epic with every declared blocker closed announces init" 0 "" \ grep -qF '' "$TMP/posted-53" check "the init announce names all five steps" 0 "5" \ grep -cE '^[1-5]\. ' "$TMP/posted-53" -check "the init announce cites RELEASES.md" 0 "" \ - grep -qF 'RELEASES.md' "$TMP/posted-53" +check "the init announce cites the portable vendored doctrine path" 0 "" \ + grep -qF 'See `.ceremony/RELEASES.md`.' "$TMP/posted-53" check "the init announce names the never-automated operator blessing" 0 "" \ grep -qF 'operator blessing the order is the one step this chain never automates' \ "$TMP/posted-53" check "the opened release gate is logged" 0 "" \ grep -qF '#53: release-init due' <<<"$release_init" +release_init_gate_reads_before_repeat="$( + grep -cE 'repos/owner/repo/issues/(201|202)$' "$TMP/api-calls" +)" issue_probe 53 $'epic\nrelease' 0 false "" "$release_init_body" >/dev/null check "an unchanged opened gate announces only once" 0 "1" \ grep -cF '' "$TMP/posted-53" +check "an announced gate does not re-read its durable blockers" 0 \ + "$release_init_gate_reads_before_repeat" \ + grep -cE 'repos/owner/repo/issues/(201|202)$' "$TMP/api-calls" printf '[]\n' >"$(cfix 54)" issue_probe 54 $'epic\nrelease' 0 false "" \ From 0bbdb2c6e2e5557b66b1d76d4ebf9ddd361611b6 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:19:59 +0000 Subject: [PATCH 088/162] test: mark portable citation literal --- test/issueflow-reconcile.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 3f10af3..e782437 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -479,6 +479,7 @@ check "a release epic with every declared blocker closed announces init" 0 "" \ grep -qF '' "$TMP/posted-53" check "the init announce names all five steps" 0 "5" \ grep -cE '^[1-5]\. ' "$TMP/posted-53" +# shellcheck disable=SC2016 # backticks are the literal portable doctrine citation check "the init announce cites the portable vendored doctrine path" 0 "" \ grep -qF 'See `.ceremony/RELEASES.md`.' "$TMP/posted-53" check "the init announce names the never-automated operator blessing" 0 "" \ From 4c8398a15dcd91f409d5fbc2ba96df4aba6ab3dc Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:29:13 +0000 Subject: [PATCH 089/162] docs(builder): the collapse rule, without the incident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 on #286: codex, claude and kimi each blocked on the same thing — the amended paragraph carried incident narrative that CONTRIBUTING.md's doctrine conventions keep in the cited record. Drop #275's head sha and both start stamps, the cancel-in-progress episode sentence, and the four clauses of 2026-07-27 gloss provenance. Carry checks_state's carve-out as one clause with a bare (#139, #276). The rule is unchanged and still decides #275's head unaided: two entries for one check, newest by start time wins, and the cancelled one is not the check's word while a non-cancelled sibling stands. --- BUILDER.md | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index bdb7780..fe2d5fb 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -259,30 +259,20 @@ CONTRIBUTING; the shared flow lives here and is not restated there.) by start time, and a `CANCELLED` entry is not that word while the same check carries a non-cancelled entry at the same head.** The survivor is the verdict about these bytes; the entry it displaced - reported nothing about them. That shape is routine rather than - exotic: a job in a `cancel-in-progress` concurrency group displaces - *itself* whenever two events land inside one of its runs, so one job - appears twice on one sha — #275's head at `806e99e1` carried - `labels / scope` `CANCELLED` started at 23:45:19Z beside - `labels / scope` `SUCCESS` started at 23:45:31Z, and that head is - green, with no argued exception owed. Say **start** time and mean it: - a cancelled run does not stop the moment its replacement begins, so - the dead run's completion routinely postdates the live run's start, - and a reader who dates entries by completion picks the corpse. When - *every* entry a check has at the head is cancelled, nothing survives - to be its word: that check has not reported at all, and it stays - not-green by the classes below — the all-cancelled context is the - case this leaves exactly where it was. Nor is any of this a new - class. The 2026-07-27 gloss that what survives at a head is same-head - cancellation was written against supersession by a newer push, not - against a job displacing itself inside its own concurrency group, and - `checks_state`'s #139 carve-out has read it that way in the machine's - voice ever since — cancelled entries dropped only where the context - keeps a non-cancelled survivor, an all-cancelled context left intact - and still blocking — so doctrine and gate partition alike on a mixed - context. What the *machine* drops from the rollup before it grades - anything is a different question, and crew's to describe rather than - this file's. + reported nothing about them. Say **start** time and mean it: a + cancelled run does not stop the moment its replacement begins, so the + dead run's completion routinely postdates the live run's start, and a + reader who dates entries by completion picks the corpse. When *every* + entry a check has at the head is cancelled, nothing survives to be + its word: that check has not reported at all, and it stays not-green + by the classes below — the all-cancelled context is the case this + leaves exactly where it was. This states a collapse and not a new + class: `checks_state`'s carve-out drops a cancelled entry only where + its context keeps a non-cancelled survivor, and leaves an + all-cancelled context intact and still blocking, so doctrine and gate + partition alike on a mixed context (#139, #276). What the *machine* + drops from the rollup before it grades anything is a different + question, and crew's to describe rather than this file's. Then classify that entry, and classify it from its **`conclusion`**, never its `status`: a check carrying a terminal conclusion is green or not-green by that conclusion whatever its `status` field still From 994aeb58aa6ac1747ecfac15d915e67462bd009b Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:43:44 +0000 Subject: [PATCH 090/162] fix: cite release doctrine in both layouts --- actions/issueflow-reconcile/issueflow-reconcile.sh | 5 ++++- test/issueflow-reconcile.test.sh | 13 +++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 27d03f8..06f4875 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -747,6 +747,9 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null esac elif has_issue_label epic; then if has_issue_label release && ! issue_comment_has_marker "$n" release-init-due; then + release_doctrine_path=.ceremony/RELEASES.md + # Ceremony dogfoods the action but owns doctrine at the repository root (#253). + [ "$REPO" != heavy-duty/ceremony ] || release_doctrine_path=RELEASES.md refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" states="$(reference_states <<<"$refs")" @@ -760,7 +763,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null 4. Ask the operator to bless the order, then open the first wave. 5. Ship the release, close this epic, and trigger the next window. -See \`.ceremony/RELEASES.md\`. The operator blessing the order is the one step this chain never automates." +See \`$release_doctrine_path\`. The operator blessing the order is the one step this chain never automates." log "#$n: release-init due" fi fi diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index e782437..605577e 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -416,7 +416,7 @@ issue_stub_gh() { shift done printf '%s\n----\n' "$body" >>"$TMP/posted-$n" - file="$TMP/repos_owner_repo_issues_${n}_comments.json" + file="$TMP/$(printf 'repos/%s/issues/%s/comments' "$REPO" "$n" | tr / _).json" [ -f "$file" ] || printf '[]\n' >"$file" jq --arg b "$body" --arg at "$(iso_at "$INOW")" \ '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \ @@ -435,7 +435,7 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 false|closing|refs, $5 m # only way to prove a rule that self-rate-limits on its own comment's # timestamp (#254): sweep, then sweep again a day later and watch the # nudge stay silent because the comment it posted is now the activity. - REPO=owner/repo NOW="${PROBE_NOW:-$INOW}" + REPO="${PROBE_REPO:-owner/repo}" NOW="${PROBE_NOW:-$INOW}" ISSUE_LABELS="$2" ISSUE_JSON="$(jq -n --arg at "$(iso_at $((INOW - 10 * 86400)))" \ --argjson assignees "$assignee_json" --arg body "$body" \ @@ -497,6 +497,15 @@ check "an announced gate does not re-read its durable blockers" 0 \ "$release_init_gate_reads_before_repeat" \ grep -cE 'repos/owner/repo/issues/(201|202)$' "$TMP/api-calls" +printf '{"state":"closed"}\n' >"$TMP/repos_heavy-duty_ceremony_issues_201.json" +printf '{"state":"closed"}\n' >"$TMP/repos_heavy-duty_ceremony_issues_202.json" +printf '[]\n' >"$TMP/repos_heavy-duty_ceremony_issues_53_comments.json" +PROBE_REPO=heavy-duty/ceremony \ + issue_probe 53 $'epic\nrelease' 0 false "" "$release_init_body" >/dev/null +# shellcheck disable=SC2016 # backticks are the literal dogfood doctrine citation +check "the ceremony dogfood announce cites its root doctrine path" 0 "" \ + grep -qF 'See `RELEASES.md`.' "$TMP/posted-53" + printf '[]\n' >"$(cfix 54)" issue_probe 54 $'epic\nrelease' 0 false "" \ $'Blocked by #203.\n\n## Task list\n- [x] #201 complete' >/dev/null From 72fa3e0b4d5fc97ce466d184aa8e1945a519a421 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:00:48 +0000 Subject: [PATCH 091/162] feat(changelog): the terminal issue cite joins the fragment guard Refs #262 --- lib/changelog.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/lib/changelog.sh b/lib/changelog.sh index 974b4a8..a054bb9 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -115,8 +115,22 @@ changelog_fragments() { # splits the measured history: every healthy entry passes untouched, # the drift cluster does not. mawk's length() counts bytes; prose here # is ASCII and the fuzz is acceptable. +# - every entry ends with its issue citation (#262): one '(' group of +# '#N', 'repo#N' or 'owner/repo#N' references separated by ', ', then +# ')', then the final '.' and nothing after it. Stated as style and +# enforced by nobody, this rule cost #255 a full four-bot round on a +# missing '(#248)'; the fragment rules that live in this guard drew no +# review comment at all across the same fifteen PRs. Measured on the +# same normalized entry as the bound above, so a citation that wraps +# onto a continuation line still counts. The repo token is the one the +# filename rule already admits, so '-.md' and its cite +# cannot drift apart; the two halves of one convention. A single group +# is what makes 'terminal' checkable — '(#236, #250).' lands two issues +# in one entry, '(#236) and (#250).' does not. The citation need not +# name the file's own issue: the filename already carries the +# authorizing one, so a fragment may cite the incident beside it. changelog_fragment_problem() { - local file="$1" base problem + local file="$1" base problem kind detail rest base="${file##*/}" if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then @@ -157,9 +171,35 @@ changelog_fragment_problem() { return 1 fi + # One walk of the entries, two rules, and the order between them is + # deliberate: an over-long entry anywhere outranks a citation problem + # anywhere, so the length diagnosis a fragment already draws is the same + # one it drew before the citation rule existed. Both read the entry the + # same normalizer produces, which is the whole reason they share a pass. problem="$( awk -v max=300 ' - function flush( len, e) { + # cite_problem — "", "uncited" or "misplaced". Counting the + # groups is what distinguishes the two admitted shapes: one group + # closing the entry passes however many references it carries, and a + # second group anywhere means no single group is terminal. + function cite_problem(e, rest, groups, consumed, group_end) { + rest = e + groups = 0 + consumed = 0 + while (match(rest, /\((([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+(, (([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+)*\)/)) { + groups++ + group_end = consumed + RSTART + RLENGTH - 1 + consumed = group_end + rest = substr(rest, RSTART + RLENGTH) + } + if (groups == 0) return e ~ /#[0-9]/ ? "misplaced" : "uncited" + if (groups > 1) return "misplaced" + return (group_end == length(e) - 1 && substr(e, group_end + 1) == ".") ? "" : "misplaced" + } + function excerpt(e) { + return length(e) > 60 ? substr(e, 1, 60) "…" : e + } + function flush( len, e, kind) { if (entry == "") return 0 e = entry entry = "" @@ -168,9 +208,14 @@ changelog_fragment_problem() { sub(/ $/, "", e) len = length(e) if (len > max) { - printf "%d\t%s\n", len, substr(e, 1, 60) + printf "long\t%d\t%s\n", len, excerpt(e) return 1 } + kind = cite_problem(e) + if (kind != "" && cite_kind == "") { + cite_kind = kind + cite_excerpt = excerpt(e) + } return 0 } /^### / { if (flush()) exit; next } @@ -182,12 +227,31 @@ changelog_fragment_problem() { } /^[[:space:]]*$/ { next } entry != "" { entry = entry " " $0 } - END { flush() } + END { + if (flush()) exit + if (cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt + } ' "$file" )" if [ -n "$problem" ]; then - printf "fragment '%s' has a %s-character entry — '%s…' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \ - "$file" "${problem%%$'\t'*}" "${problem#*$'\t'}" + kind="${problem%%$'\t'*}" + rest="${problem#*$'\t'}" + detail="${rest%%$'\t'*}" + rest="${rest#*$'\t'}" + case "$kind" in + long) + printf "fragment '%s' has a %s-character entry — '%s' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \ + "$file" "$detail" "$rest" + ;; + uncited) + printf "fragment '%s' has an entry with no issue citation — '%s' — end it with the issue it comes from: '(#N).'\n" \ + "$file" "$rest" + ;; + *) + printf "fragment '%s' has an entry whose issue citation is not terminal — '%s' — exactly one '(#N)' group ends the entry, the final '.' after it\n" \ + "$file" "$rest" + ;; + esac return 1 fi } From 46b80fb6b26b491b18e7c03c55212901b2fd1c6f Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:01:45 +0000 Subject: [PATCH 092/162] fix(changelog): the four drifted fragments carry a terminal cite Refs #262 --- changelog.d/237.md | 2 +- changelog.d/241.md | 2 +- changelog.d/248.md | 2 +- changelog.d/280.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog.d/237.md b/changelog.d/237.md index bfe9cd3..59a0d4f 100644 --- a/changelog.d/237.md +++ b/changelog.d/237.md @@ -2,4 +2,4 @@ - Define the doors-unchanged drill record and an executable release-path list, so a release may reuse live evidence only when its door bytes are unchanged - since the last rehearsed tag. (#237) + since the last rehearsed tag (#237). diff --git a/changelog.d/241.md b/changelog.d/241.md index e7e7ec5..e675aa9 100644 --- a/changelog.d/241.md +++ b/changelog.d/241.md @@ -1,3 +1,3 @@ ### Fixed -- Preserve active claims when an open local pull request links them with `Refs #N`. +- Preserve active claims when an open local pull request links them with `Refs #N` (#241). diff --git a/changelog.d/248.md b/changelog.d/248.md index d6c3bbd..5ff7a3f 100644 --- a/changelog.d/248.md +++ b/changelog.d/248.md @@ -1,3 +1,3 @@ ### Added -- Document the optional, operator-ruled release-epic flow for governed repositories. (#248) +- Document the optional, operator-ruled release-epic flow for governed repositories (#248). diff --git a/changelog.d/280.md b/changelog.d/280.md index d4f1b2d..a3c0ca2 100644 --- a/changelog.d/280.md +++ b/changelog.d/280.md @@ -2,4 +2,4 @@ - CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule, retain at most one sentence of why, cite the local record bare, and leave the - incident narrative in that record. (#280) + incident narrative in that record (#280). From c40d9412f6d66261854150c211cebc907bdccf9c Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:06:29 +0000 Subject: [PATCH 093/162] =?UTF-8?q?wip(test):=20fixture=20sweep,=20first?= =?UTF-8?q?=20pass=20=E2=80=94=20over-reaches=20onto=20section=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '- Fixed entry.' is shared between the dangling-heading fragment fixture and the section-predicate fixture, so the global replace crossed D4's line. Next commit filters to fragments that actually red on the cite rule. Refs #262 --- test/changelog-armed.test.sh | 24 ++++----- test/changelog-assemble.test.sh | 72 +++++++++++++------------- test/changelog-assembled.test.sh | 20 ++++---- test/changelog.test.sh | 86 ++++++++++++++++---------------- 4 files changed, 101 insertions(+), 101 deletions(-) diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 66c0f88..0703a52 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -275,7 +275,7 @@ fragment_tree fragments-dev-flat 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- Added fragment mode." >"$TMP/fragments-dev-flat/changelog.d/115.md" +printf '%s\n' "- Added fragment mode (#115)." >"$TMP/fragments-dev-flat/changelog.d/115.md" check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \ in_tree fragments-dev-flat @@ -310,7 +310,7 @@ EOF cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF' ### Changed -- Added fragment mode. +- Added fragment mode (#115). EOF check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \ in_tree fragments-dev-grouped @@ -322,11 +322,11 @@ fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-mixed/changelog.d/114.md" +printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-mixed/changelog.d/114.md" cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \ "fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \ @@ -342,7 +342,7 @@ EOF cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode refuses an all-grouped set over a flat published section" 1 \ "changelog.d/115.md' is grouped but newest published section '1.2.3'" \ @@ -357,7 +357,7 @@ fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md" +printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md" check "fragment mode refuses a flat set over a grouped published section" 1 \ "changelog.d/115.md' is flat but newest published section '1.2.3'" \ in_tree fragments-dev-flat-over-grouped @@ -376,14 +376,14 @@ printf '%s\n' "grouped" >"$TMP/fragments-dev-flip/changelog.d/shape" cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode: 'grouped' sentinel admits the flip tree over a flat published section" 0 \ "fragment mode" in_tree fragments-dev-flip # Post-flip drift is refused on its own PR: a flat probe fragment atop the # flip tree goes red — beside grouped fragments the mix rule names it first. -printf '%s\n' "- Flat probe." >"$TMP/fragments-dev-flip/changelog.d/116.md" +printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md" check "fragment mode: a flat probe atop the flip tree is refused" 1 \ "changelog.d/115.md' is grouped but fragment 'changelog.d/116.md' is not" \ in_tree fragments-dev-flip @@ -393,7 +393,7 @@ rm "$TMP/fragments-dev-flip/changelog.d/116.md" # holds the shape: an all-flat set under 'grouped' is refused, sentinel # named — the published-section inference never gets a say. rm "$TMP/fragments-dev-flip/changelog.d/115.md" -printf '%s\n' "- Flat probe." >"$TMP/fragments-dev-flip/changelog.d/116.md" +printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md" check "fragment mode: a flat set under the 'grouped' sentinel refused, sentinel named" 1 \ "changelog.d/116.md' is flat but 'changelog.d/shape' declares grouped" \ in_tree fragments-dev-flip @@ -417,7 +417,7 @@ EOF cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode accepts a consistent set with no published section" 0 \ "fragment mode" in_tree fragments-dev-no-published @@ -452,7 +452,7 @@ fragment_tree fragments-bad-name 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- An entry." >"$TMP/fragments-bad-name/changelog.d/notes.md" +printf '%s\n' "- An entry (#1)." >"$TMP/fragments-bad-name/changelog.d/notes.md" check "fragment mode quotes malformed-fragment diagnosis and file" 1 \ "fragment 'changelog.d/notes.md' is not named for its issue" \ in_tree fragments-bad-name @@ -531,7 +531,7 @@ check "same changelog fails in legacy mode" 1 "development tree" \ mkdir -p "$TMP/env-tree" printf '1.2.4-dev\n' >"$TMP/env-tree/VERSION" -printf '# Changelog\n\n## Unreleased\n\n- Pending.\n' >"$TMP/env-tree/NOTES.md" +printf '# Changelog\n\n## Unreleased\n\n- Pending (#1).\n' >"$TMP/env-tree/NOTES.md" # A non-default changelog name proves the env var is honored, not the default. env_tree() { (cd "$TMP/env-tree" && CHANGELOG=NOTES.md VERSION_SOURCE=file bash "$SCRIPT") diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh index de008b9..bf0e95a 100644 --- a/test/changelog-assemble.test.sh +++ b/test/changelog-assemble.test.sh @@ -55,13 +55,13 @@ tree flat-one <"$TMP/flip/changelog.d/shape" frag flip 40.md <<'EOF' ### Added -- Forty landed. +- Forty landed (#40). EOF check "sentinel: the flip release assembles grouped over a flat published section" 0 \ "consumed 1 fragment" in_tree flip 0.2.0 2026-07-24 check "sentinel: the written flip section is exact" 0 "" \ assert_file "$TMP/flip/CHANGELOG.md" \ - $'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.' + $'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed (#40).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.' check "sentinel: changelog.d/shape survives consumption" 0 "" \ test -e "$TMP/flip/changelog.d/shape" @@ -171,7 +171,7 @@ $BASE_CHANGELOG EOF printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape" frag flip-flat-frag 41.md <<'EOF' -- Flat forty-one. +- Flat forty-one (#41). EOF check "sentinel: a flat fragment under 'grouped' refuses, sentinel named" 1 \ "changelog.d/shape' declares grouped" in_tree flip-flat-frag 0.2.0 2026-07-24 @@ -183,7 +183,7 @@ printf 'Grouped\n' >"$TMP/flip-malformed/changelog.d/shape" frag flip-malformed 42.md <<'EOF' ### Added -- Forty-two. +- Forty-two (#42). EOF check "sentinel: a malformed sentinel refuses, file named" 1 \ "changelog.d/shape' declares neither shape" in_tree flip-malformed 0.2.0 2026-07-24 @@ -196,13 +196,13 @@ tree preamble-only <<'EOF' Only preamble so far. EOF frag preamble-only 1.md <<'EOF' -- The first entry ever. +- The first entry ever (#1). EOF check "a changelog with no section yet gets the section after the preamble" 0 "" \ in_tree preamble-only 0.1.0 2026-07-24 check "preamble-only write is exact" 0 "" \ assert_file "$TMP/preamble-only/CHANGELOG.md" \ - $'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever.' + $'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever (#1).' # --- --check is provably read-only ------------------------------------------- @@ -210,7 +210,7 @@ tree check-readonly <"$TMP/flagged/NOTES.md" -printf -- '- Flagged entry.\n' >"$TMP/flagged/frags/2.md" +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped (#1).\n' >"$TMP/flagged/NOTES.md" +printf -- '- Flagged entry (#2).\n' >"$TMP/flagged/frags/2.md" check "--changelog and --dir override the defaults" 0 "" \ "$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags" check "the flag-driven write landed in the named changelog" 0 "" \ - grep -qF -- "- Flagged entry." "$TMP/flagged/NOTES.md" + grep -qF -- "- Flagged entry (#2)." "$TMP/flagged/NOTES.md" # --- refusals: each names the file responsible ------------------------------- @@ -270,7 +270,7 @@ frag dangling 4.md <<'EOF' ### Fixed -- Fixed entry. +- Fixed entry (#4). EOF check "a dangling grouped heading refuses, file and heading named" 1 \ "fragment 'changelog.d/4.md' has an empty heading: '### Added'" \ @@ -282,7 +282,7 @@ EOF frag smuggled 6.md <<'EOF' ## 0.2.0 — 2026-07-24 -- An entry under a smuggled heading. +- An entry under a smuggled heading (#6). EOF check "a fragment carrying a '## ' line refuses, file named" 1 \ "fragment 'changelog.d/6.md' carries a '## ' heading" \ @@ -292,7 +292,7 @@ tree stray-txt <"$TMP/no-changelog/changelog.d/2.md" +printf -- '- Entry (#2).\n' >"$TMP/no-changelog/changelog.d/2.md" check "a missing changelog refuses" 1 "no such file" \ in_tree no-changelog 0.2.0 @@ -404,12 +404,12 @@ frag round-trip 30.md <<'EOF' ### Added - Thirty — wraps onto a - continuation line with a naïve café. + continuation line with a naïve café (#30). EOF frag round-trip 29.md <<'EOF' ### Fixed -- Fixed twenty-nine. +- Fixed twenty-nine (#29). EOF CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)" check "round trip: write mode succeeds after --check" 0 "" \ diff --git a/test/changelog-assembled.test.sh b/test/changelog-assembled.test.sh index d43e18b..3d1f313 100644 --- a/test/changelog-assembled.test.sh +++ b/test/changelog-assembled.test.sh @@ -58,8 +58,8 @@ Preamble prose belongs to no section. - The shipped entry. EOF printf '0.1.1-dev\n' >"$dir/VERSION" - printf -- '- Twelve landed.\n' >"$dir/changelog.d/12.md" - printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$dir/changelog.d/9.md" + printf -- '- Twelve landed (#12).\n' >"$dir/changelog.d/12.md" + printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$dir/changelog.d/9.md" commit_base "$name" } @@ -86,8 +86,8 @@ check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \ seed_flat faithful-grouped sed -i '/^- The shipped entry/i ### Fixed\\\n' "$TMP/faithful-grouped/CHANGELOG.md" -printf -- '### Fixed\n\n- Fixed twenty-one.\n' >"$TMP/faithful-grouped/changelog.d/21.md" -printf -- '### Added\n\n- Added twenty.\n\n### Docs\n\n- Docs twenty.\n' >"$TMP/faithful-grouped/changelog.d/20.md" +printf -- '### Fixed\n\n- Fixed twenty-one (#21).\n' >"$TMP/faithful-grouped/changelog.d/21.md" +printf -- '### Added\n\n- Added twenty (#20).\n\n### Docs\n\n- Docs twenty (#20).\n' >"$TMP/faithful-grouped/changelog.d/20.md" rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md" git -C "$TMP/faithful-grouped" add -A git -C "$TMP/faithful-grouped" commit -qm regroup @@ -108,7 +108,7 @@ check "the stamp's date never enters the comparison" 0 "byte-for-byte" \ # --- inapplicable trees: green NOTICE, never a silent skip ------------------- seed_flat ordinary-add -printf -- '- Thirteen incoming.\n' >"$TMP/ordinary-add/changelog.d/13.md" +printf -- '- Thirteen incoming (#13).\n' >"$TMP/ordinary-add/changelog.d/13.md" commit_head ordinary-add check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base @@ -195,8 +195,8 @@ Preamble prose belongs to no section. ## 0.2.0 — 2026-07-24 - Nine landed, and its prose wraps onto a - continuation line. -- Twelve landed. + continuation line (#9). +- Twelve landed (#12). ## 0.1.0 — 2026-07-01 @@ -210,7 +210,7 @@ check "re-ordered entries fail" 1 "NOT what the fragments" run reordered base # directory is not — only the survivor refusal fires. seed_flat survivor ceremony survivor 0.2.0 2026-07-24 -printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$TMP/survivor/changelog.d/9.md" +printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$TMP/survivor/changelog.d/9.md" commit_head survivor check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \ run survivor base @@ -316,9 +316,9 @@ check "merge base IS HEAD: vacuous, named honestly" 0 "vacuous" run vacuous HEAD # the env vars are honored the way the composite sets them. init_repo env-tree mkdir -p "$TMP/env-tree/frags" -printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md" +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped (#1).\n' >"$TMP/env-tree/NOTES.md" printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION" -printf -- '- Flagged entry.\n' >"$TMP/env-tree/frags/2.md" +printf -- '- Flagged entry (#2).\n' >"$TMP/env-tree/frags/2.md" git -C "$TMP/env-tree" add -A git -C "$TMP/env-tree" commit -qm base git -C "$TMP/env-tree" branch fixture-base diff --git a/test/changelog.test.sh b/test/changelog.test.sh index 0c9979d..17d8ac0 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -87,7 +87,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry. +- Fixed entry (#22). ## 1.3.0 @@ -101,7 +101,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry. +- Fixed entry (#22). ## 1.4.0 @@ -115,7 +115,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry. +- Fixed entry (#22). EOF assert_problem Unreleased 0 "" @@ -182,11 +182,11 @@ printf 'marker\n' >"$FRAG/README.md" check "fragments: README.md is the directory marker, never a fragment" 0 "" \ changelog_fragments "$FRAG" -printf -- '- Two.\n' >"$FRAG/2.md" -printf -- '- Nine.\n' >"$FRAG/9.md" -printf -- '- Ten.\n' >"$FRAG/10.md" -printf -- '- Cross.\n' >"$FRAG/ceremony-14.md" -printf -- '- Local fourteen.\n' >"$FRAG/14.md" +printf -- '- Two (#2).\n' >"$FRAG/2.md" +printf -- '- Nine (#9).\n' >"$FRAG/9.md" +printf -- '- Ten (#10).\n' >"$FRAG/10.md" +printf -- '- Cross (#14).\n' >"$FRAG/ceremony-14.md" +printf -- '- Local fourteen (#14).\n' >"$FRAG/14.md" assert_fragments_order() { local expected="$1" actual @@ -205,39 +205,39 @@ check "fragments: issue number descending (numeric, 10 before 9), filename tie-b PF="$TMP/frag-problems" mkdir -p "$PF" -printf -- '- Fine.\n' >"$PF/7.md" +printf -- '- Fine (#7).\n' >"$PF/7.md" check "fragment predicate: a flat fragment passes" 0 "" \ changelog_fragment_problem "$PF/7.md" cat >"$PF/8.md" <<'EOF' ### Added -- Grouped fine. +- Grouped fine (#8). EOF check "fragment predicate: a grouped fragment passes" 0 "" \ changelog_fragment_problem "$PF/8.md" -printf -- '- Cross-repo.\n' >"$PF/ceremony-14.md" +printf -- '- Cross-repo (#14).\n' >"$PF/ceremony-14.md" check "fragment predicate: a cross-repo name passes" 0 "" \ changelog_fragment_problem "$PF/ceremony-14.md" -printf -- '- Bad name.\n' >"$PF/Fix-12.md" +printf -- '- Bad name (#12).\n' >"$PF/Fix-12.md" check "fragment predicate: an uppercase prefix is refused, file named" 1 "Fix-12.md" \ changelog_fragment_problem "$PF/Fix-12.md" -printf -- '- Bad name.\n' >"$PF/notes.txt" +printf -- '- Bad name (#12).\n' >"$PF/notes.txt" check "fragment predicate: a non-.md file is refused, file named" 1 "notes.txt" \ changelog_fragment_problem "$PF/notes.txt" -printf -- '- Bad name.\n' >"$PF/12.markdown" +printf -- '- Bad name (#12).\n' >"$PF/12.markdown" check "fragment predicate: .markdown is refused, file named" 1 "12.markdown" \ changelog_fragment_problem "$PF/12.markdown" -printf -- '- No number.\n' >"$PF/notes.md" +printf -- '- No number (#1).\n' >"$PF/notes.md" check "fragment predicate: a name with no trailing issue number is refused" 1 "notes.md" \ changelog_fragment_problem "$PF/notes.md" cat >"$PF/20.md" <<'EOF' ## 1.0.0 — 2026-07-24 -- Smuggled heading. +- Smuggled heading (#20). EOF check "fragment predicate: a '## ' line is refused — the heading is the assembler's" 1 \ "the section heading is the assembler's to write" \ @@ -253,7 +253,7 @@ cat >"$PF/22.md" <<'EOF' ### Fixed -- Fixed entry. +- Fixed entry (#22). EOF check "fragment predicate: a dangling grouped heading is refused, heading named" 1 \ "has an empty heading: '### Added'" \ @@ -347,11 +347,11 @@ mkdir -p "$AF" printf 'marker\n' >"$AF/README.md" cat >"$AF/3.md" <<'EOF' - Three — an em dash, and prose that - wraps onto a continuation line. + wraps onto a continuation line (#3). EOF -printf -- '- Ten.\n- Ten again.\n' >"$AF/10.md" +printf -- '- Ten (#10).\n- Ten again (#10).\n' >"$AF/10.md" check "assemble: flat fragments, newest issue first, prose verbatim" 0 "" \ - assert_assemble "$AF" $'- Ten.\n- Ten again.\n- Three — an em dash, and prose that\n wraps onto a continuation line.' + assert_assemble "$AF" $'- Ten (#10).\n- Ten again (#10).\n- Three — an em dash, and prose that\n wraps onto a continuation line (#3).' check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \ changelog_assemble "$TMP/no-such-dir" @@ -361,36 +361,36 @@ mkdir -p "$AG" cat >"$AG/21.md" <<'EOF' ### Fixed -- Fixed twenty-one. +- Fixed twenty-one (#21). EOF cat >"$AG/20.md" <<'EOF' ### Added -- Added twenty. +- Added twenty (#20). ### Docs -- Docs twenty. +- Docs twenty (#20). EOF cat >"$AG/19.md" <<'EOF' ### Security -- Security nineteen. +- Security nineteen (#19). ### Added -- Added nineteen. +- Added nineteen (#19). EOF check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \ - assert_assemble "$AG" $'### Added\n\n- Added twenty.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.' + assert_assemble "$AG" $'### Added\n\n- Added twenty (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).' AM="$TMP/assemble-mixed" mkdir -p "$AM" -printf -- '- Flat five.\n' >"$AM/5.md" +printf -- '- Flat five (#5).\n' >"$AM/5.md" cat >"$AM/6.md" <<'EOF' ### Added -- Grouped six. +- Grouped six (#6). EOF check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \ changelog_assemble "$AM" @@ -400,11 +400,11 @@ check "assemble: mixed shapes refused, flat side named too" 1 "5.md" \ AX="$TMP/assemble-selfmixed" mkdir -p "$AX" cat >"$AX/7.md" <<'EOF' -- Ungrouped lead. +- Ungrouped lead (#7). ### Added -- Grouped follow. +- Grouped follow (#7). EOF check "assemble: one fragment mixing both shapes is refused, file named" 1 \ "'$AX/7.md' mixes grouped headings and ungrouped bullets" \ @@ -429,14 +429,14 @@ cat >"$SHAPE_CHANGELOG" <<'EOF' - Older section is grouped. EOF -printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md" check "shape: flat set matches newest flat published section" 0 "" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF check "shape: grouped set names its conflict with newest flat published section" 1 \ "fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \ @@ -451,7 +451,7 @@ cat >"$SHAPE_CHANGELOG" <<'EOF' - Newest section is grouped. EOF -printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md" check "shape: flat set names its conflict with newest grouped published section" 1 \ "fragment '$SHAPE_DIR/1.md' is flat but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is grouped" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -459,7 +459,7 @@ check "shape: flat set names its conflict with newest grouped published section" cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF check "shape: grouped set matches newest grouped published section" 0 "" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -484,7 +484,7 @@ EOF cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF printf 'grouped\n' >"$SHAPE_DIR/shape" check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \ @@ -492,7 +492,7 @@ check "shape: 'grouped' sentinel admits a grouped set over a flat published sect check "shape: the sentinel binds with no changelog at all — the assembler's call" 0 "" \ changelog_shape_problem "" "$SHAPE_DIR" -printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md" check "shape: flat fragment under a 'grouped' sentinel refused, fragment and sentinel named" 1 \ "fragment '$SHAPE_DIR/1.md' is flat but '$SHAPE_DIR/shape' declares grouped" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -512,14 +512,14 @@ check "shape: 'flat' sentinel admits a flat set over a grouped published section cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF check "shape: grouped fragment under a 'flat' sentinel refused, fragment and sentinel named" 1 \ "fragment '$SHAPE_DIR/1.md' is grouped but '$SHAPE_DIR/shape' declares flat" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" printf 'grouped\n' >"$SHAPE_DIR/shape" -printf -- '- Flat two.\n' >"$SHAPE_DIR/2.md" +printf -- '- Flat two (#2).\n' >"$SHAPE_DIR/2.md" check "shape: a mixed set is refused regardless of the sentinel" 1 \ "fragment '$SHAPE_DIR/1.md' is grouped but fragment '$SHAPE_DIR/2.md' is not" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -558,7 +558,7 @@ printf 'grouped\n' >"$SHAPE_DIR/shape" cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF assert_fragments_exclude_sentinel() { local out @@ -579,17 +579,17 @@ printf 'grouped\n' >"$AS/shape" cat >"$AS/30.md" <<'EOF' ### Fixed -- Fixed thirty. +- Fixed thirty (#30). EOF cat >"$AS/31.md" <<'EOF' ### Added -- Added thirty-one. +- Added thirty-one (#31). EOF check "assemble: the sentinel never assembles, and canonical order holds under it" 0 "" \ - assert_assemble "$AS" $'### Added\n\n- Added thirty-one.\n\n### Fixed\n\n- Fixed thirty.' + assert_assemble "$AS" $'### Added\n\n- Added thirty-one (#31).\n\n### Fixed\n\n- Fixed thirty (#30).' rm "$AS/30.md" "$AS/31.md" -printf -- '- Flat probe.\n' >"$AS/29.md" +printf -- '- Flat probe (#29).\n' >"$AS/29.md" check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \ "declares grouped" \ changelog_assemble "$AS" From ef818001d0c51739d1cdaf2ebcb78b1ade0a6b71 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:08:54 +0000 Subject: [PATCH 094/162] test(changelog): every fragment fixture carries a terminal cite Scoped to the fixtures the new rule actually binds: a fragment whose predicate complaint is already its name, a smuggled heading, a dangling heading or the 300-character bound is left alone, so the diagnosis it tests is still the one it draws. The section-predicate fixtures are untouched (D4). The computed-length fixtures keep their measured lengths: the cite is seven characters, so an entry that must measure exactly 300 builds 293 of the run and lets the cite carry the rest. 33.md's cite lands on the last continuation line, which is the wrapped-citation case. Refs #262 --- test/changelog-armed.test.sh | 6 +++--- test/changelog-assemble.test.sh | 10 +++++----- test/changelog-assembled.test.sh | 2 +- test/changelog.test.sh | 30 +++++++++++++++--------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 0703a52..120b9ed 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -452,7 +452,7 @@ fragment_tree fragments-bad-name 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- An entry (#1)." >"$TMP/fragments-bad-name/changelog.d/notes.md" +printf '%s\n' "- An entry." >"$TMP/fragments-bad-name/changelog.d/notes.md" check "fragment mode quotes malformed-fragment diagnosis and file" 1 \ "fragment 'changelog.d/notes.md' is not named for its issue" \ in_tree fragments-bad-name @@ -484,7 +484,7 @@ check "fragment bare + stamped section + consumed directory passes" 0 \ "fragment mode" in_tree fragments-bare-stamped cp -R "$TMP/fragments-bare-stamped" "$TMP/fragments-bare-survivor" -printf '%s\n' "- This entry was not consumed." \ +printf '%s\n' "- This entry was not consumed (#115)." \ >"$TMP/fragments-bare-survivor/changelog.d/115.md" check "fragment bare refuses and lists surviving fragments" 1 \ "these fragments were not consumed: changelog.d/115.md" \ @@ -531,7 +531,7 @@ check "same changelog fails in legacy mode" 1 "development tree" \ mkdir -p "$TMP/env-tree" printf '1.2.4-dev\n' >"$TMP/env-tree/VERSION" -printf '# Changelog\n\n## Unreleased\n\n- Pending (#1).\n' >"$TMP/env-tree/NOTES.md" +printf '# Changelog\n\n## Unreleased\n\n- Pending.\n' >"$TMP/env-tree/NOTES.md" # A non-default changelog name proves the env var is honored, not the default. env_tree() { (cd "$TMP/env-tree" && CHANGELOG=NOTES.md VERSION_SOURCE=file bash "$SCRIPT") diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh index bf0e95a..178744b 100644 --- a/test/changelog-assemble.test.sh +++ b/test/changelog-assemble.test.sh @@ -213,7 +213,7 @@ frag check-readonly 5.md <<'EOF' - Five (#5). EOF cp -R "$TMP/check-readonly" "$TMP/check-readonly.before" -check "--check prints the assembled body" 0 "Five." \ +check "--check prints the assembled body" 0 "Five (#5)." \ in_tree check-readonly 0.2.0 2026-07-24 --check check "--check is read-only: the tree is byte-identical before and after" 0 "" \ diff -r "$TMP/check-readonly.before" "$TMP/check-readonly" @@ -228,7 +228,7 @@ check "the defaulted stamp is a UTC date" 0 "" \ # --- --changelog and --dir override the defaults ----------------------------- mkdir -p "$TMP/flagged/frags" -printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped (#1).\n' >"$TMP/flagged/NOTES.md" +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/flagged/NOTES.md" printf -- '- Flagged entry (#2).\n' >"$TMP/flagged/frags/2.md" check "--changelog and --dir override the defaults" 0 "" \ "$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags" @@ -270,7 +270,7 @@ frag dangling 4.md <<'EOF' ### Fixed -- Fixed entry (#4). +- Fixed entry. EOF check "a dangling grouped heading refuses, file and heading named" 1 \ "fragment 'changelog.d/4.md' has an empty heading: '### Added'" \ @@ -282,7 +282,7 @@ EOF frag smuggled 6.md <<'EOF' ## 0.2.0 — 2026-07-24 -- An entry under a smuggled heading (#6). +- An entry under a smuggled heading. EOF check "a fragment carrying a '## ' line refuses, file named" 1 \ "fragment 'changelog.d/6.md' carries a '## ' heading" \ @@ -313,7 +313,7 @@ tree stray-case <"$TMP/env-tree/NOTES.md" +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md" printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION" printf -- '- Flagged entry (#2).\n' >"$TMP/env-tree/frags/2.md" git -C "$TMP/env-tree" add -A diff --git a/test/changelog.test.sh b/test/changelog.test.sh index 17d8ac0..1aed0b1 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -87,7 +87,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry (#22). +- Fixed entry. ## 1.3.0 @@ -101,7 +101,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry (#22). +- Fixed entry. ## 1.4.0 @@ -115,7 +115,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry (#22). +- Fixed entry. EOF assert_problem Unreleased 0 "" @@ -221,23 +221,23 @@ printf -- '- Cross-repo (#14).\n' >"$PF/ceremony-14.md" check "fragment predicate: a cross-repo name passes" 0 "" \ changelog_fragment_problem "$PF/ceremony-14.md" -printf -- '- Bad name (#12).\n' >"$PF/Fix-12.md" +printf -- '- Bad name.\n' >"$PF/Fix-12.md" check "fragment predicate: an uppercase prefix is refused, file named" 1 "Fix-12.md" \ changelog_fragment_problem "$PF/Fix-12.md" -printf -- '- Bad name (#12).\n' >"$PF/notes.txt" +printf -- '- Bad name.\n' >"$PF/notes.txt" check "fragment predicate: a non-.md file is refused, file named" 1 "notes.txt" \ changelog_fragment_problem "$PF/notes.txt" -printf -- '- Bad name (#12).\n' >"$PF/12.markdown" +printf -- '- Bad name.\n' >"$PF/12.markdown" check "fragment predicate: .markdown is refused, file named" 1 "12.markdown" \ changelog_fragment_problem "$PF/12.markdown" -printf -- '- No number (#1).\n' >"$PF/notes.md" +printf -- '- No number.\n' >"$PF/notes.md" check "fragment predicate: a name with no trailing issue number is refused" 1 "notes.md" \ changelog_fragment_problem "$PF/notes.md" cat >"$PF/20.md" <<'EOF' ## 1.0.0 — 2026-07-24 -- Smuggled heading (#20). +- Smuggled heading. EOF check "fragment predicate: a '## ' line is refused — the heading is the assembler's" 1 \ "the section heading is the assembler's to write" \ @@ -253,7 +253,7 @@ cat >"$PF/22.md" <<'EOF' ### Fixed -- Fixed entry (#22). +- Fixed entry. EOF check "fragment predicate: a dangling grouped heading is refused, heading named" 1 \ "has an empty heading: '### Added'" \ @@ -274,14 +274,14 @@ check "length bound: the refusal names the bound and the split fix" 1 \ "the bound is 300: split it into multiple '- ' entries in this same fragment" \ changelog_fragment_problem "$PF/30.md" -printf -- '- %s\n' "$(mkchars 300)" >"$PF/31.md" +printf -- '- %s (#31).\n' "$(mkchars 293)" >"$PF/31.md" check "length bound: an entry of exactly 300 passes" 0 "" \ changelog_fragment_problem "$PF/31.md" { - printf -- '- %s\n' "$(mkchars 150)" - printf -- '- %s\n' "$(mkchars 150)" - printf -- '- %s\n' "$(mkchars 150)" + printf -- '- %s (#32).\n' "$(mkchars 143)" + printf -- '- %s (#32).\n' "$(mkchars 143)" + printf -- '- %s (#32).\n' "$(mkchars 143)" } >"$PF/32.md" check "length bound: several within-bound entries pass though the file totals over 300" 0 "" \ changelog_fragment_problem "$PF/32.md" @@ -291,14 +291,14 @@ check "length bound: several within-bound entries pass though the file totals ov printf ' %s\n' "$(mkchars 50)" printf ' %s\n' "$(mkchars 50)" printf ' %s\n' "$(mkchars 50)" - printf ' %s\n' "$(mkchars 50)" + printf ' %s (#33).\n' "$(mkchars 50)" } >"$PF/33.md" check "length bound: a ~250-character entry wrapped over four continuation lines passes" 0 "" \ changelog_fragment_problem "$PF/33.md" { printf '### Added\n\n' - printf -- '- %s\n' "$(mkchars 300)" + printf -- '- %s (#34).\n' "$(mkchars 293)" } >"$PF/34.md" check "length bound: a '### ' heading counts toward no entry — 300 under it still passes" 0 "" \ changelog_fragment_problem "$PF/34.md" From f4cb970097e1f1a7d9c3f407875b6916bc1aff9d Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:11:09 +0000 Subject: [PATCH 095/162] test(changelog): the cite rule's own cases, both callers asserted Refs #262 --- test/changelog-armed.test.sh | 32 ++++++++++ test/changelog-assemble.test.sh | 32 ++++++++++ test/changelog.test.sh | 105 ++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 120b9ed..f9bb57f 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -298,6 +298,38 @@ check "fragment mode over-bound refusal names the bound and the split fix" 1 \ "the bound is 300: split it into multiple '- ' entries in this same fragment" \ in_tree fragments-dev-over-bound +# The terminal cite (#262) reds the PR that writes the fragment, through the +# same shared predicate — which is the whole point of the rule living there +# rather than in prose a reviewer has to remember. +fragment_tree fragments-dev-uncited 1.2.4-dev <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +- The shipped entry. +EOF +printf '%s\n' "- An entry that never learned to cite its issue." \ + >"$TMP/fragments-dev-uncited/changelog.d/115.md" +check "fragment mode refuses an uncited entry, fragment named" 1 \ + "115.md' has an entry with no issue citation" \ + in_tree fragments-dev-uncited +check "fragment mode uncited refusal names the shape to write" 1 \ + "end it with the issue it comes from: '(#N).'" \ + in_tree fragments-dev-uncited + +fragment_tree fragments-dev-misplaced-cite 1.2.4-dev <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +- The shipped entry. +EOF +printf '%s\n' "- The citation trails the period. (#115)" \ + >"$TMP/fragments-dev-misplaced-cite/changelog.d/115.md" +check "fragment mode refuses a non-terminal citation, fragment named" 1 \ + "115.md' has an entry whose issue citation is not terminal" \ + in_tree fragments-dev-misplaced-cite + fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF' # Changelog diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh index 178744b..f87a00f 100644 --- a/test/changelog-assemble.test.sh +++ b/test/changelog-assemble.test.sh @@ -204,6 +204,38 @@ check "preamble-only write is exact" 0 "" \ assert_file "$TMP/preamble-only/CHANGELOG.md" \ $'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever (#1).' +# --- the fragment predicate at release time (#262) --------------------------- + +# The cite rule joins changelog_fragment_problem, so it binds both callers: +# the arming guard at PR time and this assembler at release time. Asserted +# rather than assumed — a release that publishes an uncited entry is the +# failure the PR-time guard exists to have already caught. + +tree uncited-release < — a fragment holding exactly the given +# lines, so a case reads as the entry it is about. +cite_case() { + local num="$1" + shift + printf '%s\n' "$@" >"$PF/$num.md" +} + +cite_case 40 '- Local (#262).' +check "cite: the canonical '(#N).' passes" 0 "" \ + changelog_fragment_problem "$PF/40.md" +cite_case 41 '- Sibling repo (crew#309).' +check "cite: a sibling-repo reference passes" 0 "" \ + changelog_fragment_problem "$PF/41.md" +cite_case 42 '- Fully qualified (heavy-duty/crew#309).' +check "cite: an owner/repo reference passes" 0 "" \ + changelog_fragment_problem "$PF/42.md" +cite_case 43 '- Two issues, one entry (#236, #250).' +check "cite: one group carrying two references passes" 0 "" \ + changelog_fragment_problem "$PF/43.md" + +# The cite is measured on the normalized entry, so a citation that lands on +# a continuation line still closes the entry — the #167 lesson, repeated: +# wrapping alone must never red a compliant entry. +cite_case 44 '- An entry whose prose wraps onto a' ' continuation line, cite and all (#262).' +check "cite: a citation on a continuation line passes — the entry is normalized first" 0 "" \ + changelog_fragment_problem "$PF/44.md" + +cite_case 45 '### Added' '' '- Added one (#101).' '- Added two (#102).' '' \ + '### Changed' '' '- Changed one (#103).' '' '### Fixed' '' '- Fixed one (#104).' +check "cite: a grouped fragment, three headings, every entry compliant, passes" 0 "" \ + changelog_fragment_problem "$PF/45.md" + +# The two diagnoses are distinct by construction (D5): a builder who reads +# one must not be told the other's fix. +cite_case 50 '- No cite here.' +check "cite: an entry with no reference at all is refused" 1 \ + "50.md' has an entry with no issue citation" \ + changelog_fragment_problem "$PF/50.md" +check "cite: the uncited refusal names the shape to write" 1 \ + "end it with the issue it comes from: '(#N).'" \ + changelog_fragment_problem "$PF/50.md" + +cite_case 51 '- Cite before the period. (#262)' +check "cite: a citation trailing the period is refused — the 248.md shape" 1 \ + "51.md' has an entry whose issue citation is not terminal" \ + changelog_fragment_problem "$PF/51.md" +check "cite: the misplaced refusal names the shape to write" 1 \ + "exactly one '(#N)' group ends the entry, the final '.' after it" \ + changelog_fragment_problem "$PF/51.md" + +cite_case 52 '- Trailing prose (#262) and then more.' +check "cite: a citation with prose after it is refused" 1 \ + "has an entry whose issue citation is not terminal" \ + changelog_fragment_problem "$PF/52.md" + +cite_case 53 '- Two groups (#236) and (#250).' +check "cite: two citation groups are refused — one terminal group, or none (D2)" 1 \ + "has an entry whose issue citation is not terminal" \ + changelog_fragment_problem "$PF/53.md" + +cite_case 54 '- Bad token (#abc).' +check "cite: a reference with no digits is no reference" 1 \ + "has an entry with no issue citation" \ + changelog_fragment_problem "$PF/54.md" +cite_case 55 '- Bad token (#).' +check "cite: an empty reference is no reference" 1 \ + "has an entry with no issue citation" \ + changelog_fragment_problem "$PF/55.md" + +# The citation need not name the file's own issue (D3): the filename already +# carries the authorizing one, so an entry may cite the incident beside it. +cite_case 56 '- Cites another issue entirely (#101).' +check "cite: the reference need not match the filename" 0 "" \ + changelog_fragment_problem "$PF/56.md" + +# Ordering: the bound outranks the cite, so a fragment that reds today draws +# the diagnosis it drew before this rule existed. +{ + printf -- '- %s\n' "$(mkchars 301)" + printf -- '- Uncited too.\n' +} >"$PF/57.md" +check "cite: an over-bound entry still reports the bound, not the cite" 1 \ + "57.md' has a 301-character entry" \ + changelog_fragment_problem "$PF/57.md" + +# Published sections keep their pre-rule prose (D4): reddening history is a +# wall, not a guard. Every shipped section predates the cite. +check "cite: a published section with uncited entries still reds nothing" 0 "" \ + changelog_section_problem "$ROOT/CHANGELOG.md" 0.3.0 + +# The fragments this repo carries right now are the rule's own first +# constituency — the guard is worth nothing if the tree it ships in fails it. +assert_tree_fragments() { + local f + while IFS= read -r f; do + [ -n "$f" ] || continue + changelog_fragment_problem "$f" || return 1 + done <<<"$(changelog_fragments "$ROOT/changelog.d")" +} +check "cite: every fragment in this tree passes the rule it ships" 0 "" \ + assert_tree_fragments + # --- the assembler (#114) ---------------------------------------------------- assert_assemble() { From 8aeea67d8d522f6c06cd44fd825a9617975494b0 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:12:52 +0000 Subject: [PATCH 096/162] docs(changelog): the citation is guard-enforced, not house style Closes #262 --- BUILDER.md | 8 +++++++- CHANGELOG.md | 3 +++ changelog.d/262.md | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 changelog.d/262.md diff --git a/BUILDER.md b/BUILDER.md index fe2d5fb..bc57d26 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -203,7 +203,13 @@ triage bug, and the move is to say so on the issue, not to guess. a change genuinely is one. An entry is at most 300 characters — the fragment guard reds longer (#167) — so a genuinely long change ships several short entries, never one long one; wrapping an entry over - continuation lines is fine and never counts against it. Never edit + continuation lines is fine and never counts against it. Every entry + **ends with its issue citation**, and the same guard reds an entry + without one: a single `(` group of `#N`, `repo#N` or `owner/repo#N` + references separated by `, `, then `)`, then the final `.` and nothing + after it — `(#262).` locally, `(#236, #250).` when one entry honestly + lands two. The citation need not name the fragment's own issue, because + the filename already carries the authorizing one (#262). Never edit `CHANGELOG.md` for an entry — the release PR assembles the section from the fragments (#112); the monotonic guard still refuses anything that deletes a shipped heading. diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd452c..491b733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ published verbatim as that release's body (lib/changelog.sh extracts it), so entries say what changed, cite the issue, and stop — at most 300 characters each, guard-enforced on the PR that writes the fragment (#167); a genuinely long change ships several short entries, never one long one. +The citation is guard-enforced too, and it closes the entry: one `(#N)` +group, then the final `.` and nothing after it (#262). Sections published +before that rule keep their prose; the guard reads fragments only. Entries arrive as fragments — one `changelog.d/.md` per PR, never an edit to this file — and the release PR assembles them into the next section here (`bin/changelog-assemble`, #112). diff --git a/changelog.d/262.md b/changelog.d/262.md new file mode 100644 index 0000000..aaf0a75 --- /dev/null +++ b/changelog.d/262.md @@ -0,0 +1,17 @@ +### Added + +- The fragment guard now requires each entry to end with its issue + citation: one `(#N)` group — local, `repo#N` or `owner/repo#N` + references separated by `, ` — then the final `.` and nothing after it + (#262). +- The refusal distinguishes an entry carrying no reference at all from one + whose reference is present but not terminal, and names the shape to + write in both (#262). + +### Changed + +- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced + rather than as house style, beside the 300-character bound it now sits + next to (#262). +- Four fragments in flight gained a terminal citation; published sections + are untouched, so no shipped prose is re-opened (#262). From 175e082c1bb44db1db7cb3e52032e9ce2b0bc54c Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:15:04 +0000 Subject: [PATCH 097/162] test(release-exercise): the replay's fragment fixture carries a cite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step-replay job builds a consumer tree and runs the REAL assembler over it, so its changelog.d/42.md is a fragment fixture like every one in test/ — and the only one living outside it. #262's diff-surface criterion says no workflow file; the criterion and a green head cannot both hold here, and the fixture is the smaller thing to move. Refs #262 --- .github/workflows/release-exercise.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-exercise.yml b/.github/workflows/release-exercise.yml index 90201c4..51d01cb 100644 --- a/.github/workflows/release-exercise.yml +++ b/.github/workflows/release-exercise.yml @@ -119,7 +119,7 @@ jobs: EOF mkdir changelog.d printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md - printf -- '- The entry this release ships.\n' > changelog.d/42.md + printf -- '- The entry this release ships (#42).\n' > changelog.d/42.md git add VERSION CHANGELOG.md changelog.d git commit -qm "base" printf '0.7.0\n' > VERSION From 84c73fe22ea1b75a88f42085719f6793d4389db8 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:16:49 +0000 Subject: [PATCH 098/162] test(changelog): the precedence case runs in the order that can fail Refs #262 --- test/changelog.test.sh | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/test/changelog.test.sh b/test/changelog.test.sh index f1848df..54f5fe9 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -409,15 +409,27 @@ cite_case 56 '- Cites another issue entirely (#101).' check "cite: the reference need not match the filename" 0 "" \ changelog_fragment_problem "$PF/56.md" -# Ordering: the bound outranks the cite, so a fragment that reds today draws -# the diagnosis it drew before this rule existed. +# Ordering: the bound outranks the cite across the whole fragment, so a +# fragment that reds today draws the diagnosis it drew before this rule +# existed. The uncited entry comes FIRST here on purpose — the other order +# would pass whatever the precedence is. { + printf -- '- Uncited, and it comes first.\n' printf -- '- %s\n' "$(mkchars 301)" - printf -- '- Uncited too.\n' } >"$PF/57.md" -check "cite: an over-bound entry still reports the bound, not the cite" 1 \ +check "cite: an over-bound entry outranks an earlier uncited one" 1 \ "57.md' has a 301-character entry" \ changelog_fragment_problem "$PF/57.md" +assert_one_diagnosis_57() { + local count + count="$(changelog_fragment_problem "$PF/57.md" | wc -l)" + [ "$count" = 1 ] || { + printf 'wanted one diagnosis, got %s\n' "$count" + return 1 + } +} +check "cite: the outranked citation problem is not reported beside it" 0 "" \ + assert_one_diagnosis_57 # Published sections keep their pre-rule prose (D4): reddening history is a # wall, not a guard. Every shipped section predates the cite. From 75a5b68c8a0bbdc9f20c135c752e4227f569933c Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:38:13 +0000 Subject: [PATCH 099/162] fix(changelog): one fragment, one diagnosis, wherever the long entry sits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit awk runs END on the way out of an exit from a main rule, so the length row printed mid-file was followed by the citation row it outranks — the internal protocol line landing inside the human-facing excerpt. Found by claude-bot and kimi-bot in #262's first round, independently and with the same reproduction. The guard is the reported flag the empty-heading walk in this same predicate already uses. The fixtures are the axis 57.md could not reach: its over-bound entry is last, so only END's flush can print. 58.md puts one before another bullet, 59.md before a heading and after a misplaced cite. With lib/changelog.sh alone reverted they red, which is what the green suite was hiding. Refs #262. --- changelog.d/262.md | 3 +++ lib/changelog.sh | 9 ++++++++- test/changelog.test.sh | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/changelog.d/262.md b/changelog.d/262.md index aaf0a75..89e90fa 100644 --- a/changelog.d/262.md +++ b/changelog.d/262.md @@ -7,6 +7,9 @@ - The refusal distinguishes an entry carrying no reference at all from one whose reference is present but not terminal, and names the shape to write in both (#262). +- The 300-character bound still outranks the citation across the whole + fragment, and the outranked problem stays out of the message it lost + to: one fragment, one diagnosis, wherever in the file it sits (#262). ### Changed diff --git a/lib/changelog.sh b/lib/changelog.sh index a054bb9..fb229a1 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -208,6 +208,7 @@ changelog_fragment_problem() { sub(/ $/, "", e) len = length(e) if (len > max) { + reported = 1 printf "long\t%d\t%s\n", len, excerpt(e) return 1 } @@ -227,9 +228,15 @@ changelog_fragment_problem() { } /^[[:space:]]*$/ { next } entry != "" { entry = entry " " $0 } + # An exit from a main rule still runs END, so a length row printed + # mid-file would be followed by the citation row it outranks — two + # lines spliced into one diagnosis, the internal protocol row landing + # inside the human-facing excerpt (#262 round 1). The reported flag is + # the same guard the empty-heading walk above uses, for the same + # reason: one diagnosis per fragment is the contract. END { if (flush()) exit - if (cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt + if (!reported && cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt } ' "$file" )" diff --git a/test/changelog.test.sh b/test/changelog.test.sh index 54f5fe9..47e9ac9 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -420,16 +420,44 @@ check "cite: the reference need not match the filename" 0 "" \ check "cite: an over-bound entry outranks an earlier uncited one" 1 \ "57.md' has a 301-character entry" \ changelog_fragment_problem "$PF/57.md" -assert_one_diagnosis_57() { +assert_one_diagnosis() { local count - count="$(changelog_fragment_problem "$PF/57.md" | wc -l)" + count="$(changelog_fragment_problem "$PF/$1.md" | wc -l)" [ "$count" = 1 ] || { printf 'wanted one diagnosis, got %s\n' "$count" return 1 } } check "cite: the outranked citation problem is not reported beside it" 0 "" \ - assert_one_diagnosis_57 + assert_one_diagnosis 57 + +# The axis 57.md cannot test: its over-bound entry is LAST, so the only +# flush that can print is END's, which exits immediately. A flush from a +# main rule exits too — but awk runs END on the way out, so the citation +# row a mid-file length row outranks would print after it unless END is +# guarded. Both ways out of the walk, a bullet and a heading. +{ + printf -- '- Uncited, and it comes first.\n' + printf -- '- %s\n' "$(mkchars 301)" + printf -- '- A later entry the walk never reaches (#57).\n' +} >"$PF/58.md" +check "cite: an over-bound entry that is not the last one still reports the bound" 1 \ + "58.md' has a 301-character entry" \ + changelog_fragment_problem "$PF/58.md" +check "cite: and it is still one diagnosis, not the protocol row spliced into it" 0 "" \ + assert_one_diagnosis 58 +{ + printf '### Fixed\n' + printf -- '- Misplaced, and it comes first. (#59)\n' + printf -- '- %s\n' "$(mkchars 301)" + printf '### Changed\n' + printf -- '- The heading is the other way out of the walk (#59).\n' +} >"$PF/59.md" +check "cite: a heading after the over-bound entry is the same one diagnosis" 1 \ + "59.md' has a 301-character entry" \ + changelog_fragment_problem "$PF/59.md" +check "cite: the misplaced row does not ride along with it either" 0 "" \ + assert_one_diagnosis 59 # Published sections keep their pre-rule prose (D4): reddening history is a # wall, not a guard. Every shipped section predates the cite. From 9f1f88de7202466571264cac6ff7a3f28b0cbef1 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:40:55 +0000 Subject: [PATCH 100/162] fix(changelog): #285's fragment carries a terminal cite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changelog.d/253.md landed on main after this branch's point with the cite trailing the period — the crew#309 shape, the fifth fragment to arrive with it since #262 measured two. The criterion is that changelog.d/ is clean at the head, and the head CI reviews is the merge with main, which is where this one surfaced. Refs #262. --- changelog.d/253.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/253.md b/changelog.d/253.md index 3bb9f9c..25cb78d 100644 --- a/changelog.d/253.md +++ b/changelog.d/253.md @@ -1,3 +1,3 @@ ### Added -- Release epics now announce release initialization when their declared dependency gates clear. (#253) +- Release epics now announce release initialization when their declared dependency gates clear (#253). From 7af13eae73a3b107af5270aff36e94fab33485b3 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:53:38 +0000 Subject: [PATCH 101/162] docs: add terminal citations to release fragments --- changelog.d/241.md | 2 +- changelog.d/248.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/241.md b/changelog.d/241.md index e7e7ec5..a095226 100644 --- a/changelog.d/241.md +++ b/changelog.d/241.md @@ -1,3 +1,3 @@ ### Fixed -- Preserve active claims when an open local pull request links them with `Refs #N`. +- Preserve active claims when an open local pull request links them with `Refs #N`. (#241). diff --git a/changelog.d/248.md b/changelog.d/248.md index d6c3bbd..baa2137 100644 --- a/changelog.d/248.md +++ b/changelog.d/248.md @@ -1,3 +1,3 @@ ### Added -- Document the optional, operator-ruled release-epic flow for governed repositories. (#248) +- Document the optional, operator-ruled release-epic flow for governed repositories. (#248). From 6ebdd2efd9f57e2d6c3cb7c802ad2d5921684d74 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:11:32 +0000 Subject: [PATCH 102/162] =?UTF-8?q?docs(builder):=20WIP=20=E2=80=94=20slim?= =?UTF-8?q?=20BUILDER.md=20to=20the=20rules,=20bare=20local=20cites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUILDER.md | 580 +++++++++++++++++++-------------------------- changelog.d/281.md | 5 + 2 files changed, 243 insertions(+), 342 deletions(-) create mode 100644 changelog.d/281.md diff --git a/BUILDER.md b/BUILDER.md index bc57d26..a8f7620 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -7,226 +7,169 @@ 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. -- In a repository that adopts version epics, read [RELEASES.md](RELEASES.md) - before choosing among release-window members. -- **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. + never an `epic` (epics organize; their children are the work). Inside an + epic take the earliest unblocked unclaimed child; between epics and strays + prefer the issue that unblocks the most other work. Where a repository + adopts version epics, [RELEASES.md](RELEASES.md) governs the choice among + release-window members. +- **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; a red head + that owes no round and holds no conflict is otherwise nobody's next move, + and the PR strands mergeable (#163). The recovery path: 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 hand off once the check is green and current-head approvals + stand. Such a PR is **not parked** — the next move is yours whatever the + round's verdict state says. Red and green are the review round's ruled + terms below; how the engine detects a red head is crew's to describe, not + this file's. - **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 starting new work. The rule counts build work in flight, not claims: a - claim does not consume the slot while it is **parked**, meaning the next - move belongs to someone else. Exactly five shapes qualify: + **parked** claim, one whose next move belongs to someone else, does not + consume the slot. Exactly five shapes park: 1. the issue carries `needs-ruling`, its escalation names a decider, and its `Blocked:` line stops the remaining work; - 2. the deliverable is in a review round where every outstanding verdict - belongs to someone else — either the round is awaiting its first - verdicts, or it was answered whole and the owed re-requests posted — - by head, not by verdict: every panelist after a push, the - non-approvers alone at an unchanged head (the review round, steps - 1–2). 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; + 2. the deliverable is in a **live** review round, every outstanding + verdict someone else's — awaiting its first verdicts, or answered whole + with the owed re-requests posted, by head and not by verdict (steps 1–2 + below). A red check at the current head takes it **out of this shape**: + that state reads as waiting on the panel and is not, the next move is + yours, and reading it as parked strands the PR; 3. every remaining acceptance criterion is operator-owned, stated as such by triage on the issue; 4. the deliverable is **handed off** — the round passed, no `blocker:*` - stands, and you set `state:needs-human` per Handoff (below). The - remaining move is the human's merge. - 5. the claim is **held by directive** — triage or the operator has told - you to stop, the direction names what the hold waits on, and that thing - is not yours to move. This is not "waiting for a good moment": somebody - else has decided the work must not proceed, and only they end it. - And it ends the same way it started: **on the labels.** When the queue - labels and any prose — an issue body header, a triage comment, an - operator's comment — disagree about whether a hold stands, the most - recent queue-label event by the hold's owner governs, and the prose is - stale until someone corrects it. So before standing down *or* standing - up on a hold, read the issue's **label events** + stands, `state:needs-human` is set per Handoff (below), and the merge + is the human's. Shapes 2 and 4 are sequential and never overlap; + 5. the claim is **held by directive** — triage or the operator stopped the + work, the direction names what the hold waits on, and only they end it; + this is never "waiting for a good moment". A hold ends the way it + started, **on the labels**: where the queue labels and any prose + disagree about whether it stands, the most recent queue-label event by + the hold's owner governs and the prose is stale until corrected, an + operator being free to lift by label alone (#149, #151). So before + standing down *or* standing up, read the issue's **label events** (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not only its - comments: an operator may lift by label alone, and on 2026-07-24 did, - twice, on [#149](https://github.com/heavy-duty/ceremony/issues/149) - and [#151](https://github.com/heavy-duty/ceremony/issues/151). Acting - on the labels against stale prose, say so in the claim — name the - events you read, their timestamps and their actor, and invite the - correction if the read is wrong; - [the 14:11:45Z claim on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070781295) - is the exemplar. Refusing is not a resting place either: - [*"I am not claiming through that contradiction"*](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070776624) - was a correct instinct and an incomplete move — the next step is to - read the events, state what they say, and then claim or stand down on - 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. - 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 - 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, - and its move is unchanged: unassign and restore `ready` (Claiming, - below). - The 2026-07-23 board is why the rule counts work and not claims: one - builder correctly held - [#15](https://github.com/heavy-duty/ceremony/issues/15) (`offsite`, - round answered whole, one verdict outstanding) and - [#16](https://github.com/heavy-duty/ceremony/issues/16) (`needs-ruling` - hard block, triage said hold) parked beside the one active build, - [#73](https://github.com/heavy-duty/ceremony/issues/73). + comments; acting on the labels against stale prose, say so in the claim + — name the events, their timestamps and their actor, and invite the + correction. Refusing to claim through the contradiction is not a + resting place either: where the events genuinely do not resolve it, say + so on the issue and pick the next `ready` issue. + Not parked: waiting on yourself, waiting on CI (a red head is your own + work; a pending one resolves without you), or waiting for a good moment. + An issue you have simply stopped working on is abandoned, not parked — + unassign and restore `ready` (Claiming, below). The rule counts work and + not claims because parked claims are legitimately held beside the one + active build (#15, #16, #73). ## 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 unless `offsite` - records that its PR lives in another repository. -- **A park is declared, never inferred.** When your claim enters a parked - shape (Picking, above), say so in a comment on that issue, naming what it - waits on and who owns the next move. No new label: the comment is - activity, so it feeds the same reclaim clock the `needs-ruling` - ([#52](https://github.com/heavy-duty/ceremony/issues/52)) and `offsite` - ([#68](https://github.com/heavy-duty/ceremony/issues/68)) exemptions - already guard — a parked claim nobody can name is an abandoned one. - Shape 4 alone is exempt from the separate comment: the factual handoff - comment plus the `state:needs-human` write *is* its declaration — both - 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 - `labeled` event with the comment beside it. No second comment is owed on - the issue. Every other shape still declares as above. - Declared once, the declaration **stands** until the park's facts change: - a resumption that finds nothing changed posts nothing — the standing - declaration is the record, and silence while parked is compliant, not - abandonment-shaped. Re-declaring on every resume is the flood - [rig#145](https://github.com/heavy-duty/rig/pull/145) drowned in — 38 - near-identical audits in one night, each saying nothing changed - ([#177](https://github.com/heavy-duty/ceremony/discussions/177)). What - re-opens the duty to comment is the facts changing — the named wait - resolves or changes hands, the parked shape changes, or the claim - unparks — and each owes one new comment. The one place silence has a - cost: a parked claim with **no open PR** still feeds the 48-hour - reclaim clock, so there the builder refreshes the declaration before - the window closes. That refresh is the only repeat a park ever owes, - and its cadence is the reclaim window's, not any duty loop's. None of - this loosens the abandonment rule below: a claim that was never parked - and has simply stopped moving is abandoned, not silent. -- **Pick up `attention` before anything else.** On your claim, first post a - short pickup comment and remove `attention`; the removal is the ack. A - demand on a parked claim is usually its unpark, so take the slot back under - the existing rule below rather than leaving the demand parked. A demand - that *is* the park is different: the pickup comment is the declaration, - so one comment does both jobs, and the demand does not take the slot back. -- **A directed hold keeps its bookkeeping visible.** The PR carries `blocked` - with a comment naming what it waits on; the issue stays `claimed` and - carries `attention` until the builder acknowledges it. Nobody unassigns - the issue, and the 48-hour reclaim does not fire because the claim has an - open PR. Unparking follows the existing rule below. -- **Unparking is a claim like any other.** When the wait ends, the parked - issue is work again and takes the slot. If you are already active - elsewhere, finish or release that work first, and say which you did on - both issues — the slot is still one. Nothing counts claims per builder - and no reconciler path enforces any of this: `claim_decision()` sees one - issue at a time by construction, and no such machinery should be built - expecting it to have been specified here. The discipline is the - declaration, not a counter. -- **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`. + starting. The claim promises a draft PR soon: a claim with no PR and no + activity is what the staleness sweep reclaims, unless `offsite` records + that its PR lives in another repository. +- **A park is declared, never inferred.** Comment on the issue naming what + the claim waits on and who owns the next move; no new label, because the + comment is the activity that feeds the same reclaim clock the + `needs-ruling` (#52) and `offsite` (#68) exemptions already guard. Shape 4 + alone owes no separate comment — the factual handoff comment plus the + `state:needs-human` write already name the wait (the merge) and its owner + (the human), both visible to any scan. +- **A declaration stands until the park's facts change.** A resumption that + finds nothing changed posts nothing, because re-declaring on every resume + floods the record with audits each saying nothing changed (#177); silence + while parked is compliant, not abandonment-shaped. One new comment is owed + each time the facts change — the named wait resolves or changes hands, the + parked shape changes, or the claim unparks. The one place silence costs: a + parked claim with **no open PR** still feeds the 48-hour reclaim clock, so + refresh the declaration before that window closes; that refresh is the + only repeat a park ever owes, at the reclaim window's cadence and not any + duty loop's. +- **Pick up `attention` before anything else.** Post a short pickup comment + and remove `attention`; the removal is the ack. A demand on a parked claim + is usually its unpark, so take the slot back rather than leaving the + demand parked — unless the demand *is* the park, where the pickup comment + doubles as the declaration and the slot stays free. +- **A directed hold keeps its bookkeeping visible.** The PR carries + `blocked` with a comment naming what it waits on; the issue stays + `claimed` and carries `attention` until the builder acknowledges it. + Nobody unassigns the issue, and the 48-hour reclaim does not fire because + the claim has an open PR. +- **Unparking is a claim like any other.** The parked issue is work again + and takes the slot; if you are already active elsewhere, finish or release + that work first and say which you did on both issues. Nothing counts + claims per builder and no reconciler path enforces any of this — the + discipline is the declaration, not a counter, and no such machinery should + be built expecting it to have been specified here. +- **Abandoning is fine; ghosting is not.** 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. `Closes #N` does not cross repos: when the PR is in a different repo - from its authorizing issue, use `Part of /#N` instead, and - in the same step set `offsite` and comment on that issue with the draft PR - link as soon as the draft opens. - Triage closes the authorizing issue by hand when its acceptance criteria - are met; at that handoff the builder reports whether the cross-repo PR - merged or closed and clears `offsite` in the same comment. The cross-repo - merge never closes the authorizing issue. This codifies the linkage - builders already used on rig#112 and ceremony #13/#16 rather than adding a - new review obligation. - `Closes #N` also does not survive a post-merge criterion: when the issue's - body states that an acceptance criterion can only be checked after the - merge — a live proof of a workflow trigger, a released-artifact check, - anything whose subject does not exist until the change is on the base - branch — the same-repo PR uses `Refs #N` instead, and triage closes the - issue by hand on the evidence, exactly as it does for cross-repo work. The + body. Drafts are invisible to the reviewer panel on purpose: the draft + phase is yours. +- **`Closes #N` does not cross repos.** A PR in a different repo from its + authorizing issue says `Part of /#N`, sets `offsite`, and + comments the draft PR link on that issue in the same step; triage closes + that issue by hand once its acceptance criteria are met, and at that + handoff the builder reports whether the PR merged or closed and clears + `offsite` in the same comment. The cross-repo merge never closes the + authorizing issue (#13, #16). +- **`Closes #N` does not survive a post-merge criterion.** Where the issue's + body states that a criterion can only be checked after the merge — a live + proof of a workflow trigger, a released-artifact check, anything whose + subject does not exist until the change is on the base branch — the + same-repo PR says `Refs #N` and triage closes by hand on the evidence. The merge releases the claim: the issue moves to `post-merge`, the builder - walks away, and triage owns verification and closure. If evidence later - requires corrective build work, triage returns it to `ready` or mints a - fresh `ready` issue; any builder claims from current `main`, and the - original builder has no special standing. - The issue body is what says so; you never judge which issues qualify, and - absent that instruction `Closes #N` remains the default. The exception was - bought the hard way: #143 carried `Closes #137` as doctrine then required, - and the merge closed #137 with its post-merge criterion unmet (#151). - On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, + walks away, and triage owns verification and closure; corrective work is a + fresh `ready` issue any builder claims from current `main`, the original + builder holding no special standing. The issue body is what says so — you + never judge which issues qualify, and absent that instruction `Closes #N` + remains the default (#151). +- On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`) - immediately before `#N` anywhere in the body — including the sentence - explaining why the PR does not close it. GitHub reads the whole body by - adjacency, not intent. Put the number first (`#N is closed by hand`) or - omit it (`triage closes the issue by hand`). A code span does not protect - the phrase: a backticked `Closes #199` still closed #199 (#200, #218). - Drafts are invisible to the reviewer panel on - purpose — the draft phase is yours. + immediately before `#N` anywhere in the body, including the sentence + explaining why the PR does not close it: GitHub reads the whole body by + adjacency, not intent, and a code span does not protect the phrase (#200, + #218). Put the number first (`#N is closed by hand`) or omit it. - **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 writes one fragment, `changelog.d/.md`, - named for the authorizing issue (`-.md` when the work is - cross-repo) — the exact prose that will be published, nothing else: `- ` - bullets, and in a grouped repo the `### Added` / `### Changed` / - `### Fixed` headings inside the fragment, creating a rarer kind only when - a change genuinely is one. An entry is at most 300 characters — the - fragment guard reds longer (#167) — so a genuinely long change ships - several short entries, never one long one; wrapping an entry over - continuation lines is fine and never counts against it. Every entry - **ends with its issue citation**, and the same guard reds an entry - without one: a single `(` group of `#N`, `repo#N` or `owner/repo#N` - references separated by `, `, then `)`, then the final `.` and nothing - after it — `(#262).` locally, `(#236, #250).` when one entry honestly - lands two. The citation need not name the fragment's own issue, because - the filename already carries the authorizing one (#262). Never edit - `CHANGELOG.md` for an entry — the - release PR assembles the section from the fragments (#112); the monotonic - guard still refuses anything that deletes a shipped heading. + them as a checklist in the PR body and check them honestly as you go; a + criterion that turns out wrong or unreachable goes back to triage to be + amended, never silently shipped short. +- **Every behavior change writes one fragment**, `changelog.d/.md`, + named for the authorizing issue (`-.md` cross-repo): the + exact prose that will be published and nothing else — `- ` bullets, and in + a grouped repo the `### Added` / `### Changed` / `### Fixed` headings + inside the fragment, a rarer kind only when a change genuinely is one. An + entry is at most 300 characters, so a genuinely long change ships several + short entries, never one long one; wrapping an entry over continuation + lines is fine and never counts against it (#167). Every entry **ends with + its issue citation**: a single `(` group of `#N`, `repo#N` or + `owner/repo#N` references separated by `, `, then `)`, then the final `.` + and nothing after it — `(#262).` locally, `(#236, #250).` when one entry + honestly lands two. The citation need not name the fragment's own issue, + which the filename already carries (#262). The fragment guard reds a + longer entry and an uncited one alike. Never edit `CHANGELOG.md` for an + entry: the release PR assembles the section from the fragments (#112), and + the monotonic guard refuses anything that deletes a shipped heading. - 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. - **A write-capable job gets a repo-owned script, not a third-party action.** - If the job's token can write (`packages: write`, `contents: write`, + Where the job's token can write (`packages: write`, `contents: write`, `id-token: write`, deploy secrets), default to a script in the repo that a test can drive; a third-party action there needs an established publisher - and a full-commit-SHA pin. Read-only jobs still SHA-pin. The full rule and - the red-flag profile a reviewer will apply are in REVIEWER.md §What you - review against, item 2 (incubator#53/#54; #216). + and a full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule + and the red-flag profile a reviewer will apply are in REVIEWER.md §What + you review against, item 2 (#216). - **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 — + 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. + PR, because a reviewer cannot converge on a widening target. ## The review round @@ -234,149 +177,105 @@ triage bug, and the move is to say so on the issue, not to guess. repo-specific facts such as the panel roster live in that repo's own CONTRIBUTING; the shared flow lives here and is not restated there.) -1. Mark ready-for-review; request **the whole panel**. The panel is the PR - repo's `panel[]=` line if it defines one, else its `panel=` - line; minus the author in either case (#224) — and never the roster of - the repo the issue is in. The PR repo's `.github/labels.conf` is the - machine's answer; its CONTRIBUTING roster is the human-readable answer, - and the conf governs if they disagree because that is what the state - machine reads. If the PR repo names no roster, ask triage on the - authorizing issue before marking ready-for-review; do not guess. You may - request an off-panel reviewer, but say that their verdict is advisory and - 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 - 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 ``"). 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), and it is read in two - steps, because a head carries more rollup entries than it has checks: - first pick the entry that is a check's word at this head, then - classify that entry. **A check's word at a head is its newest entry - by start time, and a `CANCELLED` entry is not that word while the - same check carries a non-cancelled entry at the same head.** The - survivor is the verdict about these bytes; the entry it displaced - reported nothing about them. Say **start** time and mean it: a - cancelled run does not stop the moment its replacement begins, so the - dead run's completion routinely postdates the live run's start, and a - reader who dates entries by completion picks the corpse. When *every* - entry a check has at the head is cancelled, nothing survives to be - its word: that check has not reported at all, and it stays not-green - by the classes below — the all-cancelled context is the case this - leaves exactly where it was. This states a collapse and not a new - class: `checks_state`'s carve-out drops a cancelled entry only where - its context keeps a non-cancelled survivor, and leaves an - all-cancelled context intact and still blocking, so doctrine and gate - partition alike on a mixed context (#139, #276). What the *machine* - drops from the rollup before it grades anything is a different - question, and crew's to describe rather than this file's. - Then classify that entry, and classify it from its **`conclusion`**, - never its `status`: a check carrying a terminal conclusion is green or - not-green by that conclusion whatever its `status` field still - reports — the two can disagree, and on #259 a finished job's `status` - lagged its own `conclusion: success` at the head. A check with no - conclusion at all is neither class: a configured run still in progress - is not green, and waiting for it is compliance, not a stall. Picking - the newest entry never settles a live one: where the survivor is the - run still going, the head is not green and you wait on it exactly as - you would have. A **cancelled or stale** check is not a green head — +1. Mark ready-for-review; request **the whole panel**: the PR repo's + `panel[]=` line if it defines one, else its `panel=` line, + minus the author in either case (#224) — never the roster of the repo the + issue is in. That repo's `.github/labels.conf` governs over its + CONTRIBUTING roster, being what the state machine reads; where the PR + repo names no roster, ask triage on the authorizing issue before marking + ready-for-review rather than guessing. You may request an off-panel + reviewer, saying that their verdict is advisory and does not become + required. + **A review request requires a green check at the head**, and this binds + you whether or not any engine enforces it: a red check is the author's + own signal, not the panel's work, so fix it and push, then request. 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 + only if the request says so explicitly and names the evidence ("the same + job fails identically on `origin/main` at ``"); silence about a red + check is what is prohibited, while an argued exception shifts the burden + to the author. + *Green* is a ruled term (operator, 2026-07-27), read in two steps, + because a head carries more rollup entries than it has checks. **First + pick the entry that is a check's word at this head: its newest entry by + start time, a `CANCELLED` entry never being that word while the same + check carries a non-cancelled entry at the same head** — say *start* and + mean it, since a cancelled run does not stop when its replacement begins + and a reader who dates entries by completion picks the corpse. Where + *every* entry a check has at the head is cancelled, nothing survives to + be its word: that check has not reported, and it stays not-green by the + classes below. That is a collapse and not a new class — the gate's + carve-out likewise drops a cancelled entry only where its context keeps a + non-cancelled survivor, leaving an all-cancelled context blocking, so + doctrine and gate partition alike on a mixed context (#139, #276). + **Then classify that entry from its `conclusion`, never its `status`**, + which can still disagree with it (#259). An entry with no conclusion is + neither class: a configured run still in progress is not green, and + waiting for it is compliance, not a stall, so picking the newest entry + never settles a live one. **Cancelled or stale** is not a green head — *stale* means a check belonging to a superseded head, which the head-scoped rollup does not show anyway, so what survives there is - same-head cancellation, never a same-head node whose `status` lags its - conclusion — 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. And a head - with **no checks configured** is the third ruled case, not an argued - exception: nothing is configured, so there is nothing to wait for — - the precondition is satisfied and the request goes out straight away, - no evidence or explanation owed, because the argued-exception path - above exists for a check that ran and came up red. This rules - nothing-configured, never nothing-answered-yet: a pending run has an - owner, CI, and is waited on as above. The machine partitions the same - way — `blocker:unrequested` admits the ask on `SUCCESS` and on `NONE` - alike (#236) — so doctrine and gate state one rule and each points at - the other. The costs behind the line are asymmetric: a false green - spends a three-reviewer round; a false red spends one author session. + same-head cancellation — while **skipped or neutral** *is* green, those + being deliberate "passed / not applicable" conclusions whose reddening + would red every conditional job the fleet skips on purpose. A head with + **no checks configured** is the third ruled case, not an argued + exception: nothing is configured, so there is nothing to wait for and the + request goes out straight away with no evidence owed, the + argued-exception path existing for a check that ran and came up red. That + rules nothing-configured, never nothing-answered-yet: a pending run has + an owner, CI, and is waited on as above, and the machine partitions the + same way, admitting the ask on `SUCCESS` and on `NONE` alike (#236). The + costs behind the line are asymmetric: a false green spends a + three-reviewer round; a false red spends one author session. What the + *machine* drops from the rollup before grading is crew's to describe. 2. **Wait for every verdict, then answer the round whole** — one reply - covering every point and stating what changed and what was verified. - That reply is the written round record: the engine mirrors it under the - 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 ``; 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 + covering every point and stating what changed and what was verified. That + reply is the written round record: the engine mirrors it under the PR + body's **Round log**, newest last, appending the author's comments posted + after the round's newest verdict with `` (an + existing marker makes a retry a no-op), so the builder owes the reply and + no separate body edit; a round the builder left unanswered is recorded as + such and never blocks handoff. + Then push the fixes, and re-request **by head, not by verdict**. A push + makes every approval 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 (#26, #39). Only where the head did not move — the round + answered with argument or evidence, nothing pushed — do you re-request + just the non-approvers, a standing approval already covering this exact + head and the engine absorbing a re-request at an unchanged one (#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. + green-check-at-head precondition as the first request**, argued exception + included: a fix push whose check comes up red is your next fix, not the + panel's. 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 — silence and force-forward are not options. A panel deadlock - is one kind of human-owned decision; use the ruling ask below - ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). + in the PR; silence and force-forward are not options, and a panel + deadlock is one kind of human-owned decision (#50 D11). -**A fix round may ride a draft.** An engine may convert a PR back to draft -when a round closes; crew#139 proposes exactly that, and is still an open -proposal. What it names is the status quo without it: where an engine's own -rules make a builder's mid-round pushes *saves* rather than proposals, every -one of those saves fires CI while the PR sits ready — 41 of 106 commits -across crew's last 25 PRs, by that issue's measurement — and converting back -to draft is what would stop them. Ceremony implements no such conversion and -this passage specifies none; it is written down because a builder or a -reviewer who meets a mid-round draft has to find a state the doctrine -describes. What it means is what a draft already meant while you were -building, extended and not changed: the draft phase is yours and the panel -cannot see it (Building, above). Whose ball it is does not change either — -the round outranks the draft, so you still owe it whole, the fixes and the -reply and the flip. The label axis says the same thing in the machine's -voice rather than in this one, and [LABELS.md](LABELS.md)'s `state:building` -row is where to read it (#205). - -**Ready-for-review is the act that ends the round, and it is the builder's -alone.** No engine marks a PR ready. The flip asserts that the round was -answered whole, and that assertion is the one judgement about a round its -author cannot delegate to a machine: an engine may draft a PR, which is what -crew#139 proposes engines do, but only the builder undrafts it. - -**Where a draft suppressed the checks, green is proven at the flip and the -request still follows it.** Step 1's precondition is the whole rule and this -adds no second one — it says only *when* the head answers: marking ready is -what runs the checks the draft held back, so the order is flip, let the head -answer, then request, and the argued exception stays the only way past a red -one. Waiting there is compliance, not a stall, and the machine reads it that -way too: `blocker:unrequested` does not fire while a head's checks are pending -or red, because the one blocker that demands an act has to know when the act -is permitted (#236 — crew#318 carried it at ~12:44Z on 2026-08-03 while its -head's run was still in progress, which is the label flagging a builder for -obeying this section). +**A fix round may ride a draft**, and the draft changes nothing about who +owes what. An engine may convert a PR back to draft when a round closes, so +that mid-round saves stop firing CI on a ready PR; ceremony implements no +such conversion and this passage specifies none, but whoever meets a +mid-round draft reads it as the draft always read — the draft phase is yours +and the panel cannot see it (Building, above) — while the round outranks the +draft, so you still owe it whole, the fixes and the reply and the flip +([LABELS.md](LABELS.md)'s `state:building` row says the same in the +machine's voice, #205). **Ready-for-review is the act that ends the round, +and it is the builder's alone**: the flip asserts that the round was +answered whole, which is the one judgement about a round its author cannot +delegate to a machine, so an engine may draft a PR but only the builder +undrafts it. **Where a draft suppressed the checks, green is proven at the +flip and the request still follows it**: marking ready is what runs the +checks the draft held back, so the order is flip, let the head answer, then +request — step 1's precondition and not a second one — and the argued +exception stays the only way past a red one. Waiting there is compliance, +not a stall, and the machine reads it the same way: `blocker:unrequested` +does not fire while a head's checks are pending or red, because the one +blocker that demands an act has to know when the act is permitted (#236). ## The ruling ask @@ -384,12 +283,11 @@ Set `needs-ruling` whenever a decision belongs to a human: org policy, published artifacts, secrets, prod, or any choice whose cost lands outside the PR. A panel deadlock is one instance, not the definition. The builder is the accountable flag-setter on a PR and consolidates the decision into one -comment rather than forwarding several reviewers' phrasings -([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). +comment rather than forwarding several reviewers' phrasings (#50 D11). Keep at most these five lines above the fold and put all other analysis inside the fold. The field labels are fixed because the ruling machinery -checks for them ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50)): +checks for them (#50 D12): ```text 🧭 needs-ruling — @@ -406,11 +304,10 @@ whole problem to the human. `Blocked:` names both what stops and what continues. Write a timed `Default:` only when you are affirmatively confident the decision is reversible inside the PR before merge. Unsure is not a tie: it is a hard block. Published artifacts, secrets, prod, and org policy are -hard blocks by construction ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)). +hard blocks by construction (#50 D12–D13). The ladder is anchored to the current episode's `needs-ruling` **`labeled` -event**, not its `Default:` deadline or the last activity -([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)): +event**, not its `Default:` deadline or the last activity (#50 D13–D14): - **0–12h:** proceed when a still-clear, reversible default expires, and say out loud that you did. A hard block waits. @@ -446,10 +343,9 @@ builder's behalf, in order: 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 -lives in the Round log, mirrored mechanically from each whole-round reply as -specified above. 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. The claim is now parked as shape 4 (Picking, -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. +lives in the Round log, mirrored mechanically from each whole-round reply. +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, and the claim is now parked as shape 4 (Picking, above), the +handoff comment being its declaration and your build slot free. Address what +comes back (`state:addressing`) and re-hand-off the same way. diff --git a/changelog.d/281.md b/changelog.d/281.md new file mode 100644 index 0000000..f53fca6 --- /dev/null +++ b/changelog.d/281.md @@ -0,0 +1,5 @@ +### Changed + +- `BUILDER.md` states its rules and cites their record bare: the incident + narratives, the links into issue comments and the cross-repo issue cites + leave the normative text, which no rule leaves with them (#281). From e3ec95bb37aff41759f4f9cc142b1db1ae2eaa27 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:14:35 +0000 Subject: [PATCH 103/162] =?UTF-8?q?docs(builder):=20WIP=20=E2=80=94=20tigh?= =?UTF-8?q?ter=20register=20across=20every=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUILDER.md | 321 +++++++++++++++++++++++++---------------------------- 1 file changed, 150 insertions(+), 171 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index a8f7620..5c95465 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -12,58 +12,53 @@ triage bug, and the move is to say so on the issue, not to guess. prefer the issue that unblocks the most other work. Where a repository adopts version epics, [RELEASES.md](RELEASES.md) governs the choice among release-window members. -- **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; a red head - that owes no round and holds no conflict is otherwise nobody's next move, - and the PR strands mergeable (#163). The recovery path: 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 hand off once the check is green and current-head approvals - stand. Such a PR is **not parked** — the next move is yours whatever the - round's verdict state says. Red and green are the review round's ruled - terms below; how the engine detects a red head is crew's to describe, not - this file's. -- **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 - starting new work. The rule counts build work in flight, not claims: a - **parked** claim, one whose next move belongs to someone else, does not - consume the slot. Exactly five shapes park: - 1. the issue carries `needs-ruling`, its escalation names a decider, and - its `Blocked:` line stops the remaining work; - 2. the deliverable is in a **live** review round, every outstanding +- **Your own red head outranks a new claim.** Pick up a failing check at the + head of a PR you authored before claiming another issue: a red head that + owes no round and holds no conflict is otherwise nobody's next move, and + the PR strands mergeable (#163). Record the failing check and its failure + class; rerun a clearly retryable infrastructure failure without changing + code; return to the normal fix-round and worklog discipline when the + failure belongs to the branch; 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; hand off once + the check is green and current-head approvals stand. Such a PR is **not + parked**, whatever the round's verdict state says. Red and green are the + review round's ruled terms below; how the engine detects a red head is + crew's to describe, not this file's. +- **One build at a time**: at most one issue on which you are writing or + revising a deliverable, finished or released before you start new work. + The rule counts build work in flight, not claims — a **parked** claim, + whose next move belongs to someone else, does not consume the slot. + Exactly five shapes park: + 1. `needs-ruling` is set, the escalation names a decider, and its + `Blocked:` line stops the remaining work; + 2. a **live** review round holds the deliverable, every outstanding verdict someone else's — awaiting its first verdicts, or answered whole with the owed re-requests posted, by head and not by verdict (steps 1–2 - below). A red check at the current head takes it **out of this shape**: - that state reads as waiting on the panel and is not, the next move is - yours, and reading it as parked strands the PR; + below). A red check at the current head takes it out of this shape: the + next move is yours, and reading that as parked strands the PR; 3. every remaining acceptance criterion is operator-owned, stated as such by triage on the issue; 4. the deliverable is **handed off** — the round passed, no `blocker:*` - stands, `state:needs-human` is set per Handoff (below), and the merge - is the human's. Shapes 2 and 4 are sequential and never overlap; + stands, `state:needs-human` is set per Handoff, and the merge is the + human's. Shapes 2 and 4 are sequential and never overlap; 5. the claim is **held by directive** — triage or the operator stopped the - work, the direction names what the hold waits on, and only they end it; - this is never "waiting for a good moment". A hold ends the way it - started, **on the labels**: where the queue labels and any prose - disagree about whether it stands, the most recent queue-label event by - the hold's owner governs and the prose is stale until corrected, an - operator being free to lift by label alone (#149, #151). So before - standing down *or* standing up, read the issue's **label events** + work, the direction names what the hold waits on, and only they end it. + A hold ends the way it started, **on the labels**: where labels and + prose disagree the most recent queue-label event by the hold's owner + governs, an operator being free to lift by label alone (#149, #151). So + read the issue's label events (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not only its - comments; acting on the labels against stale prose, say so in the claim - — name the events, their timestamps and their actor, and invite the - correction. Refusing to claim through the contradiction is not a - resting place either: where the events genuinely do not resolve it, say - so on the issue and pick the next `ready` issue. - Not parked: waiting on yourself, waiting on CI (a red head is your own - work; a pending one resolves without you), or waiting for a good moment. - An issue you have simply stopped working on is abandoned, not parked — - unassign and restore `ready` (Claiming, below). The rule counts work and - not claims because parked claims are legitimately held beside the one - active build (#15, #16, #73). + comments, before standing down *or* standing up; where you act on the + labels against stale prose, say so in the claim — name the events, their + timestamps and their actor, and invite the correction. Refusing to claim + through the contradiction is no resting place either: where the events + do not resolve it, say so on the issue and pick the next `ready` issue. + Not parked: waiting on yourself, on CI (a red head is your own work; a + pending one resolves without you), or for a good moment. An issue you have + simply stopped working on is abandoned, not parked — unassign and restore + `ready`. The rule counts work and not claims because parked claims are + legitimately held beside the one active build (#15, #16, #73). ## Claiming @@ -72,38 +67,34 @@ triage bug, and the move is to say so on the issue, not to guess. activity is what the staleness sweep reclaims, unless `offsite` records that its PR lives in another repository. - **A park is declared, never inferred.** Comment on the issue naming what - the claim waits on and who owns the next move; no new label, because the - comment is the activity that feeds the same reclaim clock the - `needs-ruling` (#52) and `offsite` (#68) exemptions already guard. Shape 4 - alone owes no separate comment — the factual handoff comment plus the - `state:needs-human` write already name the wait (the merge) and its owner - (the human), both visible to any scan. + the claim waits on and who owns the next move; no new label, the comment + being the activity that feeds the same reclaim clock the `needs-ruling` + (#52) and `offsite` (#68) exemptions guard. Shape 4 owes no separate + comment: the handoff comment plus the `state:needs-human` write already + name the wait (the merge) and its owner (the human). - **A declaration stands until the park's facts change.** A resumption that finds nothing changed posts nothing, because re-declaring on every resume - floods the record with audits each saying nothing changed (#177); silence - while parked is compliant, not abandonment-shaped. One new comment is owed - each time the facts change — the named wait resolves or changes hands, the - parked shape changes, or the claim unparks. The one place silence costs: a - parked claim with **no open PR** still feeds the 48-hour reclaim clock, so + floods the record with audits each saying nothing changed (#177). One new + comment is owed each time the facts change — the named wait resolves or + changes hands, the parked shape changes, or the claim unparks. A parked + claim with **no open PR** still feeds the 48-hour reclaim clock, so refresh the declaration before that window closes; that refresh is the - only repeat a park ever owes, at the reclaim window's cadence and not any - duty loop's. + only repeat a park owes, at the reclaim window's cadence. - **Pick up `attention` before anything else.** Post a short pickup comment and remove `attention`; the removal is the ack. A demand on a parked claim - is usually its unpark, so take the slot back rather than leaving the - demand parked — unless the demand *is* the park, where the pickup comment - doubles as the declaration and the slot stays free. + is usually its unpark, so take the slot back — unless the demand *is* the + park, where the pickup comment doubles as the declaration and the slot + stays free. - **A directed hold keeps its bookkeeping visible.** The PR carries `blocked` with a comment naming what it waits on; the issue stays `claimed` and carries `attention` until the builder acknowledges it. Nobody unassigns the issue, and the 48-hour reclaim does not fire because the claim has an open PR. -- **Unparking is a claim like any other.** The parked issue is work again - and takes the slot; if you are already active elsewhere, finish or release - that work first and say which you did on both issues. Nothing counts - claims per builder and no reconciler path enforces any of this — the - discipline is the declaration, not a counter, and no such machinery should - be built expecting it to have been specified here. +- **Unparking is a claim like any other** and takes the slot: if you are + active elsewhere, finish or release that work first and say which you did + on both issues. Nothing counts claims per builder and no reconciler path + enforces this — the discipline is the declaration, not a counter, and no + such machinery should be built expecting it to have been specified here. - **Abandoning is fine; ghosting is not.** Say where you got to, push the branch if it holds anything useful, unassign, and restore `ready`. @@ -114,48 +105,47 @@ triage bug, and the move is to say so on the issue, not to guess. phase is yours. - **`Closes #N` does not cross repos.** A PR in a different repo from its authorizing issue says `Part of /#N`, sets `offsite`, and - comments the draft PR link on that issue in the same step; triage closes - that issue by hand once its acceptance criteria are met, and at that - handoff the builder reports whether the PR merged or closed and clears - `offsite` in the same comment. The cross-repo merge never closes the - authorizing issue (#13, #16). + comments the draft PR link on that issue in the same step. Triage closes + that issue by hand once its acceptance criteria are met; at that handoff + the builder reports whether the PR merged or closed and clears `offsite` + in the same comment. The cross-repo merge never closes the authorizing + issue (#13, #16). - **`Closes #N` does not survive a post-merge criterion.** Where the issue's body states that a criterion can only be checked after the merge — a live proof of a workflow trigger, a released-artifact check, anything whose subject does not exist until the change is on the base branch — the same-repo PR says `Refs #N` and triage closes by hand on the evidence. The merge releases the claim: the issue moves to `post-merge`, the builder - walks away, and triage owns verification and closure; corrective work is a - fresh `ready` issue any builder claims from current `main`, the original - builder holding no special standing. The issue body is what says so — you - never judge which issues qualify, and absent that instruction `Closes #N` - remains the default (#151). + walks away, and triage owns verification and closure, returning it to + `ready` or minting a fresh issue where corrective work is needed, which + any builder claims from current `main`. The issue body is what says so — + you never judge which issues qualify, and absent that instruction + `Closes #N` is the default (#151). - On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`) immediately before `#N` anywhere in the body, including the sentence - explaining why the PR does not close it: GitHub reads the whole body by + explaining why the PR does not close it: GitHub reads the body by adjacency, not intent, and a code span does not protect the phrase (#200, #218). Put the number first (`#N is closed by hand`) or omit it. - **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; a criterion that turns out wrong or unreachable goes back to triage to be amended, never silently shipped short. -- **Every behavior change writes one fragment**, `changelog.d/.md`, +- **Every behavior change writes one fragment**, `changelog.d/.md` named for the authorizing issue (`-.md` cross-repo): the - exact prose that will be published and nothing else — `- ` bullets, and in - a grouped repo the `### Added` / `### Changed` / `### Fixed` headings - inside the fragment, a rarer kind only when a change genuinely is one. An - entry is at most 300 characters, so a genuinely long change ships several - short entries, never one long one; wrapping an entry over continuation - lines is fine and never counts against it (#167). Every entry **ends with - its issue citation**: a single `(` group of `#N`, `repo#N` or - `owner/repo#N` references separated by `, `, then `)`, then the final `.` - and nothing after it — `(#262).` locally, `(#236, #250).` when one entry - honestly lands two. The citation need not name the fragment's own issue, - which the filename already carries (#262). The fragment guard reds a - longer entry and an uncited one alike. Never edit `CHANGELOG.md` for an - entry: the release PR assembles the section from the fragments (#112), and - the monotonic guard refuses anything that deletes a shipped heading. + exact prose to be published and nothing else — `- ` bullets, plus in a + grouped repo the `### Added` / `### Changed` / `### Fixed` headings inside + the fragment, a rarer kind only where a change genuinely is one. An entry + is at most 300 characters, so a genuinely long change ships several short + entries; wrapping one over continuation lines never counts against it. It + **ends with its issue citation**: one `(` group of `#N`, `repo#N` or + `owner/repo#N` separated by `, `, then `)`, then the final `.` and nothing + after it — `(#262).`, or `(#236, #250).` where an entry honestly lands + two — and it need not name the fragment's own issue, which the filename + carries. The fragment guard reds a longer entry (#167) and an uncited one + (#262) alike. Never edit `CHANGELOG.md` for an entry: the release PR + assembles the section from the fragments (#112), and the monotonic guard + refuses anything that deletes a shipped heading. - 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. - **A write-capable job gets a repo-owned script, not a third-party action.** @@ -163,19 +153,19 @@ triage bug, and the move is to say so on the issue, not to guess. `id-token: write`, deploy secrets), default to a script in the repo that a test can drive; a third-party action there needs an established publisher and a full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule - and the red-flag profile a reviewer will apply are in REVIEWER.md §What - you review against, item 2 (#216). + and the red-flag profile a reviewer applies are in REVIEWER.md §What you + review against, item 2 (#216). - **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, because a reviewer cannot converge on a widening target. + Adjacent problems go to a **discussion**, or a comment on the relevant + issue, where triage does its job. You do not mint issues — nobody but + triage does — and you do not fix drive-by findings in the same PR, because + a reviewer cannot converge on a widening target. ## The review round -(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: -repo-specific facts such as the panel roster live in that repo's own -CONTRIBUTING; the shared flow lives here and is not restated there.) +(Read as `.ceremony/BUILDER.md` in a governed repo: repo-specific facts such +as the panel roster live in that repo's own CONTRIBUTING, and the shared +flow lives here.) 1. Mark ready-for-review; request **the whole panel**: the PR repo's `panel[]=` line if it defines one, else its `panel=` line, @@ -186,70 +176,61 @@ CONTRIBUTING; the shared flow lives here and is not restated there.) ready-for-review rather than guessing. You may request an off-panel reviewer, saying that their verdict is advisory and does not become required. - **A review request requires a green check at the head**, and this binds + **A review request requires a green check at the head**, and that binds you whether or not any engine enforces it: a red check is the author's own signal, not the panel's work, so fix it and push, then request. 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 - only if the request says so explicitly and names the evidence ("the same - job fails identically on `origin/main` at ``"); silence about a red - check is what is prohibited, while an argued exception shifts the burden - to the author. + only where the request says so explicitly and names the evidence ("the + same job fails identically on `origin/main` at ``"). 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), read in two steps, because a head carries more rollup entries than it has checks. **First - pick the entry that is a check's word at this head: its newest entry by - start time, a `CANCELLED` entry never being that word while the same - check carries a non-cancelled entry at the same head** — say *start* and - mean it, since a cancelled run does not stop when its replacement begins - and a reader who dates entries by completion picks the corpse. Where - *every* entry a check has at the head is cancelled, nothing survives to - be its word: that check has not reported, and it stays not-green by the - classes below. That is a collapse and not a new class — the gate's - carve-out likewise drops a cancelled entry only where its context keeps a - non-cancelled survivor, leaving an all-cancelled context blocking, so - doctrine and gate partition alike on a mixed context (#139, #276). + find the check's word at this head**: its newest entry by start time, + except that a `CANCELLED` entry is never the word while the same check + has a non-cancelled entry at that head. Date entries by start and not by + completion — a cancelled run outlives its replacement's start, so the + other reading picks the corpse. A check whose every entry at the head is + cancelled has not reported at all and stays not-green by the classes + below; that is a collapse, not a new class, and the gate partitions the + same way, dropping a cancelled entry only where its context keeps a + non-cancelled survivor (#139, #276). **Then classify that entry from its `conclusion`, never its `status`**, - which can still disagree with it (#259). An entry with no conclusion is - neither class: a configured run still in progress is not green, and - waiting for it is compliance, not a stall, so picking the newest entry - never settles a live one. **Cancelled or stale** is not a green head — - *stale* means a check belonging to a superseded head, which the - head-scoped rollup does not show anyway, so what survives there is - same-head cancellation — while **skipped or neutral** *is* green, those - being deliberate "passed / not applicable" conclusions whose reddening - would red every conditional job the fleet skips on purpose. A head with - **no checks configured** is the third ruled case, not an argued - exception: nothing is configured, so there is nothing to wait for and the - request goes out straight away with no evidence owed, the - argued-exception path existing for a check that ran and came up red. That - rules nothing-configured, never nothing-answered-yet: a pending run has - an owner, CI, and is waited on as above, and the machine partitions the - same way, admitting the ask on `SUCCESS` and on `NONE` alike (#236). The - costs behind the line are asymmetric: a false green spends a - three-reviewer round; a false red spends one author session. What the - *machine* drops from the rollup before grading is crew's to describe. + which can still disagree with it (#259). No conclusion at all is neither + class: a configured run still in progress is not green, and waiting on it + is compliance, not a stall. **Cancelled or stale** is not green — *stale* + means a superseded head's check, which a head-scoped rollup never shows, + so what survives there is same-head cancellation. **Skipped or neutral** + *is* green: those are deliberate "passed / not applicable" conclusions, + and reddening them would red every conditional job the fleet skips on + purpose. **No checks configured** is the third ruled case, not an argued + exception: nothing is configured, so nothing is waited for and the + request goes out at once, no evidence owed. That rules + nothing-configured, never nothing-answered-yet — a pending run has an + owner, CI — and the machine partitions alike, admitting the ask on + `SUCCESS` and on `NONE` (#236). The costs behind the line are asymmetric: + a false green spends a three-reviewer round, a false red one author + session. What the *machine* drops from the rollup before grading is + crew's to describe. 2. **Wait for every verdict, then answer the round whole** — one reply covering every point and stating what changed and what was verified. That reply is the written round record: the engine mirrors it under the PR - body's **Round log**, newest last, appending the author's comments posted - after the round's newest verdict with `` (an - existing marker makes a retry a no-op), so the builder owes the reply and - no separate body edit; a round the builder left unanswered is recorded as - such and never blocks handoff. - Then push the fixes, and re-request **by head, not by verdict**. A push + body's **Round log**, newest last, so the builder owes the reply and no + separate body edit, and a round answered without one is recorded as such + and never blocks handoff. + Then push the fixes and re-request **by head, not by verdict**. A push makes every approval 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 (#26, #39). Only where the head did not move — the round - answered with argument or evidence, nothing pushed — do you re-request - just the non-approvers, a standing approval already covering this exact - head and the engine absorbing a re-request at an unchanged one (#94; its - mechanism is crew's to describe). **The re-request carries the same + panelist is re-requested, the approvers included**; one left + un-re-requested can never approve the tree you shipped, and the PR sits + looking finished with nothing owed by anyone (#26, #39). Only where the + head did not move — the round answered with argument or evidence, nothing + pushed — do you re-request just the non-approvers, a standing approval + already covering this exact head (#94). **The re-request carries the same green-check-at-head precondition as the first request**, argued exception included: a fix push whose check comes up red is your next fix, not the - panel's. Prefer verification over argument — when a reviewer doubts + panel's. Prefer verification over argument — where 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 @@ -260,22 +241,20 @@ CONTRIBUTING; the shared flow lives here and is not restated there.) owes what. An engine may convert a PR back to draft when a round closes, so that mid-round saves stop firing CI on a ready PR; ceremony implements no such conversion and this passage specifies none, but whoever meets a -mid-round draft reads it as the draft always read — the draft phase is yours -and the panel cannot see it (Building, above) — while the round outranks the -draft, so you still owe it whole, the fixes and the reply and the flip -([LABELS.md](LABELS.md)'s `state:building` row says the same in the -machine's voice, #205). **Ready-for-review is the act that ends the round, -and it is the builder's alone**: the flip asserts that the round was -answered whole, which is the one judgement about a round its author cannot -delegate to a machine, so an engine may draft a PR but only the builder +mid-round draft reads it as a draft always read — the draft phase is yours +and the panel cannot see it — while the round outranks the draft, so you +still owe it whole, the fixes and the reply and the flip +([LABELS.md](LABELS.md)'s `state:building` row, #205). **Ready-for-review is +the act that ends the round, and it is the builder's alone**: the flip +asserts that the round was answered whole, the one judgement about a round +its author cannot delegate, so an engine may draft a PR but only the builder undrafts it. **Where a draft suppressed the checks, green is proven at the flip and the request still follows it**: marking ready is what runs the checks the draft held back, so the order is flip, let the head answer, then -request — step 1's precondition and not a second one — and the argued -exception stays the only way past a red one. Waiting there is compliance, -not a stall, and the machine reads it the same way: `blocker:unrequested` -does not fire while a head's checks are pending or red, because the one -blocker that demands an act has to know when the act is permitted (#236). +request — step 1's precondition, not a second one. Waiting there is +compliance, not a stall, and `blocker:unrequested` does not fire while a +head's checks are pending or red, because the one blocker that demands an +act has to know when the act is permitted (#236). ## The ruling ask @@ -343,9 +322,9 @@ builder's behalf, in order: 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 -lives in the Round log, mirrored mechanically from each whole-round reply. -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, and the claim is now parked as shape 4 (Picking, above), the -handoff comment being its declaration and your build slot free. Address what -comes back (`state:addressing`) and re-hand-off the same way. +lives in the Round log, mirrored from each whole-round reply. 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, and the +claim is parked as shape 4 (Picking, above), that handoff comment being its +declaration and your build slot free. Address what comes back +(`state:addressing`) and re-hand-off the same way. From d25cbb04b76108961acb22ef6eb5470e83f1ec87 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:16:37 +0000 Subject: [PATCH 104/162] =?UTF-8?q?docs(builder):=20WIP=20=E2=80=94=20furt?= =?UTF-8?q?her=20compression,=20ruling=20ladder=20tightened?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUILDER.md | 144 ++++++++++++++++++++++++----------------------------- 1 file changed, 66 insertions(+), 78 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index 5c95465..d3e055e 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -13,18 +13,17 @@ triage bug, and the move is to say so on the issue, not to guess. adopts version epics, [RELEASES.md](RELEASES.md) governs the choice among release-window members. - **Your own red head outranks a new claim.** Pick up a failing check at the - head of a PR you authored before claiming another issue: a red head that - owes no round and holds no conflict is otherwise nobody's next move, and - the PR strands mergeable (#163). Record the failing check and its failure + head of a PR you authored before claiming another issue, or it strands + mergeable and unattended (#163). Record the failing check and its failure class; rerun a clearly retryable infrastructure failure without changing - code; return to the normal fix-round and worklog discipline when the - failure belongs to the branch; leave visible evidence when a rerun cannot + code; return to the normal fix-round and worklog discipline where the + failure belongs to the branch; leave visible evidence where a rerun cannot be started or the cause is uncertain; never repeatedly rerun a deterministic branch failure without a corrective commit; hand off once the check is green and current-head approvals stand. Such a PR is **not parked**, whatever the round's verdict state says. Red and green are the - review round's ruled terms below; how the engine detects a red head is - crew's to describe, not this file's. + ruled terms of the review round below; how the engine detects a red head + is crew's to describe, not this file's. - **One build at a time**: at most one issue on which you are writing or revising a deliverable, finished or released before you start new work. The rule counts build work in flight, not claims — a **parked** claim, @@ -45,20 +44,19 @@ triage bug, and the move is to say so on the issue, not to guess. 5. the claim is **held by directive** — triage or the operator stopped the work, the direction names what the hold waits on, and only they end it. A hold ends the way it started, **on the labels**: where labels and - prose disagree the most recent queue-label event by the hold's owner + prose disagree, the most recent queue-label event by the hold's owner governs, an operator being free to lift by label alone (#149, #151). So - read the issue's label events - (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not only its - comments, before standing down *or* standing up; where you act on the - labels against stale prose, say so in the claim — name the events, their - timestamps and their actor, and invite the correction. Refusing to claim - through the contradiction is no resting place either: where the events - do not resolve it, say so on the issue and pick the next `ready` issue. + read the label events (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), + not only the comments, before standing down *or* standing up, and where + you act against stale prose say so in the claim, naming the events, + their timestamps and their actor. Refusing to claim through the + contradiction is no resting place: where the events do not resolve it, + say so on the issue and take the next `ready` issue. Not parked: waiting on yourself, on CI (a red head is your own work; a pending one resolves without you), or for a good moment. An issue you have simply stopped working on is abandoned, not parked — unassign and restore - `ready`. The rule counts work and not claims because parked claims are - legitimately held beside the one active build (#15, #16, #73). + `ready`. The rule counts work because parked claims are legitimately held + beside the one active build (#15, #16, #73). ## Claiming @@ -106,10 +104,9 @@ triage bug, and the move is to say so on the issue, not to guess. - **`Closes #N` does not cross repos.** A PR in a different repo from its authorizing issue says `Part of /#N`, sets `offsite`, and comments the draft PR link on that issue in the same step. Triage closes - that issue by hand once its acceptance criteria are met; at that handoff - the builder reports whether the PR merged or closed and clears `offsite` - in the same comment. The cross-repo merge never closes the authorizing - issue (#13, #16). + that issue by hand once its acceptance criteria are met, and at that + handoff the builder reports whether the PR merged or closed and clears + `offsite` in the same comment (#13, #16). - **`Closes #N` does not survive a post-merge criterion.** Where the issue's body states that a criterion can only be checked after the merge — a live proof of a workflow trigger, a released-artifact check, anything whose @@ -117,10 +114,10 @@ triage bug, and the move is to say so on the issue, not to guess. same-repo PR says `Refs #N` and triage closes by hand on the evidence. The merge releases the claim: the issue moves to `post-merge`, the builder walks away, and triage owns verification and closure, returning it to - `ready` or minting a fresh issue where corrective work is needed, which - any builder claims from current `main`. The issue body is what says so — - you never judge which issues qualify, and absent that instruction - `Closes #N` is the default (#151). + `ready` or minting a fresh issue for corrective work that any builder + claims from current `main`. The issue body is what says so — you never + judge which issues qualify, and absent that instruction `Closes #N` is the + default (#151). - On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`) immediately before `#N` anywhere in the body, including the sentence @@ -140,12 +137,12 @@ triage bug, and the move is to say so on the issue, not to guess. entries; wrapping one over continuation lines never counts against it. It **ends with its issue citation**: one `(` group of `#N`, `repo#N` or `owner/repo#N` separated by `, `, then `)`, then the final `.` and nothing - after it — `(#262).`, or `(#236, #250).` where an entry honestly lands - two — and it need not name the fragment's own issue, which the filename - carries. The fragment guard reds a longer entry (#167) and an uncited one - (#262) alike. Never edit `CHANGELOG.md` for an entry: the release PR - assembles the section from the fragments (#112), and the monotonic guard - refuses anything that deletes a shipped heading. + after it — `(#262).`, or `(#236, #250).` where an entry honestly lands two + — and it need not name the fragment's own issue, which the filename + carries. The guard reds a longer entry (#167) and an uncited one (#262) + alike. Never edit `CHANGELOG.md` for an entry: the release PR assembles + the section from the fragments (#112), and the monotonic guard refuses + anything that deletes a shipped heading. - 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. - **A write-capable job gets a repo-owned script, not a third-party action.** @@ -163,39 +160,32 @@ triage bug, and the move is to say so on the issue, not to guess. ## The review round -(Read as `.ceremony/BUILDER.md` in a governed repo: repo-specific facts such -as the panel roster live in that repo's own CONTRIBUTING, and the shared -flow lives here.) - 1. Mark ready-for-review; request **the whole panel**: the PR repo's `panel[]=` line if it defines one, else its `panel=` line, minus the author in either case (#224) — never the roster of the repo the issue is in. That repo's `.github/labels.conf` governs over its CONTRIBUTING roster, being what the state machine reads; where the PR - repo names no roster, ask triage on the authorizing issue before marking - ready-for-review rather than guessing. You may request an off-panel - reviewer, saying that their verdict is advisory and does not become - required. + repo names no roster, ask triage on the authorizing issue rather than + guessing. An off-panel reviewer may be requested, saying that their + verdict is advisory and does not become required. **A review request requires a green check at the head**, and that binds you whether or not any engine enforces it: a red check is the author's own signal, not the panel's work, so fix it and push, then request. 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 only where the request says so explicitly and names the evidence ("the - same job fails identically on `origin/main` at ``"). Silence about a - red check is what is prohibited; an argued exception shifts the burden to - the author. + same job fails identically on `origin/main` at ``"); silence about a + red check is what is prohibited. *Green* is a ruled term (operator, 2026-07-27), read in two steps, because a head carries more rollup entries than it has checks. **First find the check's word at this head**: its newest entry by start time, except that a `CANCELLED` entry is never the word while the same check - has a non-cancelled entry at that head. Date entries by start and not by + has a non-cancelled entry at that head. Date entries by start, not by completion — a cancelled run outlives its replacement's start, so the other reading picks the corpse. A check whose every entry at the head is - cancelled has not reported at all and stays not-green by the classes - below; that is a collapse, not a new class, and the gate partitions the - same way, dropping a cancelled entry only where its context keeps a - non-cancelled survivor (#139, #276). + cancelled has not reported and stays not-green by the classes below; that + is a collapse and not a new class, the gate likewise dropping a cancelled + entry only where its context keeps a non-cancelled survivor (#139, #276). **Then classify that entry from its `conclusion`, never its `status`**, which can still disagree with it (#259). No conclusion at all is neither class: a configured run still in progress is not green, and waiting on it @@ -228,10 +218,10 @@ flow lives here.) head did not move — the round answered with argument or evidence, nothing pushed — do you re-request just the non-approvers, a standing approval already covering this exact head (#94). **The re-request carries the same - green-check-at-head precondition as the first request**, argued exception - included: a fix push whose check comes up red is your next fix, not the - panel's. Prefer verification over argument — where a reviewer doubts - behavior, add the test that settles it. + green-check-at-head precondition**, argued exception included: a fix push + whose check comes up red is your next fix, not the panel's. Prefer + verification over argument — where 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; silence and force-forward are not options, and a panel @@ -240,21 +230,20 @@ flow lives here.) **A fix round may ride a draft**, and the draft changes nothing about who owes what. An engine may convert a PR back to draft when a round closes, so that mid-round saves stop firing CI on a ready PR; ceremony implements no -such conversion and this passage specifies none, but whoever meets a -mid-round draft reads it as a draft always read — the draft phase is yours -and the panel cannot see it — while the round outranks the draft, so you -still owe it whole, the fixes and the reply and the flip -([LABELS.md](LABELS.md)'s `state:building` row, #205). **Ready-for-review is -the act that ends the round, and it is the builder's alone**: the flip -asserts that the round was answered whole, the one judgement about a round -its author cannot delegate, so an engine may draft a PR but only the builder -undrafts it. **Where a draft suppressed the checks, green is proven at the -flip and the request still follows it**: marking ready is what runs the -checks the draft held back, so the order is flip, let the head answer, then -request — step 1's precondition, not a second one. Waiting there is -compliance, not a stall, and `blocker:unrequested` does not fire while a -head's checks are pending or red, because the one blocker that demands an -act has to know when the act is permitted (#236). +such conversion, but whoever meets a mid-round draft reads it as a draft +always read — the draft phase is yours and the panel cannot see it — while +the round outranks the draft, so you still owe it whole, the fixes and the +reply and the flip ([LABELS.md](LABELS.md)'s `state:building` row, #205). +**Ready-for-review is the act that ends the round, and it is the builder's +alone**: the flip asserts that the round was answered whole, the one +judgement about a round its author cannot delegate, so an engine may draft a +PR but only the builder undrafts it. **Where a draft suppressed the checks, +green is proven at the flip and the request still follows it**: marking +ready is what runs the checks the draft held back, so the order is flip, let +the head answer, then request — step 1's precondition, not a second one — +and waiting there is compliance, not a stall, which is why +`blocker:unrequested` does not fire while a head's checks are pending or red +(#236). ## The ruling ask @@ -289,21 +278,20 @@ The ladder is anchored to the current episode's `needs-ruling` **`labeled` event**, not its `Default:` deadline or the last activity (#50 D13–D14): - **0–12h:** proceed when a still-clear, reversible default expires, and say - out loud that you did. A hard block waits. -- **at 12h:** do not fire a stale default. Re-read it against what has landed - and ask whether it still holds and whether reasonable doubt remains. If - doubt has appeared, make it a hard block. -- **at 24h:** proceed regardless, **as a PR**. Pick an option and state in the - PR body which way you went and what doubt remains. Nothing merges by this; - the human still gates the merge. -- **past 24h:** hand the choice to triage. Triage picks the option, records it - as a decision, and remains accountable; the operator can overturn it at + out loud that you did; a hard block waits. +- **at 12h:** do not fire a stale default — re-read it against what has + landed, and where doubt has appeared, make it a hard block. +- **at 24h:** proceed regardless, **as a PR**: pick an option and state in + the PR body which way you went and what doubt remains. Nothing merges by + this; the human still gates the merge. +- **past 24h:** hand the choice to triage, which picks the option, records + it as a decision, and remains accountable; the operator can overturn it at merge. A re-flag starts a fresh ladder. The ladder applies whatever `Default:` says, -including a hard block, and an active back-and-forth still climbs it. This is -different from the 7-day nudge, which resets on real activity. The machine -observes both clocks but never sets, clears, or decides `needs-ruling`. +including a hard block, and an active back-and-forth still climbs it — unlike +the 7-day nudge, which resets on real activity. The machine observes both +clocks but never sets, clears, or decides `needs-ruling`. The label stays until agreement is *reached*, not until the maintainer replies. The setter records the ruling, removes the label, and returns the From 5ea59780b6a34bc4d78bfc4edff1208a2e8a598e Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:16:58 +0000 Subject: [PATCH 105/162] docs(triage): scope no-assignee bug to flagging --- TRIAGE.md | 4 ++-- changelog.d/264.md | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 changelog.d/264.md diff --git a/TRIAGE.md b/TRIAGE.md index e2cbe53..d8ee1be 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -70,8 +70,8 @@ is the failure this whole flow exists to prevent. when that ruling or any directive or answered builder question delivers the assignee's next move in prose, set `attention` in the same comment on the assigned issue that owns the claim — never on the pull request, even - when the comment lives there. An unassigned issue is a board bug, not a - demand; repair the board rather than setting `attention`. + when the comment lives there. Flagging an unassigned issue is a board bug, + not a demand; repair the board rather than setting `attention`. This is not a substitute for minting work or for `needs-ruling`. 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 diff --git a/changelog.d/264.md b/changelog.d/264.md new file mode 100644 index 0000000..fa70c7c --- /dev/null +++ b/changelog.d/264.md @@ -0,0 +1,4 @@ +### Changed + +- TRIAGE.md now scopes the no-assignee board bug to flagging an unassigned + issue, while still directing triage to repair ownership instead (#264). From 92ff82c1c653bf3c151f8dc459b23948337a3272 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:18:31 +0000 Subject: [PATCH 106/162] =?UTF-8?q?docs(builder):=20WIP=20=E2=80=94=20mini?= =?UTF-8?q?mal-statement=20register?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUILDER.md | 138 +++++++++++++++++++++++++---------------------------- 1 file changed, 65 insertions(+), 73 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index d3e055e..74f9fbf 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -12,18 +12,17 @@ triage bug, and the move is to say so on the issue, not to guess. prefer the issue that unblocks the most other work. Where a repository adopts version epics, [RELEASES.md](RELEASES.md) governs the choice among release-window members. -- **Your own red head outranks a new claim.** Pick up a failing check at the +- **Your own red head outranks a new claim**: repair a failing check at the head of a PR you authored before claiming another issue, or it strands mergeable and unattended (#163). Record the failing check and its failure class; rerun a clearly retryable infrastructure failure without changing - code; return to the normal fix-round and worklog discipline where the - failure belongs to the branch; leave visible evidence where a rerun cannot - be started or the cause is uncertain; never repeatedly rerun a - deterministic branch failure without a corrective commit; hand off once - the check is green and current-head approvals stand. Such a PR is **not - parked**, whatever the round's verdict state says. Red and green are the - ruled terms of the review round below; how the engine detects a red head - is crew's to describe, not this file's. + code; treat a failure belonging to the branch as an ordinary fix round, + worklog and all; leave visible evidence where a rerun cannot be started or + the cause is uncertain; never rerun a deterministic branch failure without + a corrective commit; hand off once the check is green and current-head + approvals stand. Such a PR is **not parked**, whatever the round's verdict + state says. Red and green are the review round's ruled terms below; how + the engine detects a red head is crew's to describe, not this file's. - **One build at a time**: at most one issue on which you are writing or revising a deliverable, finished or released before you start new work. The rule counts build work in flight, not claims — a **parked** claim, @@ -35,7 +34,7 @@ triage bug, and the move is to say so on the issue, not to guess. verdict someone else's — awaiting its first verdicts, or answered whole with the owed re-requests posted, by head and not by verdict (steps 1–2 below). A red check at the current head takes it out of this shape: the - next move is yours, and reading that as parked strands the PR; + next move is yours; 3. every remaining acceptance criterion is operator-owned, stated as such by triage on the issue; 4. the deliverable is **handed off** — the round passed, no `blocker:*` @@ -54,9 +53,9 @@ triage bug, and the move is to say so on the issue, not to guess. say so on the issue and take the next `ready` issue. Not parked: waiting on yourself, on CI (a red head is your own work; a pending one resolves without you), or for a good moment. An issue you have - simply stopped working on is abandoned, not parked — unassign and restore - `ready`. The rule counts work because parked claims are legitimately held - beside the one active build (#15, #16, #73). + simply stopped working on is abandoned — unassign and restore `ready`. The + rule counts work because parked claims are legitimately held beside the + one active build (#15, #16, #73). ## Claiming @@ -70,14 +69,13 @@ triage bug, and the move is to say so on the issue, not to guess. (#52) and `offsite` (#68) exemptions guard. Shape 4 owes no separate comment: the handoff comment plus the `state:needs-human` write already name the wait (the merge) and its owner (the human). -- **A declaration stands until the park's facts change.** A resumption that - finds nothing changed posts nothing, because re-declaring on every resume - floods the record with audits each saying nothing changed (#177). One new - comment is owed each time the facts change — the named wait resolves or - changes hands, the parked shape changes, or the claim unparks. A parked - claim with **no open PR** still feeds the 48-hour reclaim clock, so - refresh the declaration before that window closes; that refresh is the - only repeat a park owes, at the reclaim window's cadence. +- **A declaration stands until the park's facts change**, so a resumption + that finds nothing changed posts nothing (#177). One new comment is owed + each time the facts change — the named wait resolves or changes hands, the + parked shape changes, or the claim unparks. A parked claim with **no open + PR** still feeds the 48-hour reclaim clock, so refresh the declaration + before that window closes; that refresh is the only repeat a park owes, at + the reclaim window's cadence. - **Pick up `attention` before anything else.** Post a short pickup comment and remove `attention`; the removal is the ack. A demand on a parked claim is usually its unpark, so take the slot back — unless the demand *is* the @@ -133,9 +131,9 @@ triage bug, and the move is to say so on the issue, not to guess. exact prose to be published and nothing else — `- ` bullets, plus in a grouped repo the `### Added` / `### Changed` / `### Fixed` headings inside the fragment, a rarer kind only where a change genuinely is one. An entry - is at most 300 characters, so a genuinely long change ships several short - entries; wrapping one over continuation lines never counts against it. It - **ends with its issue citation**: one `(` group of `#N`, `repo#N` or + is at most 300 characters, so a long change ships several short entries; + wrapping one over continuation lines never counts against it. It **ends + with its issue citation**: one `(` group of `#N`, `repo#N` or `owner/repo#N` separated by `, `, then `)`, then the final `.` and nothing after it — `(#262).`, or `(#236, #250).` where an entry honestly lands two — and it need not name the fragment's own issue, which the filename @@ -170,39 +168,35 @@ triage bug, and the move is to say so on the issue, not to guess. verdict is advisory and does not become required. **A review request requires a green check at the head**, and that binds you whether or not any engine enforces it: a red check is the author's - own signal, not the panel's work, so fix it and push, then request. 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 - only where the request says so explicitly and names the evidence ("the - same job fails identically on `origin/main` at ``"); silence about a - red check is what is prohibited. + own signal, so fix it and push, then request. 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 only where the + request says so explicitly and names the evidence ("the same job fails + identically on `origin/main` at ``"); silence about a red check is + what is prohibited. *Green* is a ruled term (operator, 2026-07-27), read in two steps, because a head carries more rollup entries than it has checks. **First - find the check's word at this head**: its newest entry by start time, - except that a `CANCELLED` entry is never the word while the same check - has a non-cancelled entry at that head. Date entries by start, not by - completion — a cancelled run outlives its replacement's start, so the - other reading picks the corpse. A check whose every entry at the head is - cancelled has not reported and stays not-green by the classes below; that - is a collapse and not a new class, the gate likewise dropping a cancelled - entry only where its context keeps a non-cancelled survivor (#139, #276). + take the check's word at this head**: its newest entry by start time, + never a `CANCELLED` entry while the same check has a non-cancelled one at + that head. Date entries by start, not completion — a cancelled run + outlives its replacement's start. A check whose every entry at the head + is cancelled has not reported at all and stays not-green by the classes + below, the gate collapsing the same way (#139, #276). **Then classify that entry from its `conclusion`, never its `status`**, which can still disagree with it (#259). No conclusion at all is neither class: a configured run still in progress is not green, and waiting on it is compliance, not a stall. **Cancelled or stale** is not green — *stale* means a superseded head's check, which a head-scoped rollup never shows, so what survives there is same-head cancellation. **Skipped or neutral** - *is* green: those are deliberate "passed / not applicable" conclusions, - and reddening them would red every conditional job the fleet skips on - purpose. **No checks configured** is the third ruled case, not an argued + *is* green, those being deliberate "passed / not applicable" conclusions. + **No checks configured** is the third ruled case, not an argued exception: nothing is configured, so nothing is waited for and the - request goes out at once, no evidence owed. That rules - nothing-configured, never nothing-answered-yet — a pending run has an - owner, CI — and the machine partitions alike, admitting the ask on - `SUCCESS` and on `NONE` (#236). The costs behind the line are asymmetric: - a false green spends a three-reviewer round, a false red one author - session. What the *machine* drops from the rollup before grading is - crew's to describe. + request goes out at once, no evidence owed — which rules + nothing-configured, never nothing-answered-yet, and the machine + partitions alike, admitting the ask on `SUCCESS` and on `NONE` (#236). + The costs behind the line are asymmetric: a false green spends a + three-reviewer round, a false red one author session. What the *machine* + drops from the rollup before grading is crew's to describe. 2. **Wait for every verdict, then answer the round whole** — one reply covering every point and stating what changed and what was verified. That reply is the written round record: the engine mirrors it under the PR @@ -213,35 +207,33 @@ triage bug, and the move is to say so on the issue, not to guess. makes every approval 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**; one left - un-re-requested can never approve the tree you shipped, and the PR sits - looking finished with nothing owed by anyone (#26, #39). Only where the - head did not move — the round answered with argument or evidence, nothing - pushed — do you re-request just the non-approvers, a standing approval - already covering this exact head (#94). **The re-request carries the same - green-check-at-head precondition**, argued exception included: a fix push - whose check comes up red is your next fix, not the panel's. Prefer - verification over argument — where a reviewer doubts behavior, add the - test that settles it. + un-re-requested can never approve the tree you shipped (#26, #39). Only + where the head did not move — the round answered with argument or + evidence, nothing pushed — do you re-request just the non-approvers, a + standing approval already covering this exact head (#94). **The + re-request carries the same green-check-at-head precondition**, argued + exception included: a fix push whose check comes up red is your next fix, + not the panel's. Prefer verification over argument — where 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; silence and force-forward are not options, and a panel deadlock is one kind of human-owned decision (#50 D11). **A fix round may ride a draft**, and the draft changes nothing about who -owes what. An engine may convert a PR back to draft when a round closes, so -that mid-round saves stop firing CI on a ready PR; ceremony implements no -such conversion, but whoever meets a mid-round draft reads it as a draft -always read — the draft phase is yours and the panel cannot see it — while -the round outranks the draft, so you still owe it whole, the fixes and the -reply and the flip ([LABELS.md](LABELS.md)'s `state:building` row, #205). -**Ready-for-review is the act that ends the round, and it is the builder's -alone**: the flip asserts that the round was answered whole, the one -judgement about a round its author cannot delegate, so an engine may draft a -PR but only the builder undrafts it. **Where a draft suppressed the checks, -green is proven at the flip and the request still follows it**: marking -ready is what runs the checks the draft held back, so the order is flip, let -the head answer, then request — step 1's precondition, not a second one — -and waiting there is compliance, not a stall, which is why +owes what: an engine may convert a PR back to draft when a round closes, and +ceremony implements no such conversion, but whoever meets a mid-round draft +reads it as a draft always read — the draft phase is yours and the panel +cannot see it — while the round outranks the draft, so you still owe it +whole, the fixes and the reply and the flip ([LABELS.md](LABELS.md)'s +`state:building` row, #205). **Ready-for-review is the act that ends the +round, and it is the builder's alone**: the flip asserts that the round was +answered whole, the one judgement its author cannot delegate, so an engine +may draft a PR but only the builder undrafts it. **Where a draft suppressed +the checks, green is proven at the flip and the request still follows it** — +marking ready is what runs the checks the draft held back, so the order is +flip, let the head answer, then request, which is step 1's precondition and +not a second one. Waiting there is compliance, not a stall, and `blocker:unrequested` does not fire while a head's checks are pending or red (#236). @@ -271,8 +263,8 @@ the question is not ready. `Recommend:` is mandatory — omitting it hands the whole problem to the human. `Blocked:` names both what stops and what continues. Write a timed `Default:` only when you are affirmatively confident the decision is reversible inside the PR before merge. Unsure is not a tie: -it is a hard block. Published artifacts, secrets, prod, and org policy are -hard blocks by construction (#50 D12–D13). +it is a hard block, as published artifacts, secrets, prod and org policy are +by construction (#50 D12–D13). The ladder is anchored to the current episode's `needs-ruling` **`labeled` event**, not its `Default:` deadline or the last activity (#50 D13–D14): From 1b6143f4aab7c0f6d5737f01c97cd098aedbb715 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:21:26 +0000 Subject: [PATCH 107/162] =?UTF-8?q?docs(builder):=20WIP=20=E2=80=94=20drop?= =?UTF-8?q?=20optional=20whys,=20classes=20as=20a=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUILDER.md | 118 ++++++++++++++++++++++++++--------------------------- 1 file changed, 57 insertions(+), 61 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index 74f9fbf..bb22122 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -14,15 +14,14 @@ triage bug, and the move is to say so on the issue, not to guess. release-window members. - **Your own red head outranks a new claim**: repair a failing check at the head of a PR you authored before claiming another issue, or it strands - mergeable and unattended (#163). Record the failing check and its failure - class; rerun a clearly retryable infrastructure failure without changing - code; treat a failure belonging to the branch as an ordinary fix round, - worklog and all; leave visible evidence where a rerun cannot be started or - the cause is uncertain; never rerun a deterministic branch failure without - a corrective commit; hand off once the check is green and current-head - approvals stand. Such a PR is **not parked**, whatever the round's verdict - state says. Red and green are the review round's ruled terms below; how - the engine detects a red head is crew's to describe, not this file's. + mergeable and unattended (#163). Record the check and its failure class; + rerun a clearly retryable infrastructure failure unchanged; treat a branch + failure as an ordinary fix round, worklog and all; leave visible evidence + where a rerun cannot be started or the cause is uncertain; never rerun a + deterministic branch failure without a corrective commit; hand off once + the check is green and current-head approvals stand. Such a PR is **not + parked**, whatever the round's verdict state says. How the engine detects + a red head is crew's to describe, not this file's. - **One build at a time**: at most one issue on which you are writing or revising a deliverable, finished or released before you start new work. The rule counts build work in flight, not claims — a **parked** claim, @@ -44,18 +43,19 @@ triage bug, and the move is to say so on the issue, not to guess. work, the direction names what the hold waits on, and only they end it. A hold ends the way it started, **on the labels**: where labels and prose disagree, the most recent queue-label event by the hold's owner - governs, an operator being free to lift by label alone (#149, #151). So - read the label events (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), - not only the comments, before standing down *or* standing up, and where - you act against stale prose say so in the claim, naming the events, - their timestamps and their actor. Refusing to claim through the - contradiction is no resting place: where the events do not resolve it, - say so on the issue and take the next `ready` issue. + governs, an operator being free to lift by label alone (#149, #151). + Read the label events (`gh api + /repos/{owner}/{repo}/issues/{n}/timeline`), not only the comments, + before standing down *or* standing up, and where you act against stale + prose say so in the claim, naming the events, their timestamps and + their actor. Refusing to claim through the contradiction is no resting + place: where the events do not resolve it, say so on the issue and take + the next `ready` issue. Not parked: waiting on yourself, on CI (a red head is your own work; a pending one resolves without you), or for a good moment. An issue you have - simply stopped working on is abandoned — unassign and restore `ready`. The - rule counts work because parked claims are legitimately held beside the - one active build (#15, #16, #73). + simply stopped working on is abandoned — unassign and restore `ready`. + Parked claims are legitimately held beside the one active build (#15, #16, + #73). ## Claiming @@ -71,11 +71,10 @@ triage bug, and the move is to say so on the issue, not to guess. name the wait (the merge) and its owner (the human). - **A declaration stands until the park's facts change**, so a resumption that finds nothing changed posts nothing (#177). One new comment is owed - each time the facts change — the named wait resolves or changes hands, the + each time they do change — the named wait resolves or changes hands, the parked shape changes, or the claim unparks. A parked claim with **no open PR** still feeds the 48-hour reclaim clock, so refresh the declaration - before that window closes; that refresh is the only repeat a park owes, at - the reclaim window's cadence. + before that window closes; that refresh is the only repeat a park owes. - **Pick up `attention` before anything else.** Post a short pickup comment and remove `attention`; the removal is the ack. A demand on a parked claim is usually its unpark, so take the slot back — unless the demand *is* the @@ -84,13 +83,13 @@ triage bug, and the move is to say so on the issue, not to guess. - **A directed hold keeps its bookkeeping visible.** The PR carries `blocked` with a comment naming what it waits on; the issue stays `claimed` and carries `attention` until the builder acknowledges it. - Nobody unassigns the issue, and the 48-hour reclaim does not fire because + Nobody unassigns the issue, and the 48-hour reclaim does not fire while the claim has an open PR. - **Unparking is a claim like any other** and takes the slot: if you are active elsewhere, finish or release that work first and say which you did - on both issues. Nothing counts claims per builder and no reconciler path - enforces this — the discipline is the declaration, not a counter, and no - such machinery should be built expecting it to have been specified here. + on both issues. No machinery counts claims per builder, and none should be + built expecting this section to have specified one — the discipline is the + declaration, not a counter. - **Abandoning is fine; ghosting is not.** Say where you got to, push the branch if it holds anything useful, unassign, and restore `ready`. @@ -133,10 +132,10 @@ triage bug, and the move is to say so on the issue, not to guess. the fragment, a rarer kind only where a change genuinely is one. An entry is at most 300 characters, so a long change ships several short entries; wrapping one over continuation lines never counts against it. It **ends - with its issue citation**: one `(` group of `#N`, `repo#N` or - `owner/repo#N` separated by `, `, then `)`, then the final `.` and nothing - after it — `(#262).`, or `(#236, #250).` where an entry honestly lands two - — and it need not name the fragment's own issue, which the filename + with its issue citation** — one parenthesised group of `#N`, `repo#N` or + `owner/repo#N` references separated by `, `, then the final `.` and + nothing after it: `(#262).`, or `(#236, #250).` where an entry honestly + lands two — and need not name the fragment's own issue, which the filename carries. The guard reds a longer entry (#167) and an uncited one (#262) alike. Never edit `CHANGELOG.md` for an entry: the release PR assembles the section from the fragments (#112), and the monotonic guard refuses @@ -153,8 +152,7 @@ triage bug, and the move is to say so on the issue, not to guess. - **Scope discipline: the PR does the issue — whole, and nothing else.** Adjacent problems go to a **discussion**, or a comment on the relevant issue, where triage does its job. You do not mint issues — nobody but - triage does — and you do not fix drive-by findings in the same PR, because - a reviewer cannot converge on a widening target. + triage does — and you do not fix drive-by findings in the same PR. ## The review round @@ -174,26 +172,26 @@ triage bug, and the move is to say so on the issue, not to guess. request says so explicitly and names the evidence ("the same job fails identically on `origin/main` at ``"); silence about a red check is what is prohibited. - *Green* is a ruled term (operator, 2026-07-27), read in two steps, - because a head carries more rollup entries than it has checks. **First - take the check's word at this head**: its newest entry by start time, - never a `CANCELLED` entry while the same check has a non-cancelled one at - that head. Date entries by start, not completion — a cancelled run - outlives its replacement's start. A check whose every entry at the head - is cancelled has not reported at all and stays not-green by the classes - below, the gate collapsing the same way (#139, #276). - **Then classify that entry from its `conclusion`, never its `status`**, - which can still disagree with it (#259). No conclusion at all is neither - class: a configured run still in progress is not green, and waiting on it - is compliance, not a stall. **Cancelled or stale** is not green — *stale* - means a superseded head's check, which a head-scoped rollup never shows, - so what survives there is same-head cancellation. **Skipped or neutral** - *is* green, those being deliberate "passed / not applicable" conclusions. - **No checks configured** is the third ruled case, not an argued - exception: nothing is configured, so nothing is waited for and the - request goes out at once, no evidence owed — which rules - nothing-configured, never nothing-answered-yet, and the machine - partitions alike, admitting the ask on `SUCCESS` and on `NONE` (#236). + *Green* is a ruled term (operator, 2026-07-27), read in two steps. + **First take the check's word at this head**: its newest entry by start + time — not by completion, a cancelled run outliving its replacement's + start — and never a `CANCELLED` entry while the same check has a + non-cancelled one at that head. A check whose every entry at the head is + cancelled has not reported at all and is not green, the gate collapsing + the same way (#139, #276). **Then classify that entry from its + `conclusion`, never its `status`**, which can still disagree with it + (#259): + - no conclusion at all — not green: a configured run still in progress is + waited on, and waiting is compliance, not a stall; + - cancelled or stale — not green, *stale* meaning a superseded head's + check, which a head-scoped rollup never shows; + - skipped or neutral — green, those being deliberate "passed / not + applicable" conclusions; + - no checks configured at the head — green, the third ruled case and not + an argued exception: the request goes out at once, no evidence owed. + That never covers nothing-answered-yet, and the machine partitions + alike, admitting the ask on `SUCCESS` and on `NONE` (#236). + The costs behind the line are asymmetric: a false green spends a three-reviewer round, a false red one author session. What the *machine* drops from the rollup before grading is crew's to describe. @@ -209,12 +207,11 @@ triage bug, and the move is to say so on the issue, not to guess. panelist is re-requested, the approvers included**; one left un-re-requested can never approve the tree you shipped (#26, #39). Only where the head did not move — the round answered with argument or - evidence, nothing pushed — do you re-request just the non-approvers, a - standing approval already covering this exact head (#94). **The - re-request carries the same green-check-at-head precondition**, argued - exception included: a fix push whose check comes up red is your next fix, - not the panel's. Prefer verification over argument — where a reviewer - doubts behavior, add the test that settles it. + evidence, nothing pushed — do you re-request just the non-approvers (#94). + **The re-request carries the same green-check-at-head precondition**, + argued exception included: a fix push whose check comes up red is your + next fix, not the panel's. Prefer verification over argument — where 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; silence and force-forward are not options, and a panel @@ -233,9 +230,8 @@ may draft a PR but only the builder undrafts it. **Where a draft suppressed the checks, green is proven at the flip and the request still follows it** — marking ready is what runs the checks the draft held back, so the order is flip, let the head answer, then request, which is step 1's precondition and -not a second one. Waiting there is compliance, not a stall, and -`blocker:unrequested` does not fire while a head's checks are pending or red -(#236). +not a second one. Waiting there is compliance, and `blocker:unrequested` +does not fire while a head's checks are pending or red (#236). ## The ruling ask From e1c3e3e9af71aa49c43c23610ba47c1ee3fa3958 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:23:56 +0000 Subject: [PATCH 108/162] =?UTF-8?q?docs(builder):=20WIP=20=E2=80=94=20ters?= =?UTF-8?q?e=20register=20throughout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUILDER.md | 382 +++++++++++++++++++++++++---------------------------- 1 file changed, 181 insertions(+), 201 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index bb22122..789835d 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -6,244 +6,225 @@ 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). Inside an - epic take the earliest unblocked unclaimed child; between epics and strays - prefer the issue that unblocks the most other work. Where a repository - adopts version epics, [RELEASES.md](RELEASES.md) governs the choice among - release-window members. -- **Your own red head outranks a new claim**: repair a failing check at the - head of a PR you authored before claiming another issue, or it strands - mergeable and unattended (#163). Record the check and its failure class; - rerun a clearly retryable infrastructure failure unchanged; treat a branch - failure as an ordinary fix round, worklog and all; leave visible evidence - where a rerun cannot be started or the cause is uncertain; never rerun a - deterministic branch failure without a corrective commit; hand off once - the check is green and current-head approvals stand. Such a PR is **not - parked**, whatever the round's verdict state says. How the engine detects - a red head is crew's to describe, not this file's. -- **One build at a time**: at most one issue on which you are writing or - revising a deliverable, finished or released before you start new work. - The rule counts build work in flight, not claims — a **parked** claim, - whose next move belongs to someone else, does not consume the slot. - Exactly five shapes park: +- Pick from issues labeled **`ready`** — never `blocked`, `claimed`, or an + `epic` (epics organize; their children are the work). Inside an epic take + the earliest unblocked unclaimed child; otherwise prefer the issue that + unblocks the most work. Where a repo adopts version epics, + [RELEASES.md](RELEASES.md) governs the choice among window members. +- **Your own red head outranks a new claim**: repair a failing check at your + PR's head before claiming another issue, or it strands mergeable and + unattended (#163). Record the check and its failure class; rerun a clearly + retryable infrastructure failure unchanged; treat a branch failure as an + ordinary fix round, worklog and all; leave evidence where a rerun cannot + start or the cause is unclear; never rerun a deterministic failure without + a corrective commit; hand off once green with current-head approvals. Such + a PR is **never parked**, whatever the round's verdict state says. How the + engine detects a red head is crew's to describe. +- **One build at a time**: one issue on which you are writing or revising a + deliverable, finished or released before you start more. The rule counts + work in flight, not claims — a **parked** claim, whose next move is + someone else's, does not hold the slot. Five shapes park: 1. `needs-ruling` is set, the escalation names a decider, and its - `Blocked:` line stops the remaining work; - 2. a **live** review round holds the deliverable, every outstanding - verdict someone else's — awaiting its first verdicts, or answered whole - with the owed re-requests posted, by head and not by verdict (steps 1–2 - below). A red check at the current head takes it out of this shape: the - next move is yours; - 3. every remaining acceptance criterion is operator-owned, stated as such - by triage on the issue; - 4. the deliverable is **handed off** — the round passed, no `blocker:*` - stands, `state:needs-human` is set per Handoff, and the merge is the - human's. Shapes 2 and 4 are sequential and never overlap; + `Blocked:` line stops the rest; + 2. a **live** review round holds it, every outstanding verdict someone + else's — awaiting first verdicts, or answered whole with the owed + re-requests posted, by head and not by verdict (steps 1–2). A red check + at the head takes it out of this shape: the next move is yours; + 3. every remaining acceptance criterion is operator-owned, stated so by + triage on the issue; + 4. it is **handed off** — round passed, no `blocker:*` standing, + `state:needs-human` set per Handoff, the merge the human's. Shapes 2 + and 4 are sequential and never overlap; 5. the claim is **held by directive** — triage or the operator stopped the - work, the direction names what the hold waits on, and only they end it. - A hold ends the way it started, **on the labels**: where labels and - prose disagree, the most recent queue-label event by the hold's owner - governs, an operator being free to lift by label alone (#149, #151). - Read the label events (`gh api - /repos/{owner}/{repo}/issues/{n}/timeline`), not only the comments, - before standing down *or* standing up, and where you act against stale - prose say so in the claim, naming the events, their timestamps and - their actor. Refusing to claim through the contradiction is no resting - place: where the events do not resolve it, say so on the issue and take - the next `ready` issue. - Not parked: waiting on yourself, on CI (a red head is your own work; a - pending one resolves without you), or for a good moment. An issue you have - simply stopped working on is abandoned — unassign and restore `ready`. - Parked claims are legitimately held beside the one active build (#15, #16, - #73). + work, named what the hold waits on, and only they end it. A hold ends + as it started, **on the labels**: where labels and prose disagree, the + most recent queue-label event by the hold's owner governs, and an + operator may lift by label alone (#149, #151). So read the label events + (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just the + comments, before standing down *or* up; acting against stale prose, say + so in the claim, naming the events, their timestamps and their actor. + Refusing to claim through the contradiction is no resting place: where + the events do not resolve it, say so and take the next `ready` issue. + Not parked: waiting on yourself, on CI (a red head is yours; a pending one + resolves without you), or for a good moment. An issue you simply stopped + working on is abandoned — unassign and restore `ready`. Parked claims are + legitimately held beside the one active build (#15, #16, #73). ## Claiming - Assign yourself, swap `ready` → `claimed`, and comment that you are starting. The claim promises a draft PR soon: a claim with no PR and no activity is what the staleness sweep reclaims, unless `offsite` records - that its PR lives in another repository. -- **A park is declared, never inferred.** Comment on the issue naming what - the claim waits on and who owns the next move; no new label, the comment - being the activity that feeds the same reclaim clock the `needs-ruling` - (#52) and `offsite` (#68) exemptions guard. Shape 4 owes no separate - comment: the handoff comment plus the `state:needs-human` write already - name the wait (the merge) and its owner (the human). + that its PR lives in another repo. +- **A park is declared, never inferred.** Comment naming what the claim + waits on and who owns the next move — no new label; the comment is the + activity the reclaim clock reads, as for `needs-ruling` (#52) and + `offsite` (#68). Shape 4 is exempt: the handoff comment and + `state:needs-human` already say both. - **A declaration stands until the park's facts change**, so a resumption - that finds nothing changed posts nothing (#177). One new comment is owed - each time they do change — the named wait resolves or changes hands, the - parked shape changes, or the claim unparks. A parked claim with **no open - PR** still feeds the 48-hour reclaim clock, so refresh the declaration - before that window closes; that refresh is the only repeat a park owes. -- **Pick up `attention` before anything else.** Post a short pickup comment - and remove `attention`; the removal is the ack. A demand on a parked claim - is usually its unpark, so take the slot back — unless the demand *is* the - park, where the pickup comment doubles as the declaration and the slot - stays free. + finding nothing changed posts nothing (#177). Each change owes one comment + — the wait resolves or changes hands, the shape changes, the claim + unparks. A parked claim with **no open PR** still feeds the 48-hour + reclaim clock, so refresh the declaration before that window closes; that + is the only repeat a park owes. +- **Pick up `attention` before anything else**: post a short pickup comment + and remove the label, which is the ack. A demand on a parked claim is + usually its unpark, so take the slot back — unless the demand *is* the + park, where the pickup comment doubles as the declaration. - **A directed hold keeps its bookkeeping visible.** The PR carries `blocked` with a comment naming what it waits on; the issue stays - `claimed` and carries `attention` until the builder acknowledges it. - Nobody unassigns the issue, and the 48-hour reclaim does not fire while - the claim has an open PR. + `claimed` and carries `attention` until the builder acks. Nobody unassigns + it, and the 48-hour reclaim does not fire while the claim has an open PR. - **Unparking is a claim like any other** and takes the slot: if you are - active elsewhere, finish or release that work first and say which you did - on both issues. No machinery counts claims per builder, and none should be - built expecting this section to have specified one — the discipline is the - declaration, not a counter. + active elsewhere, finish or release that work first and say which on both + issues. No machinery counts claims per builder, and none should be built + expecting this section to have specified one. - **Abandoning is fine; ghosting is not.** Say where you got to, push the - branch if it holds anything useful, unassign, and restore `ready`. + branch if it holds anything useful, unassign, 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. + body. Drafts are invisible to the panel on purpose: the draft phase is + yours. - **`Closes #N` does not cross repos.** A PR in a different repo from its - authorizing issue says `Part of /#N`, sets `offsite`, and - comments the draft PR link on that issue in the same step. Triage closes - that issue by hand once its acceptance criteria are met, and at that - handoff the builder reports whether the PR merged or closed and clears - `offsite` in the same comment (#13, #16). + issue says `Part of /#N`, sets `offsite`, and comments the + draft link on that issue in the same step. Triage closes that issue by + hand once its criteria are met; at that handoff the builder reports + whether the PR merged or closed and clears `offsite` (#13, #16). - **`Closes #N` does not survive a post-merge criterion.** Where the issue's - body states that a criterion can only be checked after the merge — a live - proof of a workflow trigger, a released-artifact check, anything whose - subject does not exist until the change is on the base branch — the - same-repo PR says `Refs #N` and triage closes by hand on the evidence. The - merge releases the claim: the issue moves to `post-merge`, the builder - walks away, and triage owns verification and closure, returning it to - `ready` or minting a fresh issue for corrective work that any builder - claims from current `main`. The issue body is what says so — you never - judge which issues qualify, and absent that instruction `Closes #N` is the - default (#151). + body says a criterion can only be checked after the merge — a workflow + trigger proved live, a released artifact, anything whose subject does not + exist until the change is on the base branch — the same-repo PR says + `Refs #N` and triage closes by hand on the evidence. The merge releases + the claim: the issue goes `post-merge`, the builder walks away, triage + owns verification and closure, and corrective work is a fresh issue any + builder claims from current `main`. The issue body is what says so — you + never judge which issues qualify, and absent that instruction `Closes #N` + is the default (#151). - On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`) immediately before `#N` anywhere in the body, including the sentence explaining why the PR does not close it: GitHub reads the body by adjacency, not intent, and a code span does not protect the phrase (#200, #218). Put the number first (`#N is closed by hand`) or omit it. -- **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; a - criterion that turns out wrong or unreachable goes back to triage to be - amended, never silently shipped short. +- **The issue's acceptance criteria are your definition of done**: reproduce + them as a checklist in the PR body and check them honestly. A criterion + that turns out wrong or unreachable goes back to triage to be amended, + never silently shipped short. - **Every behavior change writes one fragment**, `changelog.d/.md` named for the authorizing issue (`-.md` cross-repo): the exact prose to be published and nothing else — `- ` bullets, plus in a - grouped repo the `### Added` / `### Changed` / `### Fixed` headings inside - the fragment, a rarer kind only where a change genuinely is one. An entry - is at most 300 characters, so a long change ships several short entries; - wrapping one over continuation lines never counts against it. It **ends - with its issue citation** — one parenthesised group of `#N`, `repo#N` or - `owner/repo#N` references separated by `, `, then the final `.` and - nothing after it: `(#262).`, or `(#236, #250).` where an entry honestly - lands two — and need not name the fragment's own issue, which the filename - carries. The guard reds a longer entry (#167) and an uncited one (#262) - alike. Never edit `CHANGELOG.md` for an entry: the release PR assembles - the section from the fragments (#112), and the monotonic guard refuses - anything that deletes a shipped heading. + grouped repo the `### Added` / `### Changed` / `### Fixed` headings, a + rarer kind only where a change genuinely is one. An entry is at most 300 + characters, so a long change ships several short entries; wrapping one + over continuation lines never counts against it. It **ends with its issue + citation** — a parenthesised group of `#N`, `repo#N` or `owner/repo#N` + separated by `, `, then the final `.` and nothing after: `(#262).`, or + `(#236, #250).` where an entry honestly lands two — and need not name the + fragment's own issue, which the filename carries. The guard reds a longer + entry (#167) and an uncited one (#262). Never edit `CHANGELOG.md` for an + entry: the release PR assembles the section from fragments (#112), and the + monotonic guard refuses anything deleting a shipped heading. - 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. - **A write-capable job gets a repo-owned script, not a third-party action.** - Where the job's token can write (`packages: write`, `contents: write`, - `id-token: write`, deploy secrets), default to a script in the repo that a - test can drive; a third-party action there needs an established publisher - and a full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule - and the red-flag profile a reviewer applies are in REVIEWER.md §What you - review against, item 2 (#216). + Where the token can write (`packages: write`, `contents: write`, + `id-token: write`, deploy secrets), default to a script a test can drive; + a third-party action there needs an established publisher and a + full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule and + its red-flag profile are in REVIEWER.md §What you review against, item 2 + (#216). - **Scope discipline: the PR does the issue — whole, and nothing else.** - Adjacent problems go to a **discussion**, or a comment on the relevant - issue, where triage does its job. You do not mint issues — nobody but - triage does — and you do not fix drive-by findings in the same PR. + Adjacent problems go to a discussion, or a comment on the relevant issue. + You do not mint issues — nobody but triage does — and you do not fix + drive-by findings in the same PR. ## The review round 1. Mark ready-for-review; request **the whole panel**: the PR repo's `panel[]=` line if it defines one, else its `panel=` line, - minus the author in either case (#224) — never the roster of the repo the - issue is in. That repo's `.github/labels.conf` governs over its - CONTRIBUTING roster, being what the state machine reads; where the PR - repo names no roster, ask triage on the authorizing issue rather than - guessing. An off-panel reviewer may be requested, saying that their - verdict is advisory and does not become required. - **A review request requires a green check at the head**, and that binds - you whether or not any engine enforces it: a red check is the author's - own signal, so fix it and push, then request. 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 only where the - request says so explicitly and names the evidence ("the same job fails - identically on `origin/main` at ``"); silence about a red check is - what is prohibited. - *Green* is a ruled term (operator, 2026-07-27), read in two steps. - **First take the check's word at this head**: its newest entry by start - time — not by completion, a cancelled run outliving its replacement's - start — and never a `CANCELLED` entry while the same check has a - non-cancelled one at that head. A check whose every entry at the head is - cancelled has not reported at all and is not green, the gate collapsing - the same way (#139, #276). **Then classify that entry from its - `conclusion`, never its `status`**, which can still disagree with it - (#259): - - no conclusion at all — not green: a configured run still in progress is - waited on, and waiting is compliance, not a stall; - - cancelled or stale — not green, *stale* meaning a superseded head's - check, which a head-scoped rollup never shows; - - skipped or neutral — green, those being deliberate "passed / not - applicable" conclusions; - - no checks configured at the head — green, the third ruled case and not - an argued exception: the request goes out at once, no evidence owed. - That never covers nothing-answered-yet, and the machine partitions - alike, admitting the ask on `SUCCESS` and on `NONE` (#236). + minus the author (#224) — never the roster of the repo the issue is in. + That repo's `.github/labels.conf` governs over its CONTRIBUTING roster, + being what the state machine reads; where it names no roster, ask triage + on the authorizing issue rather than guess. An off-panel reviewer may be + requested, said to be advisory and not required. + **A review request requires a green check at the head**, whether or not + an engine enforces it: a red check is the author's own signal, so fix it + and push, then request. The one exception is a failure genuinely outside + the PR — a runner outage, a flaky dependency, a failure already on the + default branch — and only where the request says so and names the + evidence ("the same job fails identically on `origin/main` at ``"); + silence about a red check is what is prohibited. + *Green* is a ruled term, read in two steps. **First take the check's word + at this head**: its newest entry by start time — not completion, a + cancelled run outliving its replacement's start — and never a `CANCELLED` + entry while the same check has a non-cancelled one there. A check whose + entries at the head are all cancelled has not reported at all and is not + green, the gate collapsing alike (#139, #276). **Then classify that entry + by `conclusion`, never `status`**, which can disagree with it (#259): + - no conclusion — not green; a configured run in progress is waited on, + and waiting is compliance, not a stall; + - cancelled or stale — not green (*stale* is a superseded head's check, + which a head-scoped rollup never shows); + - skipped or neutral — green, being deliberate "passed / not applicable" + conclusions; + - no checks configured — green: the third ruled case, not an argued + exception, so the request goes out at once with no evidence owed. It + never covers nothing-answered-yet, and the machine partitions alike, + admitting the ask on `SUCCESS` and `NONE` (#236). The costs behind the line are asymmetric: a false green spends a - three-reviewer round, a false red one author session. What the *machine* + three-reviewer round, a false red one author session. What the machine drops from the rollup before grading is crew's to describe. 2. **Wait for every verdict, then answer the round whole** — one reply - covering every point and stating what changed and what was verified. That - reply is the written round record: the engine mirrors it under the PR - body's **Round log**, newest last, so the builder owes the reply and no - separate body edit, and a round answered without one is recorded as such - and never blocks handoff. + covering every point, stating what changed and what was verified. That + reply is the written record: the engine mirrors it under the PR body's + **Round log**, newest last, so you owe the reply and no body edit, and a + round answered without one is recorded as such and never blocks handoff. Then push the fixes and re-request **by head, not by verdict**. A push makes every approval 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**; one left - un-re-requested can never approve the tree you shipped (#26, #39). Only - where the head did not move — the round answered with argument or - evidence, nothing pushed — do you re-request just the non-approvers (#94). - **The re-request carries the same green-check-at-head precondition**, - argued exception included: a fix push whose check comes up red is your - next fix, not the panel's. Prefer verification over argument — where a - reviewer doubts behavior, add the test that settles it. + panelist is re-requested, approvers included**; one left un-re-requested + can never approve the tree you shipped (#26, #39). Only where the head + did not move — answered with argument or evidence, nothing pushed — do + you re-request just the non-approvers (#94). **The re-request carries the + same green-check-at-head precondition**, argued exception included: a fix + push whose check comes up red is your next fix, not the panel's. Prefer + verification over argument — where 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; silence and force-forward are not options, and a panel deadlock is one kind of human-owned decision (#50 D11). **A fix round may ride a draft**, and the draft changes nothing about who -owes what: an engine may convert a PR back to draft when a round closes, and -ceremony implements no such conversion, but whoever meets a mid-round draft -reads it as a draft always read — the draft phase is yours and the panel -cannot see it — while the round outranks the draft, so you still owe it -whole, the fixes and the reply and the flip ([LABELS.md](LABELS.md)'s -`state:building` row, #205). **Ready-for-review is the act that ends the -round, and it is the builder's alone**: the flip asserts that the round was -answered whole, the one judgement its author cannot delegate, so an engine -may draft a PR but only the builder undrafts it. **Where a draft suppressed -the checks, green is proven at the flip and the request still follows it** — -marking ready is what runs the checks the draft held back, so the order is -flip, let the head answer, then request, which is step 1's precondition and -not a second one. Waiting there is compliance, and `blocker:unrequested` -does not fire while a head's checks are pending or red (#236). +owes what: an engine may draft a PR when a round closes, ceremony implements +no such conversion, and whoever meets a mid-round draft reads it as a draft +always read — the phase is yours, the panel cannot see it — while the round +outranks the draft, so you owe it whole, the fixes and the reply and the +flip ([LABELS.md](LABELS.md)'s `state:building` row, #205). +**Ready-for-review is the act that ends the round, and it is the builder's +alone**: the flip asserts the round was answered whole, the one judgement +its author cannot delegate, so an engine may draft a PR but only the builder +undrafts it. **Where a draft suppressed the checks, green is proven at the +flip and the request still follows it** — marking ready runs the checks the +draft held back, so the order is flip, let the head answer, then request, +which is step 1's precondition and not a second one. Waiting there is +compliance, and `blocker:unrequested` does not fire while a head's checks +are pending or red (#236). ## The ruling ask Set `needs-ruling` whenever a decision belongs to a human: org policy, published artifacts, secrets, prod, or any choice whose cost lands outside -the PR. A panel deadlock is one instance, not the definition. The builder is -the accountable flag-setter on a PR and consolidates the decision into one -comment rather than forwarding several reviewers' phrasings (#50 D11). +the PR — a panel deadlock is one instance, not the definition. The builder +is the accountable flag-setter on a PR and consolidates the decision into +one comment rather than forwarding several reviewers' phrasings (#50 D11). -Keep at most these five lines above the fold and put all other analysis -inside the fold. The field labels are fixed because the ruling machinery -checks for them (#50 D12): +Keep at most these five lines above the fold, all other analysis inside it. +The field labels are fixed because the ruling machinery checks for them +(#50 D12): ```text 🧭 needs-ruling — @@ -276,31 +257,30 @@ event**, not its `Default:` deadline or the last activity (#50 D13–D14): it as a decision, and remains accountable; the operator can overturn it at merge. -A re-flag starts a fresh ladder. The ladder applies whatever `Default:` says, -including a hard block, and an active back-and-forth still climbs it — unlike -the 7-day nudge, which resets on real activity. The machine observes both -clocks but never sets, clears, or decides `needs-ruling`. - -The label stays until agreement is *reached*, not until the maintainer -replies. The setter records the ruling, removes the label, and returns the -item to its flow in the same comment ([LABELS.md](LABELS.md)). +A re-flag starts a fresh ladder. It applies whatever `Default:` says, +including a hard block, and an active back-and-forth still climbs it — +unlike the 7-day nudge, which resets on real activity. The machine observes +both clocks but never sets, clears, or decides `needs-ruling`. The label +stays until agreement is *reached*, not until the maintainer replies: the +setter records the ruling, removes the label, and returns the item to its +flow in the same comment ([LABELS.md](LABELS.md)). ## 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) — the engine performs these mechanical steps on the -builder's behalf, in order: +When the round passes — every panel verdict approving the **current head**, +no `blocker:*` standing (conflicts rebased, CI green, drill recorded if this +is a release PR) — the engine does these mechanical steps for the builder, +in order: 1. request the human's review; 2. set `state:needs-human`; 3. post the engine-rendered handoff comment: approvals at the current head, 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 -lives in the Round log, mirrored from each whole-round reply. 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, and the -claim is parked as shape 4 (Picking, above), that handoff comment being its -declaration and your build slot free. Address what comes back -(`state:addressing`) and re-hand-off the same way. +The builder composes no new summary: the authored record already lives in +the Round log, mirrored from each whole-round reply. The label write is +optimistic — the reconciler validates it and takes it back if the PR is not +mergeable-right-now. Then stop: the PR is the human's, and the claim is +parked as shape 4 (Picking, above), that handoff comment being its +declaration and your slot free. Address what comes back (`state:addressing`) +and re-hand-off the same way. From cef7ea206ba42e7bff4200ced6351b7cc2875a0a Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:27:52 +0000 Subject: [PATCH 109/162] =?UTF-8?q?docs(builder):=20WIP=20=E2=80=94=20prop?= =?UTF-8?q?osal=20biography=20and=20list=20scaffolding=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUILDER.md | 217 +++++++++++++++++++++++++---------------------------- 1 file changed, 103 insertions(+), 114 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index 789835d..4dff496 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -12,14 +12,14 @@ triage bug, and the move is to say so on the issue, not to guess. unblocks the most work. Where a repo adopts version epics, [RELEASES.md](RELEASES.md) governs the choice among window members. - **Your own red head outranks a new claim**: repair a failing check at your - PR's head before claiming another issue, or it strands mergeable and - unattended (#163). Record the check and its failure class; rerun a clearly - retryable infrastructure failure unchanged; treat a branch failure as an - ordinary fix round, worklog and all; leave evidence where a rerun cannot - start or the cause is unclear; never rerun a deterministic failure without - a corrective commit; hand off once green with current-head approvals. Such - a PR is **never parked**, whatever the round's verdict state says. How the - engine detects a red head is crew's to describe. + PR's head before claiming another issue (#163). Record the check and its + failure class; rerun a clearly retryable infrastructure failure unchanged; + treat a branch failure as an ordinary fix round, worklog and all; leave + evidence where a rerun cannot start or the cause is unclear; never rerun a + deterministic failure without a corrective commit; hand off once green + with current-head approvals. Such a PR is **never parked**, whatever the + verdict state says; how the engine detects a red head is crew's to + describe. - **One build at a time**: one issue on which you are writing or revising a deliverable, finished or released before you start more. The rule counts work in flight, not claims — a **parked** claim, whose next move is @@ -41,14 +41,14 @@ triage bug, and the move is to say so on the issue, not to guess. most recent queue-label event by the hold's owner governs, and an operator may lift by label alone (#149, #151). So read the label events (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just the - comments, before standing down *or* up; acting against stale prose, say - so in the claim, naming the events, their timestamps and their actor. - Refusing to claim through the contradiction is no resting place: where - the events do not resolve it, say so and take the next `ready` issue. + comments, before standing down *or* up, and say in the claim which you + read, their timestamps and their actor. Refusing to claim through the + contradiction is no resting place: where the events do not resolve it, + say so and take the next `ready` issue. Not parked: waiting on yourself, on CI (a red head is yours; a pending one - resolves without you), or for a good moment. An issue you simply stopped - working on is abandoned — unassign and restore `ready`. Parked claims are - legitimately held beside the one active build (#15, #16, #73). + resolves without you), or for a good moment. An issue you stopped working + on is abandoned — unassign and restore `ready`. Parked claims are held + beside the one active build (#15, #16, #73). ## Claiming @@ -65,8 +65,8 @@ triage bug, and the move is to say so on the issue, not to guess. finding nothing changed posts nothing (#177). Each change owes one comment — the wait resolves or changes hands, the shape changes, the claim unparks. A parked claim with **no open PR** still feeds the 48-hour - reclaim clock, so refresh the declaration before that window closes; that - is the only repeat a park owes. + reclaim clock, so refresh the declaration before it closes; that is a + park's only repeat. - **Pick up `attention` before anything else**: post a short pickup comment and remove the label, which is the ack. A demand on a parked claim is usually its unpark, so take the slot back — unless the demand *is* the @@ -89,19 +89,18 @@ triage bug, and the move is to say so on the issue, not to guess. yours. - **`Closes #N` does not cross repos.** A PR in a different repo from its issue says `Part of /#N`, sets `offsite`, and comments the - draft link on that issue in the same step. Triage closes that issue by - hand once its criteria are met; at that handoff the builder reports - whether the PR merged or closed and clears `offsite` (#13, #16). -- **`Closes #N` does not survive a post-merge criterion.** Where the issue's + draft link on that issue in the same step; triage closes that issue by + hand once its criteria are met, the builder reporting there whether the PR + merged or closed and clearing `offsite` in the same comment (#13, #16). +- **`Closes #N` does not survive a post-merge criterion.** Where the issue body says a criterion can only be checked after the merge — a workflow trigger proved live, a released artifact, anything whose subject does not - exist until the change is on the base branch — the same-repo PR says - `Refs #N` and triage closes by hand on the evidence. The merge releases - the claim: the issue goes `post-merge`, the builder walks away, triage - owns verification and closure, and corrective work is a fresh issue any - builder claims from current `main`. The issue body is what says so — you - never judge which issues qualify, and absent that instruction `Closes #N` - is the default (#151). + exist until the change is on the base branch — the same-repo PR says `Refs + #N`; the issue goes `post-merge` at the merge, the builder walks away, + triage owns verification and closure on the evidence, and corrective work + is a fresh issue any builder claims from current `main`. The issue body is + what says so — you never judge which qualify, and absent that instruction + `Closes #N` is the default (#151). - On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`) immediately before `#N` anywhere in the body, including the sentence @@ -109,35 +108,34 @@ triage bug, and the move is to say so on the issue, not to guess. adjacency, not intent, and a code span does not protect the phrase (#200, #218). Put the number first (`#N is closed by hand`) or omit it. - **The issue's acceptance criteria are your definition of done**: reproduce - them as a checklist in the PR body and check them honestly. A criterion - that turns out wrong or unreachable goes back to triage to be amended, - never silently shipped short. + them as a checklist in the PR body and check them honestly. One that turns + out wrong or unreachable goes back to triage to be amended, never silently + shipped short. - **Every behavior change writes one fragment**, `changelog.d/.md` named for the authorizing issue (`-.md` cross-repo): the - exact prose to be published and nothing else — `- ` bullets, plus in a - grouped repo the `### Added` / `### Changed` / `### Fixed` headings, a - rarer kind only where a change genuinely is one. An entry is at most 300 - characters, so a long change ships several short entries; wrapping one - over continuation lines never counts against it. It **ends with its issue - citation** — a parenthesised group of `#N`, `repo#N` or `owner/repo#N` - separated by `, `, then the final `.` and nothing after: `(#262).`, or - `(#236, #250).` where an entry honestly lands two — and need not name the - fragment's own issue, which the filename carries. The guard reds a longer - entry (#167) and an uncited one (#262). Never edit `CHANGELOG.md` for an - entry: the release PR assembles the section from fragments (#112), and the - monotonic guard refuses anything deleting a shipped heading. + prose to be published and nothing else — `- ` bullets, plus in a grouped + repo `### Added` / `### Changed` / `### Fixed` headings, a rarer kind only + where a change genuinely is one. An entry is at most 300 characters, so a + long change ships several short ones (wrapping over continuation lines is + free), and it **ends with its issue citation**: a parenthesised group of + `#N`, `repo#N` or `owner/repo#N` separated by `, `, then the final `.` and + nothing after — `(#262).`, `(#236, #250).` — which need not name the + fragment's own issue, the filename carrying it. The guard reds a long + entry (#167) and an uncited one (#262). Never edit `CHANGELOG.md`: the + release PR assembles the section from fragments (#112), and the monotonic + guard refuses anything deleting a shipped heading. - 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. -- **A write-capable job gets a repo-owned script, not a third-party action.** - Where the token can write (`packages: write`, `contents: write`, +- **A write-capable job gets a repo-owned script, not a third-party + action.** Where the token can write (`packages: write`, `contents: write`, `id-token: write`, deploy secrets), default to a script a test can drive; a third-party action there needs an established publisher and a full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule and its red-flag profile are in REVIEWER.md §What you review against, item 2 (#216). - **Scope discipline: the PR does the issue — whole, and nothing else.** - Adjacent problems go to a discussion, or a comment on the relevant issue. - You do not mint issues — nobody but triage does — and you do not fix + Adjacent problems go to a discussion, or a comment on the relevant issue; + you do not mint issues — nobody but triage does — and you do not fix drive-by findings in the same PR. ## The review round @@ -148,33 +146,29 @@ triage bug, and the move is to say so on the issue, not to guess. That repo's `.github/labels.conf` governs over its CONTRIBUTING roster, being what the state machine reads; where it names no roster, ask triage on the authorizing issue rather than guess. An off-panel reviewer may be - requested, said to be advisory and not required. - **A review request requires a green check at the head**, whether or not - an engine enforces it: a red check is the author's own signal, so fix it - and push, then request. The one exception is a failure genuinely outside - the PR — a runner outage, a flaky dependency, a failure already on the - default branch — and only where the request says so and names the - evidence ("the same job fails identically on `origin/main` at ``"); - silence about a red check is what is prohibited. - *Green* is a ruled term, read in two steps. **First take the check's word - at this head**: its newest entry by start time — not completion, a - cancelled run outliving its replacement's start — and never a `CANCELLED` - entry while the same check has a non-cancelled one there. A check whose - entries at the head are all cancelled has not reported at all and is not - green, the gate collapsing alike (#139, #276). **Then classify that entry - by `conclusion`, never `status`**, which can disagree with it (#259): - - no conclusion — not green; a configured run in progress is waited on, - and waiting is compliance, not a stall; - - cancelled or stale — not green (*stale* is a superseded head's check, - which a head-scoped rollup never shows); - - skipped or neutral — green, being deliberate "passed / not applicable" - conclusions; - - no checks configured — green: the third ruled case, not an argued - exception, so the request goes out at once with no evidence owed. It - never covers nothing-answered-yet, and the machine partitions alike, - admitting the ask on `SUCCESS` and `NONE` (#236). - - The costs behind the line are asymmetric: a false green spends a + requested, said to be advisory and not required. **A review request + requires a green check at the head**, whether or not an engine enforces + it: a red check is the author's own signal, so fix it and push, then + request. The one exception is a failure genuinely outside the PR — a + runner outage, a flaky dependency, a failure already on the default + branch — and only where the request says so and names the evidence ("the + same job fails identically on `origin/main` at ``"). *Green* is a + ruled term (operator, 2026-07-27), read in two steps. **First take the + check's word at this head**: its newest entry by start time — not + completion, a cancelled run outliving its replacement's start — and never + a `CANCELLED` entry while the same check has a non-cancelled one there. A + check whose entries at the head are all cancelled has not reported at all + and is not green, the gate collapsing alike (#139, #276). **Then classify + that entry by `conclusion`, never `status`**, which can disagree with it + (#259). No conclusion is not green: a configured run in progress is + waited on, and waiting is compliance, not a stall. Cancelled or stale is + not green, *stale* being a superseded head's check, which a head-scoped + rollup never shows. Skipped or neutral is green, those being deliberate + "passed / not applicable" conclusions. No checks configured is green — + the third ruled case, not an argued exception, so the request goes out at + once with no evidence owed; that never covers nothing-answered-yet, and + the machine partitions alike, admitting the ask on `SUCCESS` and `NONE` + (#236). The costs behind the line are asymmetric: a false green spends a three-reviewer round, a false red one author session. What the machine drops from the rollup before grading is crew's to describe. 2. **Wait for every verdict, then answer the round whole** — one reply @@ -191,40 +185,37 @@ triage bug, and the move is to say so on the issue, not to guess. you re-request just the non-approvers (#94). **The re-request carries the same green-check-at-head precondition**, argued exception included: a fix push whose check comes up red is your next fix, not the panel's. Prefer - verification over argument — where a reviewer doubts behavior, add the - test that settles it. + verification over argument — add the test that settles the doubt. 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; silence and force-forward are not options, and a panel deadlock is one kind of human-owned decision (#50 D11). **A fix round may ride a draft**, and the draft changes nothing about who -owes what: an engine may draft a PR when a round closes, ceremony implements -no such conversion, and whoever meets a mid-round draft reads it as a draft -always read — the phase is yours, the panel cannot see it — while the round -outranks the draft, so you owe it whole, the fixes and the reply and the -flip ([LABELS.md](LABELS.md)'s `state:building` row, #205). -**Ready-for-review is the act that ends the round, and it is the builder's -alone**: the flip asserts the round was answered whole, the one judgement -its author cannot delegate, so an engine may draft a PR but only the builder -undrafts it. **Where a draft suppressed the checks, green is proven at the -flip and the request still follows it** — marking ready runs the checks the -draft held back, so the order is flip, let the head answer, then request, -which is step 1's precondition and not a second one. Waiting there is -compliance, and `blocker:unrequested` does not fire while a head's checks -are pending or red (#236). +owes what: a mid-round draft reads as a draft always read — the phase is +yours, the panel cannot see it — while the round outranks it, so you owe the +round whole, the fixes and the reply and the flip ([LABELS.md](LABELS.md)'s +`state:building` row, #205). **Ready-for-review is the act that ends the +round, and it is the builder's alone**: the flip asserts the round was +answered whole, the one judgement its author cannot delegate, so an engine +may draft a PR but only the builder undrafts it. **Where a draft suppressed +the checks, green is proven at the flip and the request still follows it** — +marking ready runs the checks the draft held back, so the order is flip, let +the head answer, then request, step 1's precondition and not a second one. +Waiting there is compliance, and `blocker:unrequested` does not fire while a +head's checks are pending or red (#236). ## The ruling ask Set `needs-ruling` whenever a decision belongs to a human: org policy, published artifacts, secrets, prod, or any choice whose cost lands outside the PR — a panel deadlock is one instance, not the definition. The builder -is the accountable flag-setter on a PR and consolidates the decision into -one comment rather than forwarding several reviewers' phrasings (#50 D11). +is the PR's accountable flag-setter and consolidates the decision into one +comment rather than forwarding several reviewers' phrasings (#50 D11). Keep at most these five lines above the fold, all other analysis inside it. -The field labels are fixed because the ruling machinery checks for them -(#50 D12): +The field labels are fixed because the ruling machinery checks for them (#50 +D12): ```text 🧭 needs-ruling — @@ -238,32 +229,30 @@ Default: | none — hard block The options must be exhaustive and mutually exclusive; more than three means the question is not ready. `Recommend:` is mandatory — omitting it hands the whole problem to the human. `Blocked:` names both what stops and what -continues. Write a timed `Default:` only when you are affirmatively confident -the decision is reversible inside the PR before merge. Unsure is not a tie: -it is a hard block, as published artifacts, secrets, prod and org policy are -by construction (#50 D12–D13). +continues. Write a timed `Default:` only when affirmatively confident the +decision is reversible inside the PR before merge; unsure is not a tie but a +hard block, as published artifacts, secrets, prod and org policy are by +construction (#50 D12–D13). The ladder is anchored to the current episode's `needs-ruling` **`labeled` event**, not its `Default:` deadline or the last activity (#50 D13–D14): -- **0–12h:** proceed when a still-clear, reversible default expires, and say +- **0–12h:** proceed when a still-clear, reversible default expires, saying out loud that you did; a hard block waits. - **at 12h:** do not fire a stale default — re-read it against what has landed, and where doubt has appeared, make it a hard block. -- **at 24h:** proceed regardless, **as a PR**: pick an option and state in - the PR body which way you went and what doubt remains. Nothing merges by - this; the human still gates the merge. +- **at 24h:** proceed regardless, **as a PR**: pick an option and say in the + body which way you went and what doubt remains. Nothing merges by this. - **past 24h:** hand the choice to triage, which picks the option, records - it as a decision, and remains accountable; the operator can overturn it at - merge. + it as a decision, and stays accountable; the operator can overturn it. -A re-flag starts a fresh ladder. It applies whatever `Default:` says, -including a hard block, and an active back-and-forth still climbs it — -unlike the 7-day nudge, which resets on real activity. The machine observes -both clocks but never sets, clears, or decides `needs-ruling`. The label -stays until agreement is *reached*, not until the maintainer replies: the -setter records the ruling, removes the label, and returns the item to its -flow in the same comment ([LABELS.md](LABELS.md)). +A re-flag starts a fresh ladder, which applies whatever `Default:` says, +hard block included, and an active back-and-forth still climbs it — unlike +the 7-day nudge, which resets on real activity. The machine observes both +clocks but never sets, clears, or decides `needs-ruling`. The label stays +until agreement is *reached*, not until the maintainer replies: the setter +records the ruling, removes the label, and returns the item to its flow in +the same comment ([LABELS.md](LABELS.md)). ## Handoff @@ -281,6 +270,6 @@ The builder composes no new summary: the authored record already lives in the Round log, mirrored from each whole-round reply. The label write is optimistic — the reconciler validates it and takes it back if the PR is not mergeable-right-now. Then stop: the PR is the human's, and the claim is -parked as shape 4 (Picking, above), that handoff comment being its -declaration and your slot free. Address what comes back (`state:addressing`) -and re-hand-off the same way. +parked as shape 4 (Picking, above), that comment being its declaration and +your slot free. Address what comes back (`state:addressing`) and re-hand-off +the same way. From 4100121c7acca7da355d56b08f2e3cc7bb034fe3 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:30:12 +0000 Subject: [PATCH 110/162] =?UTF-8?q?docs(builder):=20final=20squeeze=20?= =?UTF-8?q?=E2=80=94=20272=20lines,=20every=20D2=20rule=20intact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUILDER.md | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index 4dff496..c7ebbd8 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -8,9 +8,9 @@ triage bug, and the move is to say so on the issue, not to guess. - Pick from issues labeled **`ready`** — never `blocked`, `claimed`, or an `epic` (epics organize; their children are the work). Inside an epic take - the earliest unblocked unclaimed child; otherwise prefer the issue that - unblocks the most work. Where a repo adopts version epics, - [RELEASES.md](RELEASES.md) governs the choice among window members. + the earliest unblocked unclaimed child, otherwise the issue that unblocks + the most work; where a repo adopts version epics, + [RELEASES.md](RELEASES.md) governs among window members. - **Your own red head outranks a new claim**: repair a failing check at your PR's head before claiming another issue (#163). Record the check and its failure class; rerun a clearly retryable infrastructure failure unchanged; @@ -42,9 +42,9 @@ triage bug, and the move is to say so on the issue, not to guess. operator may lift by label alone (#149, #151). So read the label events (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just the comments, before standing down *or* up, and say in the claim which you - read, their timestamps and their actor. Refusing to claim through the - contradiction is no resting place: where the events do not resolve it, - say so and take the next `ready` issue. + read, their timestamps and their actor. Where they do not resolve the + contradiction, say so and take the next `ready` issue; refusing is no + resting place. Not parked: waiting on yourself, on CI (a red head is yours; a pending one resolves without you), or for a good moment. An issue you stopped working on is abandoned — unassign and restore `ready`. Parked claims are held @@ -53,7 +53,7 @@ triage bug, and the move is to say so on the issue, not to guess. ## Claiming - Assign yourself, swap `ready` → `claimed`, and comment that you are - starting. The claim promises a draft PR soon: a claim with no PR and no + starting. The claim promises a draft PR soon: one with no PR and no activity is what the staleness sweep reclaims, unless `offsite` records that its PR lives in another repo. - **A park is declared, never inferred.** Comment naming what the claim @@ -70,7 +70,7 @@ triage bug, and the move is to say so on the issue, not to guess. - **Pick up `attention` before anything else**: post a short pickup comment and remove the label, which is the ack. A demand on a parked claim is usually its unpark, so take the slot back — unless the demand *is* the - park, where the pickup comment doubles as the declaration. + park, the pickup comment then doubling as the declaration. - **A directed hold keeps its bookkeeping visible.** The PR carries `blocked` with a comment naming what it waits on; the issue stays `claimed` and carries `attention` until the builder acks. Nobody unassigns @@ -85,8 +85,7 @@ triage bug, and the move is to say so on the issue, not to guess. ## Building - Branch per issue; open the PR **as a draft early**, `Closes #N` in the - body. Drafts are invisible to the panel on purpose: the draft phase is - yours. + body. Drafts are invisible to the panel on purpose: that phase is yours. - **`Closes #N` does not cross repos.** A PR in a different repo from its issue says `Part of /#N`, sets `offsite`, and comments the draft link on that issue in the same step; triage closes that issue by @@ -95,12 +94,12 @@ triage bug, and the move is to say so on the issue, not to guess. - **`Closes #N` does not survive a post-merge criterion.** Where the issue body says a criterion can only be checked after the merge — a workflow trigger proved live, a released artifact, anything whose subject does not - exist until the change is on the base branch — the same-repo PR says `Refs - #N`; the issue goes `post-merge` at the merge, the builder walks away, - triage owns verification and closure on the evidence, and corrective work - is a fresh issue any builder claims from current `main`. The issue body is - what says so — you never judge which qualify, and absent that instruction - `Closes #N` is the default (#151). + exist until the change is on the base branch — the same-repo PR says + `Refs #N`; the issue goes `post-merge` at the merge, the builder walks + away, triage owns verification and closure on the evidence, and corrective + work is a fresh issue any builder claims from current `main`. The issue + body says so — you never judge which qualify — and absent it `Closes #N` + is the default (#151). - On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`) immediately before `#N` anywhere in the body, including the sentence @@ -258,8 +257,7 @@ the same comment ([LABELS.md](LABELS.md)). When the round passes — every panel verdict approving the **current head**, no `blocker:*` standing (conflicts rebased, CI green, drill recorded if this -is a release PR) — the engine does these mechanical steps for the builder, -in order: +is a release PR) — the engine does these steps for the builder, in order: 1. request the human's review; 2. set `state:needs-human`; @@ -269,7 +267,6 @@ in order: The builder composes no new summary: the authored record already lives in the Round log, mirrored from each whole-round reply. The label write is optimistic — the reconciler validates it and takes it back if the PR is not -mergeable-right-now. Then stop: the PR is the human's, and the claim is -parked as shape 4 (Picking, above), that comment being its declaration and -your slot free. Address what comes back (`state:addressing`) and re-hand-off -the same way. +mergeable-right-now. Then stop: the PR is the human's, and the claim parks +as shape 4 (Picking, above), that comment its declaration and your slot +free. Address what comes back (`state:addressing`) and re-hand-off alike. From 46aeb02078640bfe19e1e4d4a34234965893559a Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:31:48 +0000 Subject: [PATCH 111/162] docs(builder): restore five rules the squeeze had compressed away --- BUILDER.md | 77 +++++++++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index c7ebbd8..29a084b 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -90,16 +90,18 @@ triage bug, and the move is to say so on the issue, not to guess. issue says `Part of /#N`, sets `offsite`, and comments the draft link on that issue in the same step; triage closes that issue by hand once its criteria are met, the builder reporting there whether the PR - merged or closed and clearing `offsite` in the same comment (#13, #16). + merged or closed and clearing `offsite` in the same comment. The + cross-repo merge never closes the authorizing issue (#13, #16). - **`Closes #N` does not survive a post-merge criterion.** Where the issue body says a criterion can only be checked after the merge — a workflow trigger proved live, a released artifact, anything whose subject does not - exist until the change is on the base branch — the same-repo PR says - `Refs #N`; the issue goes `post-merge` at the merge, the builder walks - away, triage owns verification and closure on the evidence, and corrective - work is a fresh issue any builder claims from current `main`. The issue - body says so — you never judge which qualify — and absent it `Closes #N` - is the default (#151). + exist until the change is on the base branch — the same-repo PR says `Refs + #N`; the issue goes `post-merge` at the merge, the builder walks away, and + triage owns verification and closure on the evidence, returning the issue + to `ready` or minting a fresh one where corrective work is needed — + claimable by any builder from current `main`, the original having no + special standing. The issue body says so — you never judge which qualify — + and absent it `Closes #N` is the default (#151). - On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`) immediately before `#N` anywhere in the body, including the sentence @@ -139,6 +141,9 @@ triage bug, and the move is to say so on the issue, not to guess. ## The review round +(In a governed repo this file is `.ceremony/BUILDER.md`: repo-specific facts +such as the panel roster live in that repo's own CONTRIBUTING.) + 1. Mark ready-for-review; request **the whole panel**: the PR repo's `panel[]=` line if it defines one, else its `panel=` line, minus the author (#224) — never the roster of the repo the issue is in. @@ -151,39 +156,41 @@ triage bug, and the move is to say so on the issue, not to guess. request. The one exception is a failure genuinely outside the PR — a runner outage, a flaky dependency, a failure already on the default branch — and only where the request says so and names the evidence ("the - same job fails identically on `origin/main` at ``"). *Green* is a - ruled term (operator, 2026-07-27), read in two steps. **First take the - check's word at this head**: its newest entry by start time — not - completion, a cancelled run outliving its replacement's start — and never - a `CANCELLED` entry while the same check has a non-cancelled one there. A - check whose entries at the head are all cancelled has not reported at all - and is not green, the gate collapsing alike (#139, #276). **Then classify - that entry by `conclusion`, never `status`**, which can disagree with it - (#259). No conclusion is not green: a configured run in progress is - waited on, and waiting is compliance, not a stall. Cancelled or stale is - not green, *stale* being a superseded head's check, which a head-scoped - rollup never shows. Skipped or neutral is green, those being deliberate - "passed / not applicable" conclusions. No checks configured is green — - the third ruled case, not an argued exception, so the request goes out at - once with no evidence owed; that never covers nothing-answered-yet, and - the machine partitions alike, admitting the ask on `SUCCESS` and `NONE` - (#236). The costs behind the line are asymmetric: a false green spends a + same job fails identically on `origin/main` at ``"); silence about a + red check is what is prohibited. *Green* is a ruled term (operator, + 2026-07-27), read in two steps. **First take the check's word at this + head**: its newest entry by start time — not completion, a cancelled run + outliving its replacement's start — and never a `CANCELLED` entry while + the same check has a non-cancelled one there. A check whose entries at + the head are all cancelled has not reported at all and is not green, the + gate collapsing alike (#139, #276). **Then classify that entry by + `conclusion`, never `status`**, which can disagree with it (#259). No + conclusion is not green: a configured run in progress is waited on, and + waiting is compliance, not a stall. Cancelled or stale is not green, + *stale* being a superseded head's check, which a head-scoped rollup never + shows. Skipped or neutral is green, those being deliberate "passed / not + applicable" conclusions. No checks configured is green — the third ruled + case, not an argued exception, so the request goes out at once with no + evidence owed; that never covers nothing-answered-yet, and the machine + partitions alike, admitting the ask on `SUCCESS` and `NONE` (#236). The + costs behind the line are asymmetric: a false green spends a three-reviewer round, a false red one author session. What the machine drops from the rollup before grading is crew's to describe. 2. **Wait for every verdict, then answer the round whole** — one reply covering every point, stating what changed and what was verified. That reply is the written record: the engine mirrors it under the PR body's - **Round log**, newest last, so you owe the reply and no body edit, and a - round answered without one is recorded as such and never blocks handoff. - Then push the fixes and re-request **by head, not by verdict**. A push - makes every approval 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, approvers included**; one left un-re-requested - can never approve the tree you shipped (#26, #39). Only where the head - did not move — answered with argument or evidence, nothing pushed — do - you re-request just the non-approvers (#94). **The re-request carries the - same green-check-at-head precondition**, argued exception included: a fix - push whose check comes up red is your next fix, not the panel's. Prefer + **Round log**, newest last, marked with the round's head so a retry is a + no-op, so you owe the reply and no body edit; a round answered without + one is recorded as such and never blocks handoff. Then push the fixes and + re-request **by head, not by verdict**. A push makes every approval 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, + approvers included**; one left un-re-requested can never approve the tree + you shipped (#26, #39). Only where the head did not move — answered with + argument or evidence, nothing pushed — do you re-request just the + non-approvers (#94). **The re-request carries the same + green-check-at-head precondition**, argued exception included: a fix push + whose check comes up red is your next fix, not the panel's. Prefer verification over argument — add the test that settles the doubt. 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 From b4779bbc18abbfdcf3dd4f220ae131adacd39249 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:32:36 +0000 Subject: [PATCH 112/162] docs(builder): keep code spans unbroken across the wrap --- BUILDER.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index 29a084b..eb11e9f 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -95,13 +95,13 @@ triage bug, and the move is to say so on the issue, not to guess. - **`Closes #N` does not survive a post-merge criterion.** Where the issue body says a criterion can only be checked after the merge — a workflow trigger proved live, a released artifact, anything whose subject does not - exist until the change is on the base branch — the same-repo PR says `Refs - #N`; the issue goes `post-merge` at the merge, the builder walks away, and - triage owns verification and closure on the evidence, returning the issue - to `ready` or minting a fresh one where corrective work is needed — - claimable by any builder from current `main`, the original having no - special standing. The issue body says so — you never judge which qualify — - and absent it `Closes #N` is the default (#151). + exist until the change is on the base branch — the same-repo PR says + `Refs #N`; the issue goes `post-merge` at the merge, the builder walks + away, and triage owns verification and closure on the evidence, returning + the issue to `ready` or minting a fresh one where corrective work is + needed — claimable by any builder from current `main`, the original having + no special standing. The issue body says so — you never judge which + qualify — and absent it `Closes #N` is the default (#151). - On a `Refs #N` PR, never put a closing keyword (`close`, `closes`, `closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`) immediately before `#N` anywhere in the body, including the sentence From 4800aef363b3342a574d979227fbb4a329c40cb1 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:36:41 +0000 Subject: [PATCH 113/162] docs(builder): restore the re-request deferral and three clauses the audit found --- BUILDER.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index eb11e9f..a1b8650 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -157,13 +157,14 @@ such as the panel roster live in that repo's own CONTRIBUTING.) runner outage, a flaky dependency, a failure already on the default branch — and only where the request says so and names the evidence ("the same job fails identically on `origin/main` at ``"); silence about a - red check is what is prohibited. *Green* is a ruled term (operator, - 2026-07-27), read in two steps. **First take the check's word at this - head**: its newest entry by start time — not completion, a cancelled run - outliving its replacement's start — and never a `CANCELLED` entry while - the same check has a non-cancelled one there. A check whose entries at - the head are all cancelled has not reported at all and is not green, the - gate collapsing alike (#139, #276). **Then classify that entry by + red check is what is prohibited, and an argued exception shifts the + burden to the author. *Green* is a ruled term (operator, 2026-07-27), + read in two steps. **First take the check's word at this head**: its + newest entry by start time — not completion, a cancelled run outliving + its replacement's start — and never a `CANCELLED` entry while the same + check has a non-cancelled one there. A check whose entries at the head + are all cancelled has not reported at all and is not green, the gate + collapsing alike (#139, #276). **Then classify that entry by `conclusion`, never `status`**, which can disagree with it (#259). No conclusion is not green: a configured run in progress is waited on, and waiting is compliance, not a stall. Cancelled or stale is not green, @@ -188,7 +189,8 @@ such as the panel roster live in that repo's own CONTRIBUTING.) approvers included**; one left un-re-requested can never approve the tree you shipped (#26, #39). Only where the head did not move — answered with argument or evidence, nothing pushed — do you re-request just the - non-approvers (#94). **The re-request carries the same + non-approvers, the engine absorbing a re-request at an unchanged head — + its mechanism crew's to describe (#94). **The re-request carries the same green-check-at-head precondition**, argued exception included: a fix push whose check comes up red is your next fix, not the panel's. Prefer verification over argument — add the test that settles the doubt. @@ -248,9 +250,11 @@ event**, not its `Default:` deadline or the last activity (#50 D13–D14): - **at 12h:** do not fire a stale default — re-read it against what has landed, and where doubt has appeared, make it a hard block. - **at 24h:** proceed regardless, **as a PR**: pick an option and say in the - body which way you went and what doubt remains. Nothing merges by this. + body which way you went and what doubt remains. Nothing merges by this; + the human still gates the merge. - **past 24h:** hand the choice to triage, which picks the option, records - it as a decision, and stays accountable; the operator can overturn it. + it as a decision, and stays accountable; the operator can overturn it at + merge. A re-flag starts a fresh ladder, which applies whatever `Default:` says, hard block included, and an active back-and-forth still climbs it — unlike @@ -276,4 +280,5 @@ the Round log, mirrored from each whole-round reply. The label write is optimistic — the reconciler validates it and takes it back if the PR is not mergeable-right-now. Then stop: the PR is the human's, and the claim parks as shape 4 (Picking, above), that comment its declaration and your slot -free. Address what comes back (`state:addressing`) and re-hand-off alike. +free. Address what comes back (`state:addressing`) and re-hand-off the same +way. From 43d845575af32e212ef1e9af0ebf1de1a0e0c0e3 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:39:06 +0000 Subject: [PATCH 114/162] docs(builder): state #276's collapse rule in full, not by allusion --- BUILDER.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index a1b8650..2aa0929 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -163,9 +163,11 @@ such as the panel roster live in that repo's own CONTRIBUTING.) newest entry by start time — not completion, a cancelled run outliving its replacement's start — and never a `CANCELLED` entry while the same check has a non-cancelled one there. A check whose entries at the head - are all cancelled has not reported at all and is not green, the gate - collapsing alike (#139, #276). **Then classify that entry by - `conclusion`, never `status`**, which can disagree with it (#259). No + are all cancelled has not reported at all and is not green — a collapse, + not a new class, and the gate partitions alike, dropping a cancelled + entry only where a non-cancelled survivor remains and leaving an + all-cancelled context blocking (#139, #276). **Then classify that entry + by `conclusion`, never `status`**, which can disagree with it (#259). No conclusion is not green: a configured run in progress is waited on, and waiting is compliance, not a stall. Cancelled or stale is not green, *stale* being a superseded head's check, which a head-scoped rollup never From 900963d6539c3d7e68831ad129dce00c349646d4 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:40:08 +0000 Subject: [PATCH 115/162] docs(builder): name the subject in the staleness-sweep clause --- BUILDER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUILDER.md b/BUILDER.md index 2aa0929..8c3a35d 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -53,7 +53,7 @@ triage bug, and the move is to say so on the issue, not to guess. ## Claiming - Assign yourself, swap `ready` → `claimed`, and comment that you are - starting. The claim promises a draft PR soon: one with no PR and no + starting. The claim promises a draft PR soon: a claim with no PR and no activity is what the staleness sweep reclaims, unless `offsite` records that its PR lives in another repo. - **A park is declared, never inferred.** Comment naming what the claim From 49e1fe2e6ef82de15365c5ec317e9f75f6ce0150 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:40:51 +0000 Subject: [PATCH 116/162] docs(builder): give step 1 its paragraph breaks back; fix two doubled connectives --- BUILDER.md | 90 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 47 insertions(+), 43 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index 8c3a35d..243da93 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -150,52 +150,56 @@ such as the panel roster live in that repo's own CONTRIBUTING.) That repo's `.github/labels.conf` governs over its CONTRIBUTING roster, being what the state machine reads; where it names no roster, ask triage on the authorizing issue rather than guess. An off-panel reviewer may be - requested, said to be advisory and not required. **A review request - requires a green check at the head**, whether or not an engine enforces - it: a red check is the author's own signal, so fix it and push, then - request. The one exception is a failure genuinely outside the PR — a - runner outage, a flaky dependency, a failure already on the default - branch — and only where the request says so and names the evidence ("the - same job fails identically on `origin/main` at ``"); silence about a - red check is what is prohibited, and an argued exception shifts the - burden to the author. *Green* is a ruled term (operator, 2026-07-27), - read in two steps. **First take the check's word at this head**: its - newest entry by start time — not completion, a cancelled run outliving - its replacement's start — and never a `CANCELLED` entry while the same - check has a non-cancelled one there. A check whose entries at the head - are all cancelled has not reported at all and is not green — a collapse, - not a new class, and the gate partitions alike, dropping a cancelled - entry only where a non-cancelled survivor remains and leaving an - all-cancelled context blocking (#139, #276). **Then classify that entry - by `conclusion`, never `status`**, which can disagree with it (#259). No - conclusion is not green: a configured run in progress is waited on, and - waiting is compliance, not a stall. Cancelled or stale is not green, - *stale* being a superseded head's check, which a head-scoped rollup never - shows. Skipped or neutral is green, those being deliberate "passed / not - applicable" conclusions. No checks configured is green — the third ruled - case, not an argued exception, so the request goes out at once with no - evidence owed; that never covers nothing-answered-yet, and the machine - partitions alike, admitting the ask on `SUCCESS` and `NONE` (#236). The - costs behind the line are asymmetric: a false green spends a - three-reviewer round, a false red one author session. What the machine - drops from the rollup before grading is crew's to describe. + requested, said to be advisory and not required. + + **A review request requires a green check at the head**, whether or not + an engine enforces it: a red check is the author's own signal, so fix it + and push, then request. The one exception is a failure genuinely outside + the PR — a runner outage, a flaky dependency, a failure already on the + default branch — and only where the request says so and names the + evidence ("the same job fails identically on `origin/main` at ``"); + silence about a red check is what is prohibited, and an argued exception + shifts the burden to the author. + + *Green* is a ruled term (operator, 2026-07-27), read in two steps. + **First take the check's word at this head**: its newest entry by start + time — not completion, a cancelled run outliving its replacement's start + — and never a `CANCELLED` entry while the same check has a non-cancelled + one there. A check whose entries at the head are all cancelled has not + reported at all and is not green — a collapse, not a new class, and the + gate partitions alike, dropping a cancelled entry only where a + non-cancelled survivor remains and leaving an all-cancelled context + blocking (#139, #276). **Then classify that entry by `conclusion`, never + `status`**, which can disagree with it (#259). No conclusion is not + green: a configured run in progress is waited on, and waiting is + compliance, not a stall. Cancelled or stale is not green, *stale* being a + superseded head's check, which a head-scoped rollup never shows. Skipped + or neutral is green, those being deliberate "passed / not applicable" + conclusions. No checks configured is green — the third ruled case, not an + argued exception, so the request goes out at once with no evidence owed; + that never covers nothing-answered-yet, and the machine partitions alike, + admitting the ask on `SUCCESS` and `NONE` (#236). The costs behind the + line are asymmetric: a false green spends a three-reviewer round, a false + red one author session. What the machine drops from the rollup before + grading is crew's to describe. 2. **Wait for every verdict, then answer the round whole** — one reply covering every point, stating what changed and what was verified. That reply is the written record: the engine mirrors it under the PR body's - **Round log**, newest last, marked with the round's head so a retry is a - no-op, so you owe the reply and no body edit; a round answered without - one is recorded as such and never blocks handoff. Then push the fixes and - re-request **by head, not by verdict**. A push makes every approval 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, - approvers included**; one left un-re-requested can never approve the tree - you shipped (#26, #39). Only where the head did not move — answered with - argument or evidence, nothing pushed — do you re-request just the - non-approvers, the engine absorbing a re-request at an unchanged head — - its mechanism crew's to describe (#94). **The re-request carries the same - green-check-at-head precondition**, argued exception included: a fix push - whose check comes up red is your next fix, not the panel's. Prefer - verification over argument — add the test that settles the doubt. + **Round log**, newest last and marked with the round's head, which makes + a retry a no-op; you owe the reply and no body edit, and a round answered + without one is recorded as such and never blocks handoff. Then push the + fixes and re-request **by head, not by verdict**. A push makes every + approval 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, approvers included**; one left un-re-requested can never + approve the tree you shipped (#26, #39). Only where the head did not move + — answered with argument or evidence, nothing pushed — do you re-request + just the non-approvers; the engine absorbs a re-request at an unchanged + head, and its mechanism is crew's to describe (#94). **The re-request + carries the same green-check-at-head precondition**, argued exception + included: a fix push whose check comes up red is your next fix, not the + panel's. Prefer verification over argument — add the test that settles + the doubt. 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; silence and force-forward are not options, and a panel From 08b817b81497fa874e46045c188b13b32302edc6 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:42:24 +0000 Subject: [PATCH 117/162] docs: name the epic task-list heading --- TRIAGE.md | 7 +++++-- changelog.d/266.md | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 changelog.d/266.md diff --git a/TRIAGE.md b/TRIAGE.md index d8ee1be..b7451bb 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -122,8 +122,11 @@ expected. 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 +dependency-ordered checklist of child issues. Children reference the epic; +that checklist is the progress view. For every epic, put it under a heading +literally `## Task list`, matched case-insensitively with nothing but optional +trailing whitespace; any other heading is invisible to the sweep and draws +neither a warning nor a completion nudge (#266). Builders never pick the epic itself. Keep the checklist current — a stale epic misleads every scan. Repositories that adopt version epics follow [RELEASES.md](RELEASES.md). diff --git a/changelog.d/266.md b/changelog.d/266.md new file mode 100644 index 0000000..9d6bd0b --- /dev/null +++ b/changelog.d/266.md @@ -0,0 +1,5 @@ +### Changed + +- TRIAGE.md now tells every epic author to put its progress checklist under + the literal `## Task list` heading, because any other heading is silently + invisible to the completion sweep (#266). From 92c0e7c6f0189ca7d3e2e88e6152787ea3e0d575 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:13:34 +0000 Subject: [PATCH 118/162] docs(builder): bind the red-head trigger to the round's ruled terms The red-head rule in Picking carried its own trigger definition at the pre-slim head; the squeeze took it, leaving 'failing check' and 'red check' classified only forward in the review round. At an all-cancelled head that let parked shape 2 read satisfied on its face, parking a claim the rule says is never parked (#163, #276). Co-Authored-By: Claude Opus 5 (1M context) --- BUILDER.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/BUILDER.md b/BUILDER.md index 243da93..4429bbc 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -12,14 +12,16 @@ triage bug, and the move is to say so on the issue, not to guess. the most work; where a repo adopts version epics, [RELEASES.md](RELEASES.md) governs among window members. - **Your own red head outranks a new claim**: repair a failing check at your - PR's head before claiming another issue (#163). Record the check and its - failure class; rerun a clearly retryable infrastructure failure unchanged; - treat a branch failure as an ordinary fix round, worklog and all; leave - evidence where a rerun cannot start or the cause is unclear; never rerun a - deterministic failure without a corrective commit; hand off once green - with current-head approvals. Such a PR is **never parked**, whatever the - verdict state says; how the engine detects a red head is crew's to - describe. + PR's head before claiming another issue (#163). Red and green here are the + review round's ruled terms: cancelled, stale, or unreported — every entry + at the head cancelled — is not green; skipped or neutral is. Record the + check and its failure class; rerun a clearly retryable infrastructure + failure unchanged; treat a branch failure as an ordinary fix round, + worklog and all; leave evidence where a rerun cannot start or the cause is + unclear; never rerun a deterministic failure without a corrective commit; + hand off once green with current-head approvals. Such a PR is **never + parked**, whatever the verdict state says; how the engine detects a red + head is crew's to describe. - **One build at a time**: one issue on which you are writing or revising a deliverable, finished or released before you start more. The rule counts work in flight, not claims — a **parked** claim, whose next move is From 90961cad5174246d08b7275aa6a889e04ef5a940 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:27:49 +0000 Subject: [PATCH 119/162] docs: declare collision-edge chains --- TRIAGE.md | 5 +++++ changelog.d/288.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 changelog.d/288.md diff --git a/TRIAGE.md b/TRIAGE.md index b7451bb..37cb239 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -110,6 +110,11 @@ Every issue you mint carries, in this order: epic organizes it. Name a cross-repo dependency the same way with its repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep cannot resolve it, so triage verifies it and flips the issue by hand. + When a deliverable is already carried by an open `ready`, `claimed`, or + `blocked` issue, the newer issue must declare an unconditional collision + edge with `Blocked by #N`, naming the newest open carrier; there is no + alternative for disjoint regions. This keeps every `ready` issue + concurrently claimable and makes each close release one successor (#288). - **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)). diff --git a/changelog.d/288.md b/changelog.d/288.md new file mode 100644 index 0000000..245e524 --- /dev/null +++ b/changelog.d/288.md @@ -0,0 +1,5 @@ +### Changed + +- TRIAGE.md now requires unconditional collision-edge chains when open issues + carry the same deliverable, keeping the ready queue concurrently claimable + (#288). From 353fa54ae19ded0266f65b8501955d4ad1ea5447 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:03:25 +0000 Subject: [PATCH 120/162] fix: abort on unreadable issue board --- .../issueflow-reconcile.sh | 23 +++++++--- changelog.d/257.md | 3 ++ test/issueflow-reconcile.test.sh | 45 +++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 changelog.d/257.md diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 06f4875..f6c6db1 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -918,13 +918,26 @@ main() { done < <(refs_references <<<"$body") done)" - local n tail_line + local n tail_line issue_numbers SKIPPED_COUNT=0 SKIPPED_ISSUES="" - for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \ - --jq '.[] | select(has("pull_request") | not) | .number'); do - reconcile_issue_pass "$n" - done + # A command substitution in a for list suppresses errexit. Capture and + # check the board read before entering the loop, or a 504 (including one + # after partial pagination) reports a full pass over a truncated board + # (#257). + if ! guarded_read issue_numbers gh api --paginate \ + "repos/$REPO/issues?state=open&per_page=100" \ + --jq '.[] | select(has("pull_request") | not) | .number'; then + log "could not read the issue board: $(read_failure_reason "$READ_FAILURE_STDERR")" + return 1 + fi + if [ -z "$issue_numbers" ]; then + log "no open issues." + else + while IFS= read -r n; do + [ -n "$n" ] && reconcile_issue_pass "$n" + done <<<"$issue_numbers" + fi log "reconciled." # The job stays green (D7): an hourly sweep over a hundred-issue board meets # transient 504s as a matter of course, and reddening the whole run for one diff --git a/changelog.d/257.md b/changelog.d/257.md new file mode 100644 index 0000000..b49ecfa --- /dev/null +++ b/changelog.d/257.md @@ -0,0 +1,3 @@ +### Fixed + +- Abort issue-flow reconciliation when the board read fails instead of reporting a complete pass over an empty or partial result (#257). diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 605577e..fc0ed67 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -1624,6 +1624,51 @@ sweep_run() { bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 } +# The board read is a precondition for the whole pass (#257). A failed read +# cannot be inferred from an empty result: its status is the only fact that +# separates an unreadable board from a clean one. Drive both through main(), +# including gh's partial-pagination shape where stdout is non-empty on error. +board_fixture="$SWEEP/repos_owner_repo_issues_state_open_per_page_100.json" +printf '%s\n' "$GH_STUB_ERROR_BODY" >"$board_fixture.http-error" +board_504_out="$(sweep_run)" +board_504_rc=$? +check "an issue-list 504 aborts the whole pass" 0 "" test "$board_504_rc" -eq 1 +check "...names the board read's stderr" 0 \ + "issueflow: could not read the issue board: $GH_STUB_STDERR" \ + printf '%s\n' "$board_504_out" +check "...writes no issue edit or comment" 1 "" test -s "$SWEEP/edits" +check "...never reports the pass reconciled" 1 "" \ + grep -qF 'issueflow: reconciled.' <<<"$board_504_out" + +board_silent_out="$(GH_STUB_STDERR="" sweep_run)" +board_silent_rc=$? +check "a silent issue-list failure still aborts" 0 "" test "$board_silent_rc" -eq 1 +check "...renders the empty stderr as a fact" 0 \ + 'issueflow: could not read the issue board: no error output' \ + printf '%s\n' "$board_silent_out" +check "...also writes nothing" 1 "" test -s "$SWEEP/edits" + +printf '[{"number":71}]\n' >"$board_fixture.http-error" +partial_board_out="$(sweep_run)" +partial_board_rc=$? +check "partial pagination aborts the whole pass" 0 "" \ + test "$partial_board_rc" -eq 1 +check "...does not reconcile the returned first page" 1 "" \ + grep -qF 'issue edit 71' "$SWEEP/edits" +check "...does not report the truncated pass reconciled" 1 "" \ + grep -qF 'issueflow: reconciled.' <<<"$partial_board_out" + +rm -f "$board_fixture.http-error" +sweep_board '[]' +empty_board_out="$(sweep_run)" +empty_board_rc=$? +check "a successful empty board stays green" 0 "" test "$empty_board_rc" -eq 0 +check "...writes nothing" 1 "" test -s "$SWEEP/edits" +check "...names the empty-board outcome" 0 'issueflow: no open issues.' \ + printf '%s\n' "$empty_board_out" +check "...still ends with byte-identical reconciled." 0 \ + 'issueflow: reconciled.' printf '%s\n' "$(tail -n1 <<<"$empty_board_out")" + sweep_board '[{"number":70},{"number":71}]' sweep_out="$(sweep_run)" sweep_rc=$? From 0fe015da69caa48fca3f2c533a6dffcc9c4543d7 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:33:39 +0000 Subject: [PATCH 121/162] fix(labels): the scope map locates again changelog.d/** matched every PR that changes behavior, so scope:release-flow was a constant, not a locator (#267). --- .github/labeler.yml | 28 ++++++++++++++++-- test/labels-scope.test.sh | 62 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index aa1a7aa..6ec4baf 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -7,6 +7,18 @@ # these globs implement. Scopes locate, they do not alert — a path that maps # to nothing is fine (the mapping is advisory), so these rows chase the big # surfaces, not every file. +# +# A row that matches everything is worse than a missing one: it costs the +# same silence and adds a wrong answer. `changelog.d/**` sat under +# scope:release-flow until #267 measured it — the last 20 PRs (#197–#263) +# all carried scope:release-flow and 3 of them touched a release surface, +# because BUILDER.md makes every behavior change write a fragment, so the +# glob was "any PR that changes behavior" by doctrine. CHANGELOG.md stays: +# the same doctrine forbids editing it for an entry, so only the release PR +# does. The other rows #267 added — the issueflow reconciler, the three +# unmapped guard actions, RELEASES.md, README (which this tree spells +# README.md, so the old glob could match nothing) — are the same read of the +# same file, gaps rather than wrong answers. scope:release-flow: - changed-files: - any-glob-to-any-file: @@ -18,7 +30,6 @@ scope:release-flow: - bin/** - VERSION - CHANGELOG.md - - changelog.d/** - drills/** - test/decide.test.sh - test/facts.test.sh @@ -30,14 +41,20 @@ scope:guards: - changed-files: - any-glob-to-any-file: - actions/changelog-armed/** + - actions/changelog-assembled/** - actions/changelog-monotonic/** + - actions/docs-sync/** - actions/drill-recorded/** - actions/refs-not-closing/** + - actions/runner-isolated/** - .github/workflows/refs-guard.yml - test/changelog-armed.test.sh + - test/changelog-assembled.test.sh - test/changelog-monotonic.test.sh + - test/docs-sync.test.sh - test/drill-recorded.test.sh - test/refs-not-closing.test.sh + - test/runner-isolated.test.sh scope:labels: - changed-files: - any-glob-to-any-file: @@ -45,19 +62,26 @@ scope:labels: - .github/workflows/self-labels.yml - .github/labeler.yml - .github/labels.conf + - actions/issueflow-reconcile/** - actions/labels-reconcile/** - actions/labels-scope/** + # shared by both reconcilers; lib/** keeps scope:release-flow too, + # and a mixed file honestly wears both labels (#267 D4) + - lib/read.sh + - lib/ruling.sh - LABELS.md + - test/issueflow-reconcile.test.sh - test/labels.test.sh - test/labels-reconcile.test.sh - test/labels-scope.test.sh scope:docs: - changed-files: - any-glob-to-any-file: - - README + - README.md - docs/** - AGENTS.md - BUILDER.md + - RELEASES.md - REVIEWER.md - TRIAGE.md - CONTRIBUTING.md diff --git a/test/labels-scope.test.sh b/test/labels-scope.test.sh index d34687f..cbedf99 100644 --- a/test/labels-scope.test.sh +++ b/test/labels-scope.test.sh @@ -107,6 +107,68 @@ EOF check "derive: the real mapping labels this test file" 0 \ "scope:labels" derive_labels "$real_rows" 'test/labels-scope.test.sh' + # --- the real mapping locates: one file set in, the whole label set out --- + # #267 measured the old map at 100% recall / 15% precision — 20 of the last + # 20 PRs wore scope:release-flow and 3 touched a release surface — so these + # cases assert the DERIVED SET WHOLE, brackets and all. A substring check + # cannot tell scope:labels from scope:labels plus a wrong second label, and + # a wrong second label is the whole defect. + derives() { # → "[label,label]" for the real map + printf '[%s]\n' "$(derive_labels "$real_rows" "$1" | paste -sd, -)" + } + files() { printf '%s\n' "$@"; } + + # D1: a fragment is written by every behavior change (BUILDER.md), so it + # carries no locating information. Asserted as an empty set on its own, not + # as an absence inside a longer list: this is the case that fails first if + # the glob is ever restored. + check "derive: a fragment-only path derives nothing at all" 0 \ + "[]" derives 'changelog.d/999.md' + + # D2: the issue-flow sweep is a reconciler of the label taxonomy + check "derive: the issueflow reconciler is scope:labels" 0 \ + "[scope:labels]" derives 'actions/issueflow-reconcile/issueflow-reconcile.sh' + check "derive: the issueflow reconciler's test is scope:labels" 0 \ + "[scope:labels]" derives 'test/issueflow-reconcile.test.sh' + + # the reported bug, replayed: #261's exact file set wore scope:release-flow, + # inherited from its fragment, pointing at the one surface it does not touch + check "derive: #261's file set is scope:labels alone" 0 "[scope:labels]" \ + derives "$(files actions/issueflow-reconcile/issueflow-reconcile.sh \ + changelog.d/252.md test/issueflow-reconcile.test.sh)" + + # D1's cost, checked rather than assumed: dropping the fragment glob must + # not cost the release surface its label + check "derive: a release PR is still scope:release-flow" 0 \ + "[scope:release-flow]" \ + derives "$(files VERSION CHANGELOG.md drills/0.6.0.md changelog.d/236.md)" + + # D3: the docs block matched a literal README this tree does not have + check "derive: README.md is scope:docs" 0 "[scope:docs]" derives 'README.md' + check "derive: RELEASES.md is scope:docs" 0 "[scope:docs]" derives 'RELEASES.md' + check "derive: TRIAGE.md is scope:docs" 0 "[scope:docs]" derives 'TRIAGE.md' + + # D3: three guard actions and their tests were in no block at all + for guard in changelog-assembled docs-sync runner-isolated; do + check "derive: actions/$guard is scope:guards" 0 "[scope:guards]" \ + derives "$(files "actions/$guard/$guard.sh" "test/$guard.test.sh")" + done + + # D4: lib/ is genuinely mixed, so the shared files wear both labels rather + # than lib/** being re-carved into a row per file + check "derive: lib/ruling.sh is release-flow AND labels" 0 \ + "[scope:release-flow,scope:labels]" derives 'lib/ruling.sh' + check "derive: lib/read.sh is release-flow AND labels" 0 \ + "[scope:release-flow,scope:labels]" derives 'lib/read.sh' + check "derive: lib/version.sh is release-flow only" 0 \ + "[scope:release-flow]" derives 'lib/version.sh' + + # D6: the map stays advisory. An unmapped path derives an empty set and + # exits 0 — a guard that redded here would fail every PR touching FLEET.md + # or ci.yml, neither of which this map claims. + check "derive: an unmapped path is silence, not an error" 0 "[]" \ + derives "$(files FLEET.md .github/workflows/ci.yml)" + # refusals: unsupported shapes fail loudly, naming the label cat >"$TMP/allglobs.yml" <<'EOF' scope:x: From 535670281746200a66f0441b0ad1bd20472a47b8 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:34:17 +0000 Subject: [PATCH 122/162] docs(changelog): fragment for #267 --- changelog.d/267.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 changelog.d/267.md diff --git a/changelog.d/267.md b/changelog.d/267.md new file mode 100644 index 0000000..2fb77a0 --- /dev/null +++ b/changelog.d/267.md @@ -0,0 +1,19 @@ +### Fixed + +- `scope:release-flow` no longer rides every pull request: `changelog.d/**` + is out of its path map. Doctrine makes every behavior change write a + fragment, so the glob labelled 20 of the last 20 PRs while 3 touched a + release surface. `CHANGELOG.md` stays, as only the release PR edits it + (#267). +- The issue-flow reconciler and its test now derive `scope:labels`, the scope + that already names the taxonomy they reconcile (#267). + +### Changed + +- `README.md` and `RELEASES.md` derive `scope:docs`, and the + `changelog-assembled`, `docs-sync` and `runner-isolated` actions and tests + derive `scope:guards`; all five were mapped nowhere. The docs block matched + a literal `README`, which this tree does not carry (#267). +- `lib/read.sh` and `lib/ruling.sh` derive `scope:labels` beside + `scope:release-flow`. Both reconcilers share them, and a mixed file wears + both labels rather than `lib/**` being re-carved into a row per file (#267). From de3ab517d7ccb783bec7814391e174bafd36173a Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:48:27 +0000 Subject: [PATCH 123/162] docs: slim triage doctrine --- TRIAGE.md | 68 ++++++++++++++++------------------------------ changelog.d/282.md | 5 ++++ 2 files changed, 28 insertions(+), 45 deletions(-) create mode 100644 changelog.d/282.md diff --git a/TRIAGE.md b/TRIAGE.md index 37cb239..58ae791 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -1,32 +1,25 @@ # 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. +You are the only door issues come through. Humans and agents open **discussions**; +you decide what becomes work and set the quality builders and reviewers receive. ## 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. +Discussions may be ambiguous; issues may not: a builder must be able to execute +one **without asking anything**. One accountable role keeps builders from guessing. ## 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. + substance back into a discussion and close it, saying why. Route the work + without shaming the filer. ## 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. + already shipped or tracked. Reply with the answer and evidence; 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 @@ -35,11 +28,10 @@ is the failure this whole flow exists to prevent. 3. **Escalate.** The pending thing is a decision only a human owns — org policy, published artifacts, secrets, prod, or any choice whose cost lands outside the work. A panel deadlock is one instance, not the definition - ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Say - precisely what the decision is, name the decider, and use + (#50 D11). Say precisely what the decision is, name the decider, and use [BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask), including its options, recommendation, blocked/continues statement, and - reversible-only default rules ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)). + reversible-only default rules (#50 D12–D13). The discussion is where humans decide; wait there. When the decision blocks something already on the board — an existing issue, or minted work a discussion's ruling gates — set `needs-ruling` on it too, so the board @@ -53,18 +45,12 @@ is the failure this whole flow exists to prevent. `needs-ruling` ask — re-read that issue's **label events** (`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its comments: the answer often arrives as a label with no comment, and a - write that re-read only the thread races it. Both 2026-07-24 failures — - [a header correction on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070758613) - asserting a hold 58 seconds after its lift, and - [a `needs-ruling` ask on #151](https://github.com/heavy-duty/ceremony/issues/151#issuecomment-5070768876) - the operator's label events had answered 132 seconds earlier — are this - sentence's absence. + write that re-read only the thread races it (#149, #151). Past 24 hours from the current episode's `labeled` event, if the ruling still stands and doubt remains, it is triage's duty to pick the option the builder proceeds on, record that pick as a decision, and stay accountable - for it; the operator may overturn it at merge - ([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)). You set - the flag, so you also close it out ([LABELS.md](LABELS.md)): judge when + for it; the operator may overturn it at merge (#50 D13–D14). You set the + flag, so you also close it out ([LABELS.md](LABELS.md)): judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the issue to its flow in that same comment; when that ruling or any directive or answered builder question delivers @@ -96,15 +82,12 @@ Every issue you mint carries, in this order: A criterion that can only be checked after the merge must carry its own mechanism, in the criterion itself: that it is post-merge, that triage owns the close, and that the PR references the issue with `Refs #N` - rather than `Closes #N`. A criterion that survives the merge only if - someone remembers to reopen the issue is an incomplete criterion — #137's - amended body is the worked example, reopened by hand after `Closes #137` - closed it with the criterion unmet (#151). The merge moves the issue to - `post-merge` and releases the claim. The sweep writes the transition - comment when it derives the move; when triage or the operator moves it by - hand, triage writes the comment in the same tick. In either case triage - follows up with the remaining criteria, their owner, and the wake condition - for completion. + rather than `Closes #N`; relying on somebody to reopen the issue is an + incomplete criterion (#151). The merge moves the issue to `post-merge` and + releases the claim. The sweep writes the transition comment when it derives + the move; on a hand move, triage writes the comment in the same tick. In + either case triage follows up with the remaining criteria, their owner, and + the wake condition for completion. - **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. Name a cross-repo dependency the same way with its @@ -126,9 +109,9 @@ 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 checklist of child issues. Children reference the epic; -that checklist is the progress view. For every epic, put it under a heading +label) with the approach, decisions, constraints, and a dependency-ordered +child checklist. Children reference the epic; that checklist is the progress +view. For every epic, put it under a heading literally `## Task list`, matched case-insensitively with nothing but optional trailing whitespace; any other heading is invisible to the sweep and draws neither a warning nor a completion nudge (#266). Builders never pick the epic @@ -154,13 +137,8 @@ Repositories that adopt version epics follow [RELEASES.md](RELEASES.md). them. Every label on every open issue stays true; the board is only worth scanning if it does not lie. - **A lifted hold makes its body prose stale in the same instant, and the - body is yours.** The "stays true" bar above extends past the labels to - the prose that describes them: when a hold lifts, correcting the body - header that described it is your move in the same tick — not the - builder's, and not left for the next reader to diff. On - [#149](https://github.com/heavy-duty/ceremony/issues/149) the lift - arrived by label alone and the body said held for the next five and a - half minutes; two builders read that window to opposite conclusions. + body is yours.** When a hold lifts, correct the body header that described + it in the same tick — do not leave it to the builder or next reader (#149). ## What you never do diff --git a/changelog.d/282.md b/changelog.d/282.md new file mode 100644 index 0000000..90d412d --- /dev/null +++ b/changelog.d/282.md @@ -0,0 +1,5 @@ +### Changed + +- TRIAGE.md now states its rules with bare record cites: the label-race and + lifted-hold incident narratives leave the normative text while their + operational rules remain complete (#282). From f3125631b97f7539406b7e95fefddc55f8f2634d Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:51:16 +0000 Subject: [PATCH 124/162] docs: keep answer outcome explicit --- TRIAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TRIAGE.md b/TRIAGE.md index 58ae791..8159379 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -19,7 +19,7 @@ one **without asking anything**. One accountable role keeps builders from guessi ## 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 tracked. Reply with the answer and evidence; mark answered. + already shipped or tracked. Link the code, doc, or 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 From abe31a6d4ac163a472d9df43ff62e93c8ada6d91 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:56:55 +0000 Subject: [PATCH 125/162] test(labels): assert each guard row alone, not bundled with its sibling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The D3 guard loop passed the action path and its test path to derives in one call. derive_labels emits scope:guards when either matches, so any one of the six rows could be deleted with the case still green — three assertions standing in for six rows. Split into one assertion per path. Co-Authored-By: Claude Opus 5 (1M context) --- test/labels-scope.test.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/labels-scope.test.sh b/test/labels-scope.test.sh index cbedf99..5e1c437 100644 --- a/test/labels-scope.test.sh +++ b/test/labels-scope.test.sh @@ -148,10 +148,16 @@ EOF check "derive: RELEASES.md is scope:docs" 0 "[scope:docs]" derives 'RELEASES.md' check "derive: TRIAGE.md is scope:docs" 0 "[scope:docs]" derives 'TRIAGE.md' - # D3: three guard actions and their tests were in no block at all + # D3: three guard actions and their tests were in no block at all. Each of + # the six paths is asserted ALONE, never bundled with its sibling: a set + # holding both the action and its test derives scope:guards when either row + # matches, so one row could be deleted with the case still green — the six + # rows have to be six assertions to be six protections (#300 round). for guard in changelog-assembled docs-sync runner-isolated; do check "derive: actions/$guard is scope:guards" 0 "[scope:guards]" \ - derives "$(files "actions/$guard/$guard.sh" "test/$guard.test.sh")" + derives "actions/$guard/$guard.sh" + check "derive: $guard's test is scope:guards" 0 "[scope:guards]" \ + derives "test/$guard.test.sh" done # D4: lib/ is genuinely mixed, so the shared files wear both labels rather From 48547d5eb1557f528f726c80adb899db833c04a4 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Tue, 4 Aug 2026 16:16:19 +0000 Subject: [PATCH 126/162] fix: the issue-side ruling clock reads comments only (#284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claiming a needs-ruling issue dated it through the assigned timeline event, silencing the 7-day escalation nudge at exactly the moment a builder started working through it. The ruling block now reads last_issue_comment_activity (D1); the reclaim clock keeps the assignment (D2) because there the assignment IS the claim; post-merge hands its evidence read to the ruling block instead of reading again (D6, D7); the claimed branch reads both clocks at its top, before anything it posts. LABELS.md and lib/ruling.sh now say what each surface's clock reads (D4, D5). The #257-era order compositions move to the comments read — the timeline is no longer an input the issue clocks take, and its unreadability no longer holds unrelated writes hostage; that narrowing is pinned rather than implied. Co-Authored-By: Claude Fable 5 --- LABELS.md | 7 +- .../issueflow-reconcile.sh | 75 +++--- changelog.d/284.md | 6 + lib/ruling.sh | 7 +- test/issueflow-reconcile.test.sh | 215 ++++++++++++++++-- 5 files changed, 263 insertions(+), 47 deletions(-) create mode 100644 changelog.d/284.md diff --git a/LABELS.md b/LABELS.md index 406455c..61ffcfc 100644 --- a/LABELS.md +++ b/LABELS.md @@ -154,8 +154,11 @@ label never removed — and a ruling with no real activity for 7 days draws a comment-only nudge addressed to the decider, linking the escalation. The nudge carries no marker on purpose: the comment is itself activity, so it resets its own window and never repeats within a quiet week. Label churn is -not activity — the clock reads comments, reviews and commits, or the sweep -would reset itself. +never activity, or the sweep would reset itself — and each surface's clock +reads what exists on it: on a pull request, comments, reviews and commits; +on an issue, comments alone. An assignment is the claim clock's fact, not +the ruling's — claiming a flagged issue does not answer it, and buys the +escalation no quiet (#284). `offsite` is issue-only and records that a claimed issue's deliverable lives in another repository, where a closing reference cannot make a local open PR diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index f6c6db1..9081297 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -515,19 +515,26 @@ issue_activity_at() { # $1 issue, $2 created_at, $3 with-assignment|comments-onl } last_issue_activity() { # $1 issue, $2 created_at → epoch; non-zero if a read failed - # The claim clock, and the ruling clock with it. Assignment is the claim + # The claim clock, and only the claim clock (#284). Assignment is the claim # itself. Ignoring it would let an old issue be reclaimed in the seconds - # between assignment and its required draft PR. + # between assignment and its required draft PR. The ruling clock no longer + # rides here: an assignment says nothing about whether the decider + # answered, and counting it let claiming a flagged issue buy its + # escalation another 7 quiet days. issue_activity_at "$1" "$2" with-assignment } last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on a failed read - # The evidence nudge's clock (#254). Same computation, one input fewer, and - # the input it drops is the one that would starve the criterion: on + # The evidence nudge's clock (#254), and the issue-side ruling clock with + # it (#284): on an issue, a comment is the only substantive activity + # toward a ruling. Same computation as the claim clock, one input fewer, + # and the input it drops is the one that would starve each criterion: on # `post-merge` there is no claim for an assignment to protect, and an # assignee there is the invalid composition the `post-merge-assigned` flag - # reports. Counting it would let a broken board buy the item another 7 days - # of silence — the failure direction of #254 taken backwards. + # reports; under `needs-ruling` the assignment is the *claim's* fact, and + # counting it silenced the escalation at exactly the moment somebody + # started working through it. Either way, a wider clock would let board + # state buy the wait another 7 days of silence. # # A comment the sweep itself wrote is still activity here, deliberately: # the nudge carries no marker, so its own comment is what rate-limits it, @@ -538,7 +545,7 @@ last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on } reconcile_issue() { - local n="$1" decision refs cross_refs states age evidence_age created assignees open_pr=false label owners + local n="$1" decision refs cross_refs states age evidence_age ruling_age created assignees open_pr=false label owners local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" local unchecked="" remove_claimed=claimed local attention_active=true attention_suppression="" @@ -557,6 +564,19 @@ reconcile_issue() { if has_issue_label claimed; then assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")" grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true + # Under a pending ruling, the ruling clock is read at the top of the + # branch, before anything either arm below can post — the derived + # transition comment, the reclaim notice and the claimed-unassigned flag + # are all comments, and a read taken after one would date the issue by + # this sweep's own writing (#284; the hazard #274 met from the other + # side). Two clocks on purpose: `age` below counts the assignment + # because the assignment IS the claim; the ruling waits on a human, and + # an assignment says nothing about whether the decider answered. + if has_issue_label needs-ruling; then + created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" + guarded_read ruling_age last_issue_comment_activity "$n" "$created" \ + || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" + fi merged_ref_pr="$(post_merge_pr_for_issue "$n")" if [ -n "$merged_ref_pr" ]; then transition_marker="$(post_merge_transition_marker "$merged_ref_pr")" @@ -636,28 +656,18 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in # The evidence nudge's clock is read BEFORE any comment this branch # posts. `ensure_comment` below is itself activity, so reading after it # would let the assigned-flag comment silence the nudge for another 7 - # days — the same self-silencing the ruling nudge avoids by reading its - # facts once, at the top of the pass. - # - # Its own variable, not `age`: the ruling block below reuses `age` when - # it is already set, and the evidence clock is deliberately narrower than - # the ruling clock. Leaking it there would silently change what a ruling - # nudge means depending on which queue label the issue sits under. + # days — the same self-silencing the ruling nudge avoids by taking its + # clock from this same read, below. created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" guarded_read evidence_age last_issue_comment_activity "$n" "$created" \ || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" - # The ruling clock is read HERE, not in the ruling block, for the same - # reason the evidence clock is: that block reads only when `age` is - # unset, and by the time it runs this branch may have posted the - # evidence nudge — so its read would date the issue by this sweep's own - # comment and silence the ruling nudge. Both waits are answered from - # facts that predate anything this pass writes. The cost is one extra - # comments read on `post-merge` + `needs-ruling`, and only there: an - # ordinary `post-merge` issue reads once. - if has_issue_label needs-ruling; then - guarded_read age last_issue_activity "$n" "$created" \ - || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" - fi + # On this surface the ruling clock IS this read (#284 D6): both nudges + # wait on comments and nothing else, so the evidence clock is handed to + # the ruling block rather than read again — and handed HERE, before the + # assigned-flag comment and the evidence nudge below, so neither wait is + # ever answered by anything this pass writes. `post-merge` + + # `needs-ruling` now costs one comments read where it cost three. + ruling_age="$evidence_age" if [ "$assignees" -gt 0 ] || has_issue_label attention; then ensure_comment "$n" post-merge-assigned \ 'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.' @@ -796,12 +806,19 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null log "#$n: unstale (a ruling is pending)" fi - if [ -z "${age:-}" ]; then + # The ruling clock reads comments only (#284 D1): an `assigned` event is + # the claim clock's fact, and counting it here let claiming a flagged + # issue buy its escalation another 7 quiet days. The reclaim clock + # (`age`) must never reach this call — the branches that write comments + # before this block (`claimed`, `post-merge`) arrive holding + # `ruling_age` already, read before anything they post; the fresh read + # serves the paths that arrive empty-handed. + if [ -z "${ruling_age:-}" ]; then created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" - guarded_read age last_issue_activity "$n" "$created" \ + guarded_read ruling_age last_issue_comment_activity "$n" "$created" \ || skip_issue "$n" "could not read its activity history: $(read_failure_reason "$READ_FAILURE_STDERR")" fi - reconcile_ruling "$n" "$age" "$NOW" + reconcile_ruling "$n" "$ruling_age" "$NOW" fi } diff --git a/changelog.d/284.md b/changelog.d/284.md new file mode 100644 index 0000000..ad98894 --- /dev/null +++ b/changelog.d/284.md @@ -0,0 +1,6 @@ +### Fixed + +- Claiming a `needs-ruling` issue no longer buys its escalation another 7 + quiet days: the issue-side ruling clock reads comments alone — an + assignment is the claim clock's fact — and LABELS.md now names what each + surface's clock reads (#284). diff --git a/lib/ruling.sh b/lib/ruling.sh index 9ac6b0b..626f4f8 100644 --- a/lib/ruling.sh +++ b/lib/ruling.sh @@ -175,8 +175,11 @@ ruling_default_decision() { # escalation body on stdin → DEADLINE | HARDB } ruling_nudge_decision() { # $1 now, $2 last real-activity epoch → NUDGE | KEEP - # Real activity only — comments, reviews, commits, never label churn, or - # the sweep would reset its own clock. The nudge needs NO marker: the + # Real activity only, as the caller's surface defines it: the PR sweep + # supplies comments, reviews and commits; the issue sweeps supply comments + # alone — an `assigned` event is the claim clock's fact, and counting it + # let a claim silence a pending ruling (#284). Never label churn, or the + # sweep would reset its own clock. The nudge needs NO marker: the # nudge comment is itself activity, so posting it resets this window and # the rule self-rate-limits to at most one nudge per 7 quiet days. That is # deliberate — a later refactor that "fixes" it by adding a marker breaks diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index fc0ed67..37f3682 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -900,6 +900,167 @@ check "...it is reused from lib/ruling.sh" 0 "" \ check "the evidence-nudge probes perform no issue edits" 0 "$nudge_edits_before" \ bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" +# -- the issue-side ruling clock is comments-only (#284) --------------------- +# #52 D10's "reuse the activity computation" made the issue-side ruling nudge +# ride the claim-reclamation clock, `assigned` events included — so claiming +# a flagged issue dated it, and the escalation the flag exists to keep +# visible went quiet for another 7 days at exactly the moment somebody +# started working through it. The ruling clock is now the comments-only one +# (D1); the reclaim clock keeps the assignment (D2), because there the +# assignment IS the claim. Every must-nudge probe below was run against the +# pre-#284 sweep and went red — the #274 round-1 discipline: the fixture +# proves the defect, not merely the fix. +ruling_clock_edits_before="$(wc -l <"$TMP/issue-edits")" +ruling_quiet() { # $1 issue — conforming escalation, both rungs fired, ~9d of comment quiet + jq -n --arg l "$(iso_at $((INOW - 10 * 86400)))" \ + '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' \ + >"$(tfix "$1")" + jq -n --arg at "$(iso_at $((INOW - 10 * 86400 - 60)))" \ + --arg b $'Options: A — x B — y\nRecommend: A, because x.\nBlocked: z\nDefault: none — hard block' \ + --arg r12 "$(iso_at $((INOW - 10 * 86400 + 13 * 3600)))" \ + --arg r24 "$(iso_at $((INOW - 10 * 86400 + 25 * 3600)))" \ + '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc","body":$b}, + {"user":{"login":"sweep-bot"},"created_at":$r12,"html_url":"https://x/r12","body":"\nrung"}, + {"user":{"login":"sweep-bot"},"created_at":$r24,"html_url":"https://x/r24","body":"\nrung"}]' \ + >"$(cfix "$1")" +} +timeline_add() { # $1 issue, $2 event, $3 seconds ago + jq --arg e "$2" --arg at "$(iso_at $((INOW - $3)))" \ + '. + [{"event":$e,"created_at":$at}]' \ + "$(tfix "$1")" >"$(tfix "$1").tmp" && mv "$(tfix "$1").tmp" "$(tfix "$1")" +} +comment_add() { # $1 issue, $2 seconds ago + jq --arg at "$(iso_at $((INOW - $2)))" \ + '. + [{"user":{"login":"decider"},"created_at":$at,"html_url":"https://x/d","body":"still thinking"}]' \ + "$(cfix "$1")" >"$(cfix "$1").tmp" && mv "$(cfix "$1").tmp" "$(cfix "$1")" +} + +# The live shape, and the defect: a builder claims the flagged issue, the +# assignment is an hour old, the decider has been silent ~9 days. +ruling_quiet 101 +timeline_add 101 assigned 3600 +claimed_fresh="$(issue_probe 101 $'claimed\nneeds-ruling' 1 true)" +check "an hour-old claim does not silence a ruling 9 days quiet" 0 "" \ + grep -q 'ruling nudge' <<<"$claimed_fresh" +check "...and the fresh assignment is not reclaim bait either" 1 "" \ + grep -q 'reclaimed' <<<"$claimed_fresh" + +# The clock rule alone, no assignee in the way: an assigned/unassigned pair +# in the timeline is the claim's history, not activity toward the ruling. +ruling_quiet 102 +timeline_add 102 assigned 3600 +timeline_add 102 unassigned 3500 +ready_pair="$(issue_probe 102 $'ready\nneeds-ruling' 0)" +check "a ready issue nudges through an hour-old assignment pair" 0 "" \ + grep -q 'ruling nudge' <<<"$ready_pair" + +# post-merge + needs-ruling fires BOTH nudges in one sweep, from one read +# taken before either write. This probe is also the read-order pin: the +# evidence nudge posts first and the stub stamps it as fresh activity, so +# restoring a ruling-clock read below `ensure_comment` turns the second +# check red — the hazard #274 met and killed inside one round. +ruling_quiet 103 +timeline_add 103 assigned 3600 +timeline_add 103 unassigned 3500 +both_fresh="$(issue_probe 103 $'post-merge\nneeds-ruling' 0)" +check "a fresh assignment starves neither post-merge wait" 0 "" \ + grep -q 'post-merge evidence nudge' <<<"$both_fresh" +check "...the ruling nudge fires beside it, not behind it" 0 "" \ + grep -q 'ruling nudge' <<<"$both_fresh" + +# blocked composes the same way. The #252 parse echo is pre-seeded old so +# the probe isolates the clock rule — steady state, where the echo for this +# parse set already exists and the branch posts nothing before the tail. +ruling_quiet 104 +timeline_add 104 assigned 3600 +refs_104="$(blocked_references <<<'Blocked by #999.')" +cross_104="$(blocked_cross_references <<<'Blocked by #999.')" +marker_104="$(blocked_parse_marker "$(blocked_parse_set "$refs_104" "$cross_104")")" +jq --arg m "$marker_104" --arg at "$(iso_at $((INOW - 9 * 86400)))" \ + '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/echo","body":("\necho")}]' \ + "$(cfix 104)" >"$(cfix 104).tmp" && mv "$(cfix 104).tmp" "$(cfix 104)" +blocked_fresh="$(issue_probe 104 $'blocked\nneeds-ruling' 0 false "" 'Blocked by #999.')" +check "a blocked issue nudges through an hour-old assignment" 0 "" \ + grep -q 'ruling nudge' <<<"$blocked_fresh" + +# 6 days of comment quiet is 6, with or without an assignment inside it. +ruling_quiet 105 +timeline_add 105 assigned 3600 +comment_add 105 $((6 * 86400)) +six_days="$(issue_probe 105 $'claimed\nneeds-ruling' 1 true)" +check "6 days of comment quiet draws no nudge" 1 "" \ + grep -q 'ruling nudge' <<<"$six_days" + +# Label churn is not activity on this clock either — it reads no timeline +# at all, which closes the class rather than the spelling. +ruling_quiet 106 +timeline_add 106 labeled 1800 +timeline_add 106 unlabeled 1700 +churn="$(issue_probe 106 $'ready\nneeds-ruling' 0)" +check "hour-old label churn does not hold the nudge back" 0 "" \ + grep -q 'ruling nudge' <<<"$churn" + +# Self-rate-limiting, asserted as the property (#254's discipline): sweep +# again a day after probe 101's nudge and the nudge it posted is the +# activity that keeps it silent — no marker involved. +day_after="$(PROBE_NOW=$((INOW + 86400)) issue_probe 101 $'claimed\nneeds-ruling' 1 true)" +check "the sweep a day after its nudge holds its silence" 1 "" \ + grep -q 'ruling nudge' <<<"$day_after" + +# No flag, no nudge, whatever the clock says. +quiet_comment 107 $((60 * 86400)) +noflag="$(issue_probe 107 ready 0)" +check "an unflagged issue draws no ruling nudge at any age" 1 "" \ + grep -q 'ruling nudge' <<<"$noflag" + +# D2's input doing its job — the one thing a "the clocks are the same now, +# merge them" refactor would break. Red the instant `last_issue_activity` +# loses `assigned`. +printf '[]\n' >"$(cfix 108)" +jq -n --arg at "$(iso_at $((INOW - 600)))" \ + '[{"event":"assigned","created_at":$at}]' >"$(tfix 108)" +not_reclaimed="$(issue_probe 108 claimed 1 false)" +check "a 10-minute-old claim on a silent issue is not reclaimed" 1 "" \ + grep -q 'reclaimed' <<<"$not_reclaimed" + +# One fixture, two clocks, asserted directly and not by inspection: the +# newest event is the assignment; the reclaim clock returns it and the +# ruling clock returns the older comment. +jq -n --arg at "$(iso_at $((INOW - 8 * 86400)))" \ + '[{"user":{"login":"decider"},"created_at":$at,"html_url":"https://x/c9","body":"x"}]' >"$(cfix 109)" +jq -n --arg at "$(iso_at $((INOW - 3600)))" \ + '[{"event":"assigned","created_at":$at}]' >"$(tfix 109)" +clock_read() { # $1 clock fn — both against fixture 109 + ( REPO=owner/repo + # shellcheck disable=SC2317 # reached indirectly, through the clock under test + gh() { issue_stub_gh "$@"; } + "$1" 109 "$(iso_at $((INOW - 10 * 86400)))" ) +} +check "one fixture, two clocks: the reclaim clock returns the assignment" \ + 0 "$((INOW - 3600))" clock_read last_issue_activity +check "...and the ruling clock returns the older comment" \ + 0 "$((INOW - 8 * 86400))" clock_read last_issue_comment_activity + +# The mechanical call-site pins: the reclaim clock can never reach +# reconcile_ruling, on any path, asserted against the source and not the +# diff. Beside them, the one-spelling pins this issue inherits stay green. +# shellcheck disable=SC2016 # the call sites are asserted as literals +check "reconcile_ruling is never handed the reclaim clock" 1 "" \ + grep -E '^[^#]*reconcile_ruling.*\$age' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # the call site is asserted as a literal +check "...its one call site is fed the comments-only clock" 0 "1" \ + grep -cF 'reconcile_ruling "$n" "$ruling_age" "$NOW"' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" +# shellcheck disable=SC2016 # the guarded_read is asserted as a literal +check "...and ruling_age is never fed by the reclaim clock" 1 "" \ + grep -E 'ruling_age.*last_issue_activity ' \ + "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" + +# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell +check "the #284 probes perform no issue edits" 0 "$ruling_clock_edits_before" \ + bash -c 'wc -l <"$1"' _ "$TMP/issue-edits" + # -- non-triggers stay byte-for-byte outside the transition ------------------ recent_timeline() { jq -n --arg at "$(iso_at $((INOW - 60)))" \ @@ -1800,52 +1961,78 @@ check "...and crew#329's label is not written on the way out" 1 "" \ check "...and nothing claims it was" 1 "" \ grep -qF '#81: needs-triage (no queue state)' <<<"$broken_mint" -# -- 3. the blockers->ready flip, then a failed TIMELINE read ---------------- -# The wider class: the failing read is the second one inside -# last_issue_activity, so the comments read answers and the marker check and -# the flip both complete first. A comment AND a label edit are staged. +# -- 3. the blockers->ready flip, then a failed COMMENTS read ---------------- +# The read that guards this branch is the marker check ahead of the parse +# echo: a broken comments endpoint skips there, before the echo or the flip +# is staged. The timeline is no longer an input on this path at all (#284 +# D1 — the ruling clock reads comments alone), so the unreadable-timeline +# case moved from "skips everything" to its own pin below. printf '%s\n' '{"number":82,"state":"closed"}' \ >"$ORDER/repos_owner_repo_issues_82.json" order_fixture 83 '[{"name":"blocked"},{"name":"needs-ruling"}]' 'Blocked by #82.' order_board '[{"number":83}]' +order_heals 83 comments order_heals 83 timeline healthy_flip="$(order_run)" check "the control: a healthy pass really does flip cleared blockers to ready" 0 \ "issueflow: #83: blockers closed -> ready" printf '%s\n' "$healthy_flip" check "...writing the label edit" 0 "" order_wrote 83 edit check "...and posting the blockers-cleared comment" 0 "" order_wrote 83 comment -order_breaks 83 timeline +order_breaks 83 comments broken_flip="$(order_run)" -check "a failed timeline read skips the blockers->ready composition" 0 \ - "issueflow: #83: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ +check "a failed comments read skips the blockers->ready composition" 0 \ + "issueflow: #83: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ printf '%s\n' "$broken_flip" check "...leaving the issue blocked" 1 "" order_wrote 83 edit check "...with no comment posted about it" 1 "" order_wrote 83 comment check "...and nothing claiming the flip happened" 1 "" \ grep -qF 'blockers closed -> ready' <<<"$broken_flip" +# The read this path no longer takes cannot skip it (#284): with comments +# healthy and the timeline broken, the flip commits, and only the ruling +# ladder's own soft-failing read goes without — no verdict is invented, and +# no unrelated write is held hostage by an input the clocks stopped reading. +order_heals 83 comments +order_breaks 83 timeline +narrowed_flip="$(order_run)" +check "a failed timeline read no longer skips the flip" 1 "" \ + grep -qF 'skipped this pass' <<<"$narrowed_flip" +check "...the flip commits" 0 "" order_wrote 83 edit +check "...and the ruling ladder says what it could not read" 0 \ + "issueflow: #83: ruling timeline unreadable — no verdict invented this pass" \ + printf '%s\n' "$narrowed_flip" -# -- 4. a posted nudge, then a failed TIMELINE read ------------------------- -# The comment-only half of the class: an epic nudge is staged, and the -# ruling tail's activity read fails after it. A comment is as much a -# mutation as a label — it is the thing markers exist to make idempotent. +# -- 4. a posted nudge, then a failed COMMENTS read ------------------------- +# The comment-only half of the class: the epic nudge's own marker check is +# the read that fails, so the nudge is never staged and the skip reports the +# truth. A comment is as much a mutation as a label — it is the thing +# markers exist to make idempotent. order_fixture 84 '[{"name":"epic"},{"name":"needs-ruling"}]' \ '## Task list - [x] #82' order_board '[{"number":84}]' +order_heals 84 comments order_heals 84 timeline healthy_nudge="$(order_run)" check "the control: a healthy pass really does nudge a completed epic" 0 \ "issueflow: #84: completed epic nudged" printf '%s\n' "$healthy_nudge" check "...by posting a comment" 0 "" order_wrote 84 comment -order_breaks 84 timeline +order_breaks 84 comments broken_nudge="$(order_run)" -check "a failed timeline read skips the epic-nudge composition" 0 \ - "issueflow: #84: skipped this pass — could not read its activity history: $GH_STUB_STDERR" \ +check "a failed comments read skips the epic-nudge composition" 0 \ + "issueflow: #84: skipped this pass — could not read its comments: $GH_STUB_STDERR" \ printf '%s\n' "$broken_nudge" check "...and the nudge comment is never posted" 1 "" order_wrote 84 comment check "...and nothing claims it was" 1 "" \ grep -qF 'completed epic nudged' <<<"$broken_nudge" +# The narrowed surface again (#284): a broken timeline neither skips nor +# suppresses the nudge; the ruling ladder alone goes without a verdict. +order_heals 84 comments +order_breaks 84 timeline +narrowed_nudge="$(order_run)" +check "a failed timeline read no longer skips the epic nudge" 1 "" \ + grep -qF 'skipped this pass' <<<"$narrowed_nudge" +check "...the nudge commits" 0 "" order_wrote 84 comment # -- the skip is still just a skip: counted, tailed, and green (D4, D6, D7) -- check "a mutation-bearing composition that skips is still not a crash" 1 "" \ From 404c4f6099695e259a4104a98552403a023b0ec9 Mon Sep 17 00:00:00 2001 From: Daniel Marin Date: Tue, 4 Aug 2026 17:39:45 +0100 Subject: [PATCH 127/162] Update labels.conf --- .github/labels.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/labels.conf b/.github/labels.conf index 3a779e4..e8e179f 100644 --- a/.github/labels.conf +++ b/.github/labels.conf @@ -1,4 +1,4 @@ -panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl +panel=claude-bot-andresmgsl codex-bot-andresmgsl kimi-bot-andresmgsl triage-actors=dan-claude-bot scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded From 66a0eb5d65ba98f68da161a0b77afacf6064598b Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:04:09 +0000 Subject: [PATCH 128/162] test(labels-reconcile): fixture roster replaces the live panel by slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The state-machine fixtures bound BOT1/BOT2/BOT3 to REQUIRED_BOTS by index off the shipped .github/labels.conf, so three fixtures silently required a four-member panel=. Shrinking it to three left the third slot unbound and set -u aborted the file before assertion 1: 217 assertions became 0, on main and on every branch cut from it. The fixtures now write their own conf, in test/labels.test.sh's shape, at all three load sites (top of file, the #205 re-drafted-round block, and the mutant_blockers subshell). One live-file case survives as a property — the shipped conf parses and recuses each member from its own panel — with no index and no expected size, and a copy whose panel= names nobody proves it still has teeth. Refs #304 --- changelog.d/304.md | 11 +++++ test/labels-reconcile.test.sh | 76 ++++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 changelog.d/304.md diff --git a/changelog.d/304.md b/changelog.d/304.md new file mode 100644 index 0000000..0b57d16 --- /dev/null +++ b/changelog.d/304.md @@ -0,0 +1,11 @@ +### Fixed + +- A roster edit no longer reds the whole suite: the labels-reconcile + state-machine fixtures name their own panel instead of binding + `.github/labels.conf` by slot (#304). +- Shrinking `panel=` to three had left that binding's third slot unbound, and + `set -u` aborted the file before its first assertion — 217 assertions + became 0, on `main` and on every branch cut from it (#304). +- The one case still reading the shipped roster asserts a property, not a + size: it parses, and each member is recused from its own panel. Any + `panel=` of one or more members leaves `test/run.sh` green (#304). diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index a9a0ac7..a394a27 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -13,13 +13,29 @@ export LC_ALL=C cd "$(dirname "$0")/.." # shellcheck source=actions/labels-reconcile/labels-reconcile.sh . actions/labels-reconcile/labels-reconcile.sh -load_config .github/labels.conf -set_required_bots codex-bot-andresmgsl +RTMP="$(mktemp -d)" +trap 'rm -rf "$RTMP"' EXIT + +# The fixture roster is the test's own, and deliberately not the shipped one +# (#304). The state machine is roster-agnostic — it needs three distinct +# required logins, not THESE three — so binding the fixtures to +# .github/labels.conf by slot bought nothing and cost the file: when the +# operator shrank panel= from four members to three, the recused author left +# two, the third slot came up unbound, and set -u aborted this file before +# its first assertion. 217 assertions became 0, on main and on every branch cut +# from it, and no fixture here was about the panel's size. The shape below is +# test/labels.test.sh's, which has always written its own conf. +FIXTURE_CONF="$RTMP/fixture-labels.conf" +FIXTURE_AUTHOR=fixture-builder # 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="${REQUIRED_BOTS[0]}" BOT2="${REQUIRED_BOTS[1]}" BOT3="${REQUIRED_BOTS[2]}" +BOT1=fixture-bot-one BOT2=fixture-bot-two BOT3=fixture-bot-three +printf 'panel=%s %s %s %s\n' "$BOT1" "$BOT2" "$BOT3" "$FIXTURE_AUTHOR" \ + >"$FIXTURE_CONF" +load_config "$FIXTURE_CONF" +set_required_bots "$FIXTURE_AUTHOR" pass=0 fail=0 expect() { # $1 = description, $2 = want, $3 = got @@ -696,8 +712,6 @@ expect "...and an already-applied stale comes off" \ # posted comments appended back into the fixture so a second sweep sees the # first one's writes, and every label edit recorded. # --------------------------------------------------------------------------- -RTMP="$(mktemp -d)" -trap 'rm -rf "$RTMP"' EXIT iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } RNOW=2000000000 @@ -1139,8 +1153,8 @@ done # a PR carrying a standing CHANGES_REQUESTED that its builder converted back # to draft read state:building — and the staleness sweep read a dropped fix # round as a build in progress. -load_config .github/labels.conf -set_required_bots codex-bot-andresmgsl +load_config "$FIXTURE_CONF" +set_required_bots "$FIXTURE_AUTHOR" MERGEABLE=MERGEABLE CHECKS=SUCCESS LABELS="" HEAD_SHA=head1 DRAFT=true REQUESTED="" REVIEWS_JSON="$(reviews \ "$(rev "$BOT1" CHANGES_REQUESTED head1 no t1)" \ @@ -1305,10 +1319,10 @@ mutant_blockers() { # $1 = sed program → blockers() from a copy of the script RECONCILE_UNREQUESTED_GRACE="$RECONCILE_UNREQUESTED_GRACE" \ bash -u -c ' . "$1" - load_config .github/labels.conf - set_required_bots codex-bot-andresmgsl + load_config "$2" + set_required_bots "$3" blockers - ' bash "$mutated" + ' bash "$mutated" "$FIXTURE_CONF" "$FIXTURE_AUTHOR" } # the harness itself, unmutated: it must reproduce the verdict the sourced # functions give, or a "flip" below proves nothing about the guard @@ -1370,5 +1384,47 @@ expect "without the row the same approvals leave the round incomplete" \ expect "...and the owed, unasked verdict is named" \ blocker:unrequested "$(blockers)" +# -- the shipped roster, as a property rather than a slot (#304 D2) ---------- +# The one case that reads the real .github/labels.conf, and it asserts only +# what that file can honestly prove here: it parses, and recusal removes the +# author from whatever it names. No index, no expected size — the panel is the +# operator's to resize (D3), and the fixtures above no longer care. What this +# does catch is a shipped conf that stopped parsing, which must never be +# reported as a green suite. +# +# The probe runs in a subshell so a refusal cannot leave this file's globals +# half-loaded, and it quantifies over every member rather than sampling one: +# there is no member whose recusal is special. load_config's stderr is dropped +# because the exit status is the assertion; the broken-conf case below would +# otherwise print its (correct) complaint into a passing run. +live_panel_probe() { # $1 = conf → PARSE: [RECUSED: SHRANK:] + bash -u -c ' + . actions/labels-reconcile/labels-reconcile.sh + rc=0 + load_config "$1" 2>/dev/null || rc=$? + printf "PARSE:%s" "$rc" + [ "$rc" -eq 0 ] || { printf "\n"; exit 0; } + recused=yes shrank=yes + for author in "${BOTS[@]}"; do + set_required_bots "$author" + for bot in ${REQUIRED_BOTS[@]+"${REQUIRED_BOTS[@]}"}; do + [ "$bot" != "$author" ] || recused=no + done + [ "${#REQUIRED_BOTS[@]}" -eq "$((${#BOTS[@]} - 1))" ] || shrank=no + done + printf " RECUSED:%s SHRANK:%s\n" "$recused" "$shrank" + ' bash "$1" +} +expect "the shipped labels.conf parses, and recuses each member from its own panel" \ + "PARSE:0 RECUSED:yes SHRANK:yes" "$(live_panel_probe .github/labels.conf)" +# ...and the teeth: the same probe on a copy whose panel= line names nobody. +# A roster edit that empties the line is the shape this catches — the file +# still looks like a conf, and every panel in the repo would resolve to +# nothing. +BROKEN_CONF="$RTMP/broken-labels.conf" +sed 's/^panel=.*/panel=/' .github/labels.conf >"$BROKEN_CONF" +expect "...and a malformed panel= line in that same file is refused, not passed" \ + PARSE:1 "$(live_panel_probe "$BROKEN_CONF")" + printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] From 830a643a4b0e8ecb5c43d577f4a39b3948da0d4f Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:12:48 +0000 Subject: [PATCH 129/162] docs: make standing release windows explicit graphs Add the mint-time membership call, the sink/source/subset invariants, and the release note while the contradictory successor-count criterion awaits triage clarification.\n\nCloses #292. --- RELEASES.md | 7 +++++++ TRIAGE.md | 7 +++++++ changelog.d/292.md | 3 +++ 3 files changed, 17 insertions(+) create mode 100644 changelog.d/292.md diff --git a/RELEASES.md b/RELEASES.md index d3d6816..7d30248 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -74,6 +74,13 @@ interleaving unrelated windows blurs both the release story and the evidence behind it. Gates open windows; they do not silently admit members, so builders still see one deliberately ordered queue. +While a window stands — an open release-labeled issue with a non-empty +enumerated gate — its members form a DAG whose sink is the release issue. +Every member reaches that sink; ordering edges live on members, while the sink +records membership only; and the `ready` set is exactly the graph's sources. +It follows that every `ready` issue is a gate member. `epic` and `post-merge` +issues are exempt because neither is claimable (#292). + The operator may declare a parallel track at init when its footprint is disjoint from the primary window: another repository, another artifact, or provably non-overlapping clusters. The declaration names the boundary and any diff --git a/TRIAGE.md b/TRIAGE.md index 8159379..848e941 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -98,6 +98,13 @@ Every issue you mint carries, in this order: edge with `Blocked by #N`, naming the newest open carrier; there is no alternative for disjoint regions. This keeps every `ready` issue concurrently claimable and makes each close release one successor (#288). + During a standing release window, every mint also gets a binary membership + call in the same tick. A non-member names the release issue as its blocker. + A member is placed by naming its member predecessors on the new issue, + adding or re-pointing every downstream member's dependency to the new issue + (inserting X into A → B makes A → X → B, never a fan), and adding the new + issue to the release issue's gate; collision and window edges are + independent, so write both when both apply (#292). - **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)). diff --git a/changelog.d/292.md b/changelog.d/292.md new file mode 100644 index 0000000..0fbafcb --- /dev/null +++ b/changelog.d/292.md @@ -0,0 +1,3 @@ +### Changed + +- Standing release windows are dependency DAGs: every mint is placed in the window or behind it, and only current sources are `ready` (#292). From 630fd116c2abfbe51e4539bc50358a52c9d278bf Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:23:03 +0000 Subject: [PATCH 130/162] docs: distinguish window fan-out from collision chains --- RELEASES.md | 13 +++++++++---- TRIAGE.md | 12 +++++++----- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index 7d30248..930463c 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -76,10 +76,15 @@ still see one deliberately ordered queue. While a window stands — an open release-labeled issue with a non-empty enumerated gate — its members form a DAG whose sink is the release issue. -Every member reaches that sink; ordering edges live on members, while the sink -records membership only; and the `ready` set is exactly the graph's sources. -It follows that every `ready` issue is a gate member. `epic` and `post-merge` -issues are exempt because neither is claimable (#292). +Every member reaches that sink. Members declare only their immediate +predecessors; ordering edges live on members, while the sink records membership +only; and the `ready` set is exactly the graph's current sources. Every close +releases exactly its declared successors, and that whole set is concurrently +claimable: a member may have multiple successors, while the collision rule +already orders any that share a deliverable. Insertion re-points downstream +edges rather than merely appending membership at the sink. It follows that +every `ready` issue is a gate member. `epic` and `post-merge` issues are exempt +because neither is claimable (#292). The operator may declare a parallel track at init when its footprint is disjoint from the primary window: another repository, another artifact, or diff --git a/TRIAGE.md b/TRIAGE.md index 848e941..f6c0644 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -99,11 +99,13 @@ Every issue you mint carries, in this order: alternative for disjoint regions. This keeps every `ready` issue concurrently claimable and makes each close release one successor (#288). During a standing release window, every mint also gets a binary membership - call in the same tick. A non-member names the release issue as its blocker. - A member is placed by naming its member predecessors on the new issue, - adding or re-pointing every downstream member's dependency to the new issue - (inserting X into A → B makes A → X → B, never a fan), and adding the new - issue to the release issue's gate; collision and window edges are + call in the same tick. A non-member names the release issue as its blocker + in its own Dependencies. A member is placed with three writes: the new issue + names its immediate member predecessors; every downstream member adds or + re-points its dependency to the new issue, dropping any predecessor the new + issue now reaches (inserting X into A → B makes A → X → B, so B drops A); + and the release issue adds the new issue to its gate, recording membership + only. A member may have multiple successors. Collision and window edges are independent, so write both when both apply (#292). - **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)). From f5bac7c7eea2fc916a105b7c0d436b6825dbd423 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Tue, 4 Aug 2026 17:24:50 +0000 Subject: [PATCH 131/162] fix: the labeler map learns lib/attention.sh and the surfaces it never knew (#302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/attention.sh had #267 D4's premise exactly — both reconcilers source it, nothing release-side does — and [scope:release-flow] alone was a wrong answer of the class that decision exists to correct. The sweep workflow pair joins beside its trigger pair (detached in #209), the shared-lib tests take scope:labels alone (a test inherits no lib/** glob), and D4's seven enumerated rows land one each. No catch-all, by decision: both directories span all four scopes. Each of the 13 new map rows is protected by its own assertion — deleted alone, each reds exactly its case. Co-Authored-By: Claude Fable 5 --- .github/labeler.yml | 32 +++++++++++++++++++++++- changelog.d/302.md | 6 +++++ test/labels-scope.test.sh | 51 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 changelog.d/302.md diff --git a/.github/labeler.yml b/.github/labeler.yml index 6ec4baf..c7a60bc 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -19,6 +19,20 @@ # unmapped guard actions, RELEASES.md, README (which this tree spells # README.md, so the old glob could match nothing) — are the same read of the # same file, gaps rather than wrong answers. +# +# #302 is the same read once more, from #300's review: lib/attention.sh had +# #267 D4's premise exactly (both reconcilers source it, nothing release-side +# does) and was not in the rows — a wrong answer, not a gap. The sweep +# workflow pair joins beside its trigger pair: the sweeps detached in #209 +# and took the reconcile jobs and the cron with them. Two asymmetries are +# deliberate, not drift: the TESTS of the shared lib/ files take +# scope:labels alone, because lib/ruling.sh and lib/read.sh wear +# scope:release-flow only through the lib/** glob being kept whole and a +# test file inherits no such glob; and there is still no test/** or +# .github/scripts/** catch-all, because both directories span all four +# scopes — a catch-all is the changelog.d/** defect again, 100% recall and +# no locating power. The enumeration is the price of a test locating its +# subject. scope:release-flow: - changed-files: - any-glob-to-any-file: @@ -37,6 +51,9 @@ scope:release-flow: - test/version.test.sh - test/changelog.test.sh - test/self-ref.test.sh + - test/changelog-assemble.test.sh + - .github/scripts/release-path.sh + - test/release-path.test.sh scope:guards: - changed-files: - any-glob-to-any-file: @@ -55,25 +72,38 @@ scope:guards: - test/drill-recorded.test.sh - test/refs-not-closing.test.sh - test/runner-isolated.test.sh + - .github/scripts/marker-check.sh + - test/marker-check.test.sh + - .github/scripts/vendored-check.sh + - test/vendored.test.sh scope:labels: - changed-files: - any-glob-to-any-file: - .github/workflows/labels.yml - .github/workflows/self-labels.yml + - .github/workflows/labels-sweep.yml + - .github/workflows/self-labels-sweep.yml - .github/labeler.yml - .github/labels.conf - actions/issueflow-reconcile/** - actions/labels-reconcile/** - actions/labels-scope/** # shared by both reconcilers; lib/** keeps scope:release-flow too, - # and a mixed file honestly wears both labels (#267 D4) + # and a mixed file honestly wears both labels (#267 D4, #302 D1) - lib/read.sh - lib/ruling.sh + - lib/attention.sh - LABELS.md - test/issueflow-reconcile.test.sh - test/labels.test.sh - test/labels-reconcile.test.sh - test/labels-scope.test.sh + # tests of the shared lib/ files: scope:labels ALONE — a test + # inherits no lib/** glob, so its row is the one scope its subject + # actually locates (#302 D3) + - test/attention.test.sh + - test/ruling.test.sh + - test/labels-triggers.test.sh scope:docs: - changed-files: - any-glob-to-any-file: diff --git a/changelog.d/302.md b/changelog.d/302.md new file mode 100644 index 0000000..c0a759a --- /dev/null +++ b/changelog.d/302.md @@ -0,0 +1,6 @@ +### Fixed + +- `lib/attention.sh` locates as label machinery beside its two shelf-mates — + `[scope:release-flow]` alone was a wrong answer of the class #267 measured + — and the map learns the sweep workflow pair, the shared-lib tests, and + seven enumerated test/guard surfaces (#302). diff --git a/test/labels-scope.test.sh b/test/labels-scope.test.sh index 5e1c437..650d556 100644 --- a/test/labels-scope.test.sh +++ b/test/labels-scope.test.sh @@ -175,6 +175,57 @@ EOF check "derive: an unmapped path is silence, not an error" 0 "[]" \ derives "$(files FLEET.md .github/workflows/ci.yml)" + # --- #302: one wrong answer and the surfaces the map never learned ------ + # Every path asserted ALONE, per #300 round 1: a set holding a script and + # its test derives the scope when either row matches, so bundling would + # let a row be deleted with the case still green. + + # D1, the reported bug replayed: both reconcilers source lib/attention.sh, + # nothing release-side does — [scope:release-flow] alone was a wrong + # answer, and the honest set is both, same as its two shelf-mates + check "derive: lib/attention.sh is release-flow AND labels" 0 \ + "[scope:release-flow,scope:labels]" derives 'lib/attention.sh' + + # D2: the sweep half of the automation, detached from the trigger half in + # #209 — cadence, permissions and job wiring must locate + check "derive: the labels sweep workflow is scope:labels" 0 \ + "[scope:labels]" derives '.github/workflows/labels-sweep.yml' + check "derive: the self sweep workflow is scope:labels" 0 \ + "[scope:labels]" derives '.github/workflows/self-labels-sweep.yml' + + # D3, the deliberate asymmetry with D1: a test file inherits no lib/** + # glob, so its row is the one scope its subject actually locates + check "derive: attention's test is scope:labels alone" 0 \ + "[scope:labels]" derives 'test/attention.test.sh' + check "derive: ruling's test is scope:labels alone" 0 \ + "[scope:labels]" derives 'test/ruling.test.sh' + + # D4: the same read's remaining gaps, one row each + check "derive: the trigger-surface pins are scope:labels" 0 \ + "[scope:labels]" derives 'test/labels-triggers.test.sh' + check "derive: the assemble test is scope:release-flow" 0 \ + "[scope:release-flow]" derives 'test/changelog-assemble.test.sh' + check "derive: the release-path manifest is scope:release-flow" 0 \ + "[scope:release-flow]" derives '.github/scripts/release-path.sh' + check "derive: the release-path test is scope:release-flow" 0 \ + "[scope:release-flow]" derives 'test/release-path.test.sh' + check "derive: the marker-check guard is scope:guards" 0 \ + "[scope:guards]" derives '.github/scripts/marker-check.sh' + check "derive: the marker-check test is scope:guards" 0 \ + "[scope:guards]" derives 'test/marker-check.test.sh' + check "derive: the vendored-check guard is scope:guards" 0 \ + "[scope:guards]" derives '.github/scripts/vendored-check.sh' + check "derive: the vendored test is scope:guards" 0 \ + "[scope:guards]" derives 'test/vendored.test.sh' + + # D7: no test/** or .github/scripts/** catch-all — both directories span + # all four scopes, so this pair reds under any catch-all row: each file + # would gain the other's scope beside its own + check "derive: test/version.test.sh is release-flow alone" 0 \ + "[scope:release-flow]" derives 'test/version.test.sh' + check "derive: this test file is scope:labels alone" 0 \ + "[scope:labels]" derives 'test/labels-scope.test.sh' + # refusals: unsupported shapes fail loudly, naming the label cat >"$TMP/allglobs.yml" <<'EOF' scope:x: From d81b04148e312c22bb28b92365c95d3a8cceabe0 Mon Sep 17 00:00:00 2001 From: Andriujose <43181885+andriujoseba@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:39:46 +0000 Subject: [PATCH 132/162] docs: scope window insertion to immediate successors --- TRIAGE.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/TRIAGE.md b/TRIAGE.md index f6c0644..e05ccb4 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -101,12 +101,14 @@ Every issue you mint carries, in this order: During a standing release window, every mint also gets a binary membership call in the same tick. A non-member names the release issue as its blocker in its own Dependencies. A member is placed with three writes: the new issue - names its immediate member predecessors; every downstream member adds or - re-points its dependency to the new issue, dropping any predecessor the new - issue now reaches (inserting X into A → B makes A → X → B, so B drops A); - and the release issue adds the new issue to its gate, recording membership - only. A member may have multiple successors. Collision and window edges are - independent, so write both when both apply (#292). + names its immediate member predecessors; every member whose immediate + predecessor the new issue becomes adds or re-points its dependency to the + new issue, dropping any predecessor the new issue now reaches (inserting X + into A → B makes A → X → B, so B drops A); a member that must land after the + new issue but already reaches it through another member declares nothing + new; and the release issue adds the new issue to its gate, recording + membership only. Collision and window edges are independent, so write both + when both apply (#292). - **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)). From 90a35008a1118503157d0584b32139511e68c398 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:06:03 +0000 Subject: [PATCH 133/162] wip: the collision and window board flags, decisions and gather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both checks ride the existing sweep walk and write nothing but comments (#293 D1). The board read now answers the whole payload, because both decisions are over the WHOLE board — every open issue's labels and title, and every open release issue's body — and a second pagination for the same rows would be a second board free to disagree with this one mid-sweep. Fixtures still owed. --- .../issueflow-reconcile.sh | 283 +++++++++++++++++- 1 file changed, 274 insertions(+), 9 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 9081297..94d2e64 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -346,10 +346,18 @@ blocked_parse_marker() { # $1 rendered set -> the echo's idempotency marker # pair and leave the class: `-`, `_` and `.` are legal in a qualifier token # and all collapse the same way. The slug stays in front so a human reading # the raw comment can still see which set it belongs to; it decides nothing. + state_marker blockers-parsed "$1" +} + +state_marker() { # $1 = marker family, $2 = the state's rendered value + # The one spelling of a value-keyed marker. Three flags now key on a state + # that changes rather than on "have I ever said this" — the blocked-parse + # echo (#252) and the two board flags (#293) — and a second implementation + # of the slug-plus-digest rule is the drift a shared helper prevents. local slug digest - slug="$(printf '%s' "$1" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" - digest="$(printf '%s' "$1" | sha256sum | cut -c1-12)" - printf 'blockers-parsed-%s-%s\n' "${slug:-none}" "$digest" + slug="$(printf '%s' "$2" | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//')" + digest="$(printf '%s' "$2" | sha256sum | cut -c1-12)" + printf '%s-%s-%s\n' "$1" "${slug:-none}" "$digest" } blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 quiet @@ -367,13 +375,20 @@ blocked_parse_echo_needed() { # $1 issue, $2 this parse's marker → 0 echo, 1 q # Comparing markers rather than re-rendering the last set keeps the digest as # the only identity: two sets are the same here iff blocked_parse_marker says # so, the same rule the marker itself is built on. + state_echo_needed "$1" blockers-parsed "$2" +} + +state_echo_needed() { # $1 issue, $2 family, $3 this state's marker → 0 echo, 1 quiet + # The value-keyed dedup itself, family-scoped so each flag compares against + # its OWN last word and never against another flag's (#293 D4 asks for the + # declaration echo's mechanism exactly, and three families now share it). local bodies last guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ || skip_issue "$1" "could not read its comments: $(read_failure_reason "$READ_FAILURE_STDERR")" # The read fails closed above (#247 D1): an unreadable history skips the # issue rather than answering "nothing echoed yet" and re-posting. - last="$(grep -o '' <<<"$bodies" | tail -n 1)" - [ "$last" != "" ] + last="$(grep -o "" <<<"$bodies" | tail -n 1)" + [ "$last" != "" ] } blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs @@ -402,6 +417,138 @@ epic_decision() { # $1 refs, $2 states fi } +# ---- the two board flags (#293): collision (#288) and window (#292) -------- +# +# Both are ADVISORY and comment-only (#293 D1). The sweep never guesses +# intent, so neither writes a label, changes a state, or invents one: it +# states the board fact and triage resolves it. Both are also the first +# checks here whose input is the WHOLE board rather than one issue, so the +# facts are gathered once in main() and every decision below is pure over +# those records — one record per open issue, `numberlabelstitle`. +# +# Why they exist as guards at all: #288 and #292 are triage prose, and both +# failed silently on the same morning (2026-08-04) — #284 minted `ready` +# into a claimed file's function, and six `ready` non-members raced an +# emptying gate. #262 measured the pattern: the same class of rule, once in +# a guard, produced zero misses. + +DELIVERABLE_PATH_PREFIXES=(actions/ lib/ bin/ .github/) + +deliverable_key() { # $1 = one title segment -> its normalized key, or nothing + # Normalized, because the 2026-08-04 miss spelled one deliverable two ways + # — `actions/issueflow-reconcile` against plain `issueflow-reconcile` — so + # exact-prefix matching would have missed the pair it was written for + # (#293 D2). One leading path segment comes off, then every extension: + # `issueflow-reconcile.test.sh` and `issueflow-reconcile.sh` are the same + # spelling habit one more time. + local key="$1" prefix + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + for prefix in "${DELIVERABLE_PATH_PREFIXES[@]}"; do + [ "${key#"$prefix"}" = "$key" ] || { key="${key#"$prefix"}"; break; } + done + while [[ "$key" =~ \.[[:alnum:]]+$ ]]; do key="${key%.*}"; done + # Case folds because the key is a spelling, not an identifier, and folding + # only ever widens the match — the same direction of error the blocked + # parse takes, and the cheap one: a false pair costs a comment a human + # dismisses, a missed pair costs two builders one deliverable. + printf '%s\n' "$key" | tr '[:upper:]' '[:lower:]' +} + +deliverable_keys() { # title on stdin -> its deliverable keys, one per line + local title prefix segment key + IFS= read -r title + # The issue contract forces every title to name its deliverable before the + # em dash, so the key exists on every well-formed title by construction + # (#288 D5). A title without one names no deliverable, and inventing a key + # out of prose is the guessing this sweep never does — the malformed title + # is triage's own contract to enforce, not this flag's to infer around. + prefix="${title%%—*}" + [ "$prefix" != "$title" ] || return 0 + # A multi-file deliverable joins its files with `+` and collides on any + # segment: `TRIAGE.md + RELEASES.md` carries both keys. + local segments=() + IFS='+' read -r -a segments <<<"$prefix" + for segment in "${segments[@]}"; do + key="$(deliverable_key "$segment")" + [ -z "$key" ] || printf '%s\n' "$key" + done +} + +collision_in_scope() { # $1 = comma-joined labels -> 0 in the collision set + # `blocked` is out: a chained issue is the GOAL state of #288's rule, and + # flagging it would report the fix as the defect. `epic` and `post-merge` + # are out by #288 D6 — neither is picked by a builder — and they carry no + # queue label to admit them here anyway. + case ",$1," in *,blocked,*) return 1 ;; esac + case ",$1," in *,ready,*|*,claimed,*) return 0 ;; esac + return 1 +} + +window_in_scope() { # $1 = comma-joined labels -> 0 subject to the window rule + # `epic` and `post-merge` are outside the claimable set and exempt by name + # (#292 D1); `blocked` is already placed behind something and is what the + # non-member leg of the mint-time call writes. + case ",$1," in *,blocked,*|*,epic,*|*,post-merge,*) return 1 ;; esac + return 0 +} + +collision_key_index() { # board records on stdin -> "keynumber" in scope + local n labels title key + while IFS=$'\t' read -r n labels title; do + [ -n "$n" ] || continue + collision_in_scope "$labels" || continue + while IFS= read -r key; do + [ -z "$key" ] || printf '%s\t%s\n' "$key" "$n" + done < <(deliverable_keys <<<"$title") + done +} + +collision_flags() { # key index on stdin -> "numberkey=carrier[,key=carrier]" + # A CHAIN, not a fan (#288 D3): within one key, each issue names the newest + # open carrier below it, so the declaration the flag asks for releases + # exactly one successor per close. Three issues on one deliverable draw two + # comments — #257 naming #253, #284 naming #257 — never three pairs, which + # is the fan the rule exists to forbid. + # + # One line per issue, its keys folded into one state: an issue carrying two + # colliding deliverables has ONE offending state and owes one comment (D4), + # the same shape the blocked-parse echo takes with its set. + sort -t $'\t' -k1,1 -k2,2n \ + | awk -F '\t' ' + $1 == key { print $2 "\t" $1 "=" carrier } + { key = $1; carrier = $2 } + ' \ + | sort -t $'\t' -k1,1n -k2,2 \ + | awk -F '\t' ' + $1 != n { if (n != "") print n "\t" state; n = $1; state = $2; next } + { state = state "," $2 } + END { if (n != "") print n "\t" state } + ' +} + +window_flags() { # $1 gate members, $2 window carriers; records on stdin -> numbers + local n labels title gate="$1" carriers="$2" + [ -n "$carriers" ] || return 0 + while IFS=$'\t' read -r n labels title; do + [ -n "$n" ] || continue + window_in_scope "$labels" || continue + grep -qxF "$n" <<<"$gate" && continue + # The release issue is the graph's SINK, never one of its own members + # (#292 D2), so it can never be its own non-member. + grep -qxF "$n" <<<"$carriers" && continue + printf '%s\n' "$n" + done +} + +window_state() { # $1 = window carriers -> the rendered state, "#249" | "#249, #250" + awk 'NF { printf "%s#%s", (shown++ ? ", " : ""), $1 } END { printf "\n" }' <<<"$1" +} + +flag_for_issue() { # $1 = issue, $2 = flag records "numberstate" + awk -F '\t' -v n="$1" '$1 == n { print $2 }' <<<"$2" +} + offsite_cross_referenced_prs() { # timeline JSON on stdin -> owner/repo#N jq -r ' .[] @@ -544,6 +691,80 @@ last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on issue_activity_at "$1" "$2" comments-only } +reconcile_board_flags() { # $1 = issue — the collision and window flags (#293) + # Dedup is the declaration echo's, per family (#293 D4): the marker is + # keyed to the offending state's VALUE and compared against this family's + # last word on the thread, so a state that changes speaks and a state that + # stands is silent. What that buys over ensure_comment's any-occurrence + # grep is the A -> B -> A case — an issue that collides with #257, is + # re-declared against #284, and collides with #257 again is saying + # something new each time, and an any-occurrence marker would go quiet on + # the third. What it does not buy is the state that resolves and returns + # unchanged: nothing is posted at the resolution, so the thread's last word + # is still the state itself and the return is silent. That is the echo's + # own boundary, and it is the right one here — the flag speaks about a + # board fact that is true right now, and a board where the fact never + # changed has nothing new to say. + local n="$1" state marker rendered + state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")" + if [ -n "$state" ]; then + marker="$(state_marker collision "$state")" + if state_echo_needed "$n" collision "$marker"; then + rendered="$(tr ',' '\n' <<<"$state" \ + | awk -F= '{ print "- `" $1 "` — also carried by #" $2 }')" + run gh issue comment "$n" -R "$REPO" --body " +This issue and the issue named beside each key below are both open and +unblocked, and their titles name the same deliverable: + +$rendered + +That owes a **collision edge**, and #288 makes it unconditional: a deliverable +already carried by an open \`ready\`, \`claimed\` or \`blocked\` issue owes +\`Blocked by #N\` on the newer issue, naming the newest open carrier, so each +close releases exactly one successor. Disjoint regions do not waive it — +\`ready\` must mean claimable concurrently with every other \`ready\` issue, +and an undeclared collision sends two builders at one deliverable. + +The key is the title's em-dash prefix, normalized: one leading \`actions/\`, +\`lib/\`, \`bin/\` or \`.github/\` segment comes off, then every extension, and +a \`+\`-joined title matches on any segment. That is what the machine read, +never a judgment about what the deliverable is — if two spellings normalized +to one deliverable that is really two, say so and no edge is owed. + +*Comment only: nothing on this path writes a label or changes a state. The +marker carries the collision itself, so an unchanged one never re-posts.*" >/dev/null + log "#$n: collision flag — $state" + fi + fi + + state="$(flag_for_issue "$n" "${WINDOW_FLAGS:-}")" + if [ -n "$state" ]; then + marker="$(state_marker window-nonmember "$state")" + if state_echo_needed "$n" window-nonmember "$marker"; then + run gh issue comment "$n" -R "$REPO" --body " +A release window is standing ($state) and this issue is neither one of its +gate members nor an \`epic\` or \`post-merge\` issue. + +#292's invariant: during a standing window — an open \`release\`-labeled issue +with a non-empty gate — the \`ready\` set is a subset of the gate, \`epic\` and +\`post-merge\` exempt. Every mint during a window is a membership call, binary, +made at mint time: **behind the gate**, this issue's own Dependencies declare +the release issue as a blocker and the sweep releases it when the release +closes; or **into the graph**, three writes in one tick — this issue declares +its immediate predecessors, every member whose immediate predecessor it +becomes re-points to it, and the release issue gains \`Blocked by #N\`, which +records membership and nothing else. Silence is not a state. + +The gate is read from the release issue's own \`Blocked by\` declarations — the +same parse every \`blocked\` issue is gated on, echoed on that issue. + +*Comment only: nothing on this path writes a label or changes a state. The +marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null + log "#$n: window flag — a ready non-member under $state" + fi + fi +} + reconcile_issue() { local n="$1" decision refs cross_refs states age evidence_age ruling_age created assignees open_pr=false label owners local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" @@ -794,6 +1015,13 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step reconcile_attention "$n" issue "$assignees" "$attention_suppression" fi + # ---- the two board flags (#293), on any queue state ---- + # After the queue branches for the same reason the ruling block is: both + # compose with every queue state, and FLAG_CONFLICT's early return still + # short-circuits them, because a board lying about its queue state is + # repaired before anything is derived from it. + reconcile_board_flags "$n" + # ---- the ruling invariants (#52), on any queue state ---- # The flag composes with the queue labels (#50 D8), so this runs after the # queue branches rather than inside one of them. The FLAG_CONFLICT return @@ -935,19 +1163,56 @@ main() { done < <(refs_references <<<"$body") done)" - local n tail_line issue_numbers + local n tail_line issue_numbers board_json release_bodies rn rbody gate + local window_rendered="" SKIPPED_COUNT=0 SKIPPED_ISSUES="" # A command substitution in a for list suppresses errexit. Capture and # check the board read before entering the loop, or a 504 (including one # after partial pagination) reports a full pass over a truncated board # (#257). - if ! guarded_read issue_numbers gh api --paginate \ - "repos/$REPO/issues?state=open&per_page=100" \ - --jq '.[] | select(has("pull_request") | not) | .number'; then + # + # The read answers the whole payload rather than a projection of it because + # the two board flags (#293) are decided over the WHOLE board — every open + # issue's labels and title, and every open `release` issue's body. One read + # supplies all of it; a second pagination for the same rows would be a + # second board, free to disagree with this one mid-sweep. + if ! guarded_read board_json gh api --paginate \ + "repos/$REPO/issues?state=open&per_page=100"; then log "could not read the issue board: $(read_failure_reason "$READ_FAILURE_STDERR")" return 1 fi + BOARD_RECORDS="$(jq -r '.[] | select(has("pull_request") | not) + | [(.number | tostring), ((.labels // []) | map(.name) | join(",")), (.title // "")] + | @tsv' \ + <<<"$board_json")" + issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")" + # A standing window is an open `release`-labeled issue whose gate still + # holds an OPEN member (#292 D1). The board read IS the open set, so + # membership decides openness with no extra call — and an all-closed gate + # is exactly the emptied gate the release's own `blocked` -> `ready` + # promotion answers, which is why a `ready` release leaves the flag + # dormant rather than flagging the whole board. + release_bodies="$(jq -r '.[] | select(has("pull_request") | not) + | select((.labels // []) | map(.name) | index("release")) + | [(.number | tostring), ((.body // "") | gsub("[\t\r\n]"; " "))] | @tsv' \ + <<<"$board_json")" + WINDOW_CARRIERS="" + WINDOW_GATE="" + if [ -n "$issue_numbers" ]; then + while IFS=$'\t' read -r rn rbody; do + [ -n "$rn" ] || continue + gate="$(blocked_references <<<"$rbody")" + [ -n "$gate" ] || continue + grep -qxF -f <(printf '%s\n' "$issue_numbers") <<<"$gate" || continue + WINDOW_CARRIERS="${WINDOW_CARRIERS}${rn}"$'\n' + WINDOW_GATE="${WINDOW_GATE}${gate}"$'\n' + done <<<"$release_bodies" + fi + [ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")" + COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)" + WINDOW_FLAGS="$(window_flags "$WINDOW_GATE" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \ + | awk -v state="$window_rendered" 'NF { print $1 "\t" state }')" if [ -z "$issue_numbers" ]; then log "no open issues." else From bdcc211a0b5f795a2a023e2eef3d356f69eb3d6f Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:15:00 +0000 Subject: [PATCH 134/162] the sweep flags what the window and collision rules forbid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two advisory flags on the issue-flow sweep, the mechanical backstop for #288's collision rule and #292's window rule. Both are prose today, and both failed silently on the same morning: #284 was minted `ready` into a file another issue held claimed with a PR in flight, and six `ready` non-members raced an emptying gate. #262 measured the pattern — the same class of rule, once in a guard, produced zero misses. Comments only (D1): no label write, no state change, no new label. The sweep never guesses intent; it states the board fact and triage resolves. The collision key is the title's em-dash prefix NORMALIZED, because the 2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile` against bare `issueflow-reconcile` — so exact-prefix matching would have missed the pair it was written for. One leading path segment comes off, then every extension; a `+`-joined title matches on any segment. The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue names the newest open carrier below it, so the declaration it asks for releases exactly one successor per close. A standing window is a release issue whose gate still holds an OPEN member. The board read IS the open set, so membership decides openness with no extra call, and an all-closed gate is the emptied gate the release's own blocked -> ready promotion answers — which is why a `ready` release leaves the flag dormant instead of flagging the whole board. Dedup is the declaration echo's, extracted into state_marker / state_echo_needed and scoped per family (D4): the marker is keyed to the offending state's value and compared against that family's last word on the thread, so a state that changes always speaks. Fixtures replay the 2026-08-04 morning board whole and the post-ruling board beside it: the first draws exactly four collision flags and six window flags and writes not one label; the second draws none. Closes #293. --- changelog.d/293.md | 13 ++ test/issueflow-reconcile.test.sh | 291 +++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 changelog.d/293.md diff --git a/changelog.d/293.md b/changelog.d/293.md new file mode 100644 index 0000000..2ef2ebb --- /dev/null +++ b/changelog.d/293.md @@ -0,0 +1,13 @@ +### Added + +- The issue-flow sweep now flags a collision the board never declared: two + open, unblocked issues whose titles name one deliverable draw a comment + naming the newer's owed `Blocked by` edge. Keys normalize, so + `actions/x` and `x` are one deliverable (#288). +- The sweep now flags a `ready` non-member during a standing release window, + naming the window's invariant. The gate is read from the release issue's + own `Blocked by` declarations, and an emptied gate leaves it dormant + (#292). +- Both flags are advisory: comments only, no label write and no state + change, deduped against each family's last word on the thread so a + standing state re-sweeps silently (#293). diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 37f3682..9ea6878 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2044,6 +2044,297 @@ order_board '[{"number":83}]' order_run >/dev/null check "...and still leaves the job green (D7)" 0 "" test $? -eq 0 +# -- the two board flags (#293): the deliverable key, normalized ------------ +# The 2026-08-04 miss spelled one deliverable two ways, so exact-prefix +# matching is specified away (D2). These pin the normalization itself. +check "the em-dash prefix is the key" 0 "issueflow-reconcile" \ + deliverable_keys <<<"issueflow-reconcile — the ruling clock counts assigned" +check "a leading actions/ segment comes off" 0 "issueflow-reconcile" \ + deliverable_keys <<<"actions/issueflow-reconcile — a failed board read" +check "...and so does .github/" 0 "labeler" \ + deliverable_keys <<<".github/labeler.yml — one wrong answer left by D4" +check "...and lib/" 0 "attention" deliverable_keys <<<"lib/attention.sh — the target" +check "...and bin/" 0 "decide" deliverable_keys <<<"bin/decide.sh — the door" +check "every extension comes off, not just the last" 0 "issueflow-reconcile" \ + deliverable_keys <<<"issueflow-reconcile.test.sh — the pre-read is unpinned" +check "the key folds case" 0 "triage" deliverable_keys <<<"TRIAGE.md — the bullet" +check "a + title carries both segments" 0 $'triage\nreleases' \ + deliverable_keys <<<"TRIAGE.md + RELEASES.md — a standing window is a graph" +# A path segment the rule does not name stays part of the key: the strip list +# is closed on purpose (D2), so `test/issueflow-reconcile.test.sh` is its own +# deliverable and not the action it exercises. +check "an unlisted path segment stays in the key" 0 "test/issueflow-reconcile" \ + deliverable_keys <<<"test/issueflow-reconcile.test.sh — the pre-read" +# No em dash, no key. Inventing one out of prose is the guessing this sweep +# never does; the malformed title is triage's own contract to enforce. +check "a title with no em dash names no deliverable" 0 "" \ + deliverable_keys <<<"a title that names nothing" + +# -- the collision decision: a chain, never a fan (#288 D3) ------------------ +# Sourced helpers, not `bash -c`: a subshell started with -c has none of these +# functions, and a pipeline ending in grep would then answer "no match" from a +# command-not-found and pass a negative case for the wrong reason. +collision_chain() { collision_key_index | collision_flags; } +collision_flags_issue() { collision_chain | grep -q "^$1"; } +window_flags_issue() { # $1 issue, $2 gate, $3 carriers; records on stdin + window_flags "$2" "$3" | grep -qx "$1" +} +collision_board=$'253\tclaimed\tissueflow-reconcile — release-init\n257\tclaimed\tactions/issueflow-reconcile — a failed board read\n284\tready\tissueflow-reconcile — the ruling clock' +check "three issues on one deliverable chain, each naming the newest below it" 0 \ + $'257\tissueflow-reconcile=253\n284\tissueflow-reconcile=257' \ + collision_chain <<<"$collision_board" +check "...so the oldest carrier is never itself flagged" 1 "" \ + collision_flags_issue 253 <<<"$collision_board" +check "a lone carrier draws nothing" 0 "" \ + collision_chain <<<$'284\tready\tissueflow-reconcile — alone' +# `blocked` is the GOAL state of #288's rule; flagging it reports the fix as +# the defect. Both legs of the test plan, on one board. +check "two blocked twins are the declared chain, not a collision" 0 "" \ + collision_chain \ + <<<$'264\tblocked\tTRIAGE.md — one\n266\tblocked\tTRIAGE.md — two' +check "a blocked twin does not carry a ready one's edge either" 0 "" \ + collision_chain \ + <<<$'264\tblocked\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' +check "an epic carrying the key is outside the claimable set (#288 D6)" 0 "" \ + collision_chain \ + <<<$'264\tepic\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' +check "a post-merge carrier is outside it too" 0 "" \ + collision_chain \ + <<<$'264\tpost-merge\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two' +# The #284 shape, stated as its own case (test plan): a `claimed` issue whose +# PR is already in flight is the STRONGEST collision on the board, not a +# weaker one, and the flag reads the queue label rather than the PR link. +check "a claimed carrier with a PR in flight still carries the collision" 0 \ + $'284\tissueflow-reconcile=253' \ + collision_chain \ + <<<$'253\tclaimed,scope:labels\tissueflow-reconcile — release-init\n284\tready\tissueflow-reconcile — the ruling clock' +# One issue, two colliding deliverables: ONE offending state, one comment (D4). +check "a multi-file title folds its collisions into one state" 0 \ + $'295\treleases=292,triage=264' \ + collision_chain \ + <<<$'264\tready\tTRIAGE.md — one\n292\tready\tRELEASES.md — two\n295\tready\tTRIAGE.md + RELEASES.md — three' + +# -- the window decision (#292 D1) ------------------------------------------ +window_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n264\tready\tTRIAGE.md — a non-member\n270\tepic\tsome epic — exempt\n271\tpost-merge\tsome item — exempt\n272\tblocked\tsome issue — already placed' +check "a ready non-member is flagged during a standing window" 0 "264" \ + window_flags "253" "249" <<<"$window_board" +check "...and a gate member is not" 1 "" \ + window_flags_issue 264 $'253\n264' 249 <<<"$window_board" +check "...nor an epic (#292 D1 exempts it by name)" 1 "" \ + window_flags_issue 270 253 249 <<<"$window_board" +check "...nor a post-merge issue" 1 "" \ + window_flags_issue 271 253 249 <<<"$window_board" +check "...nor a blocked issue, which is already placed behind something" 1 "" \ + window_flags_issue 272 253 249 <<<"$window_board" +# The release issue is the graph's SINK (#292 D2), so it can never be its own +# non-member — even when its own labels would otherwise admit it. +check "the window carrier is never flagged as its own non-member" 1 "" \ + window_flags_issue 249 253 249 \ + <<<$'249\tready,release\tRelease 0.6.0 — the board empties' +check "no standing window means no flag at all" 0 "" \ + window_flags "" "" <<<"$window_board" +check "two standing windows render as one state" 0 "#249, #250" window_state $'249\n250\n' + +# -- the 2026-08-04 board, replayed whole (D5) ------------------------------ +# The corpus the operator ruled on. Both flags are decided over the WHOLE +# board, so a sourced decision probe cannot exercise the gather — these run +# the script as a subprocess behind the PATH-stubbed gh, #91's lesson applied +# to a board-wide check. +BOARD="$TMP/board" +mkdir -p "$BOARD" +cp "$ARRIVAL/fixtures/graphql-open.json" "$BOARD/graphql-open.json" +cp "$ARRIVAL/fixtures/graphql-merged.json" "$BOARD/graphql-merged.json" + +board_issue() { # $1 number, $2 labels(csv), $3 title, $4 body, $5 assignee count + local labels_json + labels_json="$(printf '%s' "$2" | tr ',' '\n' \ + | jq -R . | jq -sc 'map(select(. != "") | {name: .})')" + jq -n --argjson n "$1" --argjson labels "$labels_json" --arg t "$3" \ + --arg b "${4:-}" --argjson a "${5:-0}" --arg at "$(iso_at "$INOW")" \ + '{number: $n, state: "open", title: $t, body: $b, labels: $labels, + created_at: $at, user: {login: "triage-one"}, + assignees: (if $a > 0 then [{login: "builder-bot"}] else [] end)}' \ + >"$BOARD/repos_owner_repo_issues_$1.json" +} + +board_assemble() { # numbers… -> the open-issue list, with fresh comment threads + local n + for n in "$@"; do printf '[]\n' >"$BOARD/repos_owner_repo_issues_${n}_comments.json"; done + # shellcheck disable=SC2016 # the filename expansion belongs to the loop below + for n in "$@"; do cat "$BOARD/repos_owner_repo_issues_$n.json"; done \ + | jq -sc . >"$BOARD/repos_owner_repo_issues_state_open_per_page_100.json" +} + +flag_count() { # $1 = collision|window, $2 = a sweep's output + grep -c ": $1 flag — " <<<"$2" +} + +board_run() { + : >"$BOARD/edits" + env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$BOARD" ISSUEFLOW_NOW="$INOW" \ + REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ + bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 +} + +# The morning shape: ten open issues, six of them `ready` non-members, a +# standing gate, and one deliverable carried three times in two spellings. +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253, #257.' +board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 +board_issue 257 claimed 'actions/issueflow-reconcile — a failed board read sweeps an empty board' '' 1 +board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' +board_issue 276 ready 'REVIEWER.md — the green-check precondition' +board_issue 281 ready 'LABELS.md — the attention row' +board_issue 282 ready 'TRIAGE.md — the two comment links come out' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' +board_assemble 249 253 257 264 266 276 281 282 284 +morning_out="$(board_run)" +morning_rc=$? + +check "the morning board replays green" 0 "" test "$morning_rc" -eq 0 +# D5's named pair, and the whole reason the key is normalized: #257 spells the +# deliverable `actions/issueflow-reconcile`, #284 spells it bare. +check "#284 draws the collision flag, naming #257 across the spelling variance" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=257' \ + printf '%s\n' "$morning_out" +check "...and #257 names #253, so the flag asks for a chain and not a fan" 0 \ + 'issueflow: #257: collision flag — issueflow-reconcile=253' \ + printf '%s\n' "$morning_out" +check "...while #253, the oldest carrier, is asked for nothing" 1 "" \ + grep -qF 'issueflow: #253: collision flag' <<<"$morning_out" +check "the TRIAGE.md triple chains the same way" 0 \ + 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$morning_out" +check "...through its tail" 0 'issueflow: #282: collision flag — triage=266' \ + printf '%s\n' "$morning_out" +check "the morning board draws exactly four collision flags" 0 "4" \ + flag_count collision "$morning_out" +# D3's corpus: the six `ready` non-members that raced the emptying gate. +for nonmember in 264 266 276 281 282 284; do + check "#$nonmember is flagged as a ready non-member under #249" 0 \ + "issueflow: #$nonmember: window flag — a ready non-member under #249" \ + printf '%s\n' "$morning_out" +done +check "the morning board draws exactly six window flags" 0 "6" \ + flag_count window "$morning_out" +check "...and never flags a gate member" 1 "" \ + grep -qE 'issueflow: #(253|257): window flag' <<<"$morning_out" +check "...nor the release issue that carries the window" 1 "" \ + grep -qF 'issueflow: #249: window flag' <<<"$morning_out" +# D1: comments only. Not "no unexpected edit" — no edit at all. +check "the whole replay writes no label and no state (D1)" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" +check "...and no new label is ever proposed" 1 "" \ + grep -qE 'add-label (collision|window)' "$BOARD/edits" +check "the collision comment cites the rule it is asking for" 0 "" \ + grep -qF 'collision edge' "$BOARD/edits" +check "...and names #288 as its authority" 0 "" grep -qF '#288 makes it unconditional' "$BOARD/edits" +check "the window comment names #292's invariant" 0 "" \ + grep -qF "#292's invariant" "$BOARD/edits" +# shellcheck disable=SC2016 # backticks are the comment body's own Markdown +check "...and states the subset rule with its exemptions" 0 "" \ + grep -qF 'the `ready` set is a subset of the gate' "$BOARD/edits" +check "both comments carry idempotency markers (D4)" 0 "" \ + grep -qF ' +said already" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +jq -n --arg b " +said already" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_276_comments.json" +resweep_out="$(board_run)" +check "a standing collision is silent on the next sweep (D4)" 1 "" \ + grep -qF 'issueflow: #284: collision flag' <<<"$resweep_out" +check "a standing window non-membership is silent too" 1 "" \ + grep -qF 'issueflow: #276: window flag' <<<"$resweep_out" +check "...while every other flag on the board still speaks" 0 "3" \ + flag_count collision "$resweep_out" +check "...and the window flags with it" 0 "5" \ + flag_count window "$resweep_out" +# The value-keyed marker's whole point: a state that CHANGED speaks, even +# though this family has already had its say on the thread (#252's A -> B -> A). +jq -n --arg b " +an older, different state" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +changed_out="$(board_run)" +check "a changed collision state speaks over this family's last word" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=257' \ + printf '%s\n' "$changed_out" +# And a family only ever silences itself: the blocked-parse echo's marker +# lives on many of these threads and must not read as either flag's. +jq -n --arg b " +a different family entirely" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +foreign_out="$(board_run)" +check "another family's marker never silences the collision flag" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=257' \ + printf '%s\n' "$foreign_out" + +# -- the post-ruling board draws nothing (D5's must-not-flag leg) ----------- +# The same issues after triage placed them: the TRIAGE.md triple chained +# oldest-first, the reconciler chain chained, and every one of them a gate +# member. Every flag above must go quiet, or the flag is reporting the fix. +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253, #257, #264, #266, #276, #281, #282, #284.' +board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 +board_issue 257 blocked 'actions/issueflow-reconcile — a failed board read sweeps an empty board' \ + 'Blocked by #253.' +board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 266 blocked 'TRIAGE.md — the epic task-list heading is literally `## Task list`' \ + 'Blocked by #264.' +board_issue 276 ready 'REVIEWER.md — the green-check precondition' +board_issue 281 ready 'LABELS.md — the attention row' +board_issue 282 blocked 'TRIAGE.md — the two comment links come out' 'Blocked by #266.' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 blocked 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' \ + 'Blocked by #257.' +board_assemble 249 253 257 264 266 276 281 282 284 +ruled_out="$(board_run)" +check "the post-ruling board replays green" 0 "" test $? -eq 0 +check "...and draws no collision flag at all" 1 "" \ + grep -qF ': collision flag' <<<"$ruled_out" +check "...and no window flag either" 1 "" grep -qF ': window flag' <<<"$ruled_out" +check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ + printf '%s\n' "$ruled_out" + +# -- an emptied gate leaves the window flag dormant (test plan) ------------- +# The release stands `ready` because every declared member closed, so no +# member is on the open board. `Blocked by` is still in the body: a +# declaration is not a gate, an OPEN member is. +board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253, #257.' +board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' +board_assemble 249 264 266 +empty_gate_out="$(board_run)" +check "an emptied gate leaves D3 dormant" 1 "" grep -qF ': window flag' <<<"$empty_gate_out" +check "...while the collision flag beside it is unaffected" 0 \ + 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$empty_gate_out" + +# -- the #284 shape end to end: a claimed carrier with its PR in flight ----- +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":285,"body":"","closingIssuesReferences":{"nodes":[{"number":253}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$BOARD/graphql-open.json" +board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' +board_assemble 253 284 +in_flight_out="$(board_run)" +check "a claimed carrier with an open PR still draws the newer issue's flag" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=253' \ + printf '%s\n' "$in_flight_out" +check "...and the live claim is left exactly as it was" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" + # -- the invariant is enforced at the source, not remembered ---------------- # Staging only holds while every mutation goes through run(). A future call # site reaching gh directly would reopen this hole silently, so it is pinned From 5d13573c53605f567cce77b150ae2954f6fa334a Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:21:56 +0000 Subject: [PATCH 135/162] fixtures take the corpus correction and the three ruled cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triage ruled all three questions (18:11Z) and corrected the corpus: at the 10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed` with NO open PR — #285 was not created until 10:49:16Z. The morning board takes that shape, which also makes the six `ready` non-members come out as six, and puts the blocked twin on the board rather than only in a decision probe. D2's parenthetical is struck: unblocked = open and not blocked, both flags, one definition. D3b is corrected in the same direction — a non-member `claimed` WITH an open PR is flagged too, because a non-member holding a builder and a review round is #292's competition realized, not a mild case of it. Nothing in the implementation moves: neither flag ever consulted PR liveness. Three cases added, so the fixtures pin the rulings and not the prose: - both carriers `claimed` with their own PRs open -> still flags. The ninety-three minutes from #285's creation to its merge are exactly when the struck parenthetical went silent on a live collision, which made the flag's firing a property of someone's workflow rather than of the board. - a fifteen-member declaration with every member closed -> D3 dormant, and the release issue never flagged as its own non-member. A gate declaration never empties; the precondition is its OPEN members, read off the board. - flagged -> resolved -> recreated unchanged -> silent, asserted as D4's stated boundary rather than left accidental. Plus today's board — the `blocked` sink, a `claimed` gate member, two `blocked` issues — which draws nothing. Verified live as well as in fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real board draws zero flags of either kind. --- test/issueflow-reconcile.test.sh | 118 +++++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 23 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 9ea6878..032e02d 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2176,18 +2176,23 @@ board_run() { bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 } -# The morning shape: ten open issues, six of them `ready` non-members, a -# standing gate, and one deliverable carried three times in two spellings. +# The morning shape, as the board actually stood at the 10:28:54Z mint: +# #253 `claimed` with no open PR (#285 was not created until 10:49:16Z), +# #257 `ready` since the evening before, #284 minted `ready` into both of +# them — six `ready` non-members against a standing gate, and one deliverable +# carried three times in two spellings. board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ - 'Blocked by #253, #257.' + 'Blocked by #253.' board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 -board_issue 257 claimed 'actions/issueflow-reconcile — a failed board read sweeps an empty board' '' 1 +board_issue 257 ready 'actions/issueflow-reconcile — a failed board read sweeps an empty board' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' board_issue 276 ready 'REVIEWER.md — the green-check precondition' board_issue 281 ready 'LABELS.md — the attention row' -board_issue 282 ready 'TRIAGE.md — the two comment links come out' +# The blocked twin on the same key, on the board rather than in a decision +# probe: it is neither a collision flag nor one of the six. +board_issue 282 blocked 'TRIAGE.md — the two comment links come out' 'Blocked by #266.' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' board_assemble 249 253 257 264 266 276 281 282 284 @@ -2205,22 +2210,24 @@ check "...and #257 names #253, so the flag asks for a chain and not a fan" 0 \ printf '%s\n' "$morning_out" check "...while #253, the oldest carrier, is asked for nothing" 1 "" \ grep -qF 'issueflow: #253: collision flag' <<<"$morning_out" -check "the TRIAGE.md triple chains the same way" 0 \ +check "the TRIAGE.md pair chains the same way" 0 \ 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$morning_out" -check "...through its tail" 0 'issueflow: #282: collision flag — triage=266' \ - printf '%s\n' "$morning_out" -check "the morning board draws exactly four collision flags" 0 "4" \ +check "...while the blocked twin beside them is the goal state, not a flag" 1 "" \ + grep -qF 'issueflow: #282: collision flag' <<<"$morning_out" +check "the morning board draws exactly three collision flags" 0 "3" \ flag_count collision "$morning_out" # D3's corpus: the six `ready` non-members that raced the emptying gate. -for nonmember in 264 266 276 281 282 284; do +for nonmember in 257 264 266 276 281 284; do check "#$nonmember is flagged as a ready non-member under #249" 0 \ "issueflow: #$nonmember: window flag — a ready non-member under #249" \ printf '%s\n' "$morning_out" done check "the morning board draws exactly six window flags" 0 "6" \ flag_count window "$morning_out" -check "...and never flags a gate member" 1 "" \ - grep -qE 'issueflow: #(253|257): window flag' <<<"$morning_out" +check "...and never flags the gate member holding the window open" 1 "" \ + grep -qF 'issueflow: #253: window flag' <<<"$morning_out" +check "...nor the blocked issue already placed behind something" 1 "" \ + grep -qF 'issueflow: #282: window flag' <<<"$morning_out" check "...nor the release issue that carries the window" 1 "" \ grep -qF 'issueflow: #249: window flag' <<<"$morning_out" # D1: comments only. Not "no unexpected edit" — no edit at all. @@ -2254,7 +2261,7 @@ check "a standing collision is silent on the next sweep (D4)" 1 "" \ grep -qF 'issueflow: #284: collision flag' <<<"$resweep_out" check "a standing window non-membership is silent too" 1 "" \ grep -qF 'issueflow: #276: window flag' <<<"$resweep_out" -check "...while every other flag on the board still speaks" 0 "3" \ +check "...while every other flag on the board still speaks" 0 "2" \ flag_count collision "$resweep_out" check "...and the window flags with it" 0 "5" \ flag_count window "$resweep_out" @@ -2306,34 +2313,99 @@ check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ printf '%s\n' "$ruled_out" # -- an emptied gate leaves the window flag dormant (test plan) ------------- -# The release stands `ready` because every declared member closed, so no -# member is on the open board. `Blocked by` is still in the body: a -# declaration is not a gate, an OPEN member is. +# A gate DECLARATION never empties: #249 names fifteen members and still names +# fifteen after all fifteen close. So the precondition is the gate's OPEN +# members, not its parse — read straight off the board, which already is the +# open set. Under the declaration reading the release issue, now `ready`, is +# itself an open unblocked non-`epic` non-member, and D3 would flag the sink +# at the exact moment the window ends. board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \ - 'Blocked by #253, #257.' + 'Blocked by #218, #230, #232, #236, #237, #238, #241, #242, #247, #248, #251, #252, #253, #254, #257.' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' board_assemble 249 264 266 empty_gate_out="$(board_run)" -check "an emptied gate leaves D3 dormant" 1 "" grep -qF ': window flag' <<<"$empty_gate_out" +check "a fifteen-member declaration with every member closed leaves D3 dormant" 1 "" \ + grep -qF ': window flag' <<<"$empty_gate_out" +check "...and the release issue is never flagged as its own non-member" 1 "" \ + grep -qF 'issueflow: #249' <<<"$empty_gate_out" check "...while the collision flag beside it is unaffected" 0 \ 'issueflow: #266: collision flag — triage=264' printf '%s\n' "$empty_gate_out" -# -- the #284 shape end to end: a claimed carrier with its PR in flight ----- +# -- both carriers claimed, both with their own PRs open (test plan) -------- +# The ninety-three minutes from #285's creation to its merge: under D2's +# struck parenthetical the live collision went silent for all of them, so +# whether the flag ever fired depended on where the sweep tick fell relative +# to a builder opening a PR. It fires on the board, and only on the board. printf '%s\n' \ - '{"data":{"repository":{"pullRequests":{"nodes":[{"number":285,"body":"","closingIssuesReferences":{"nodes":[{"number":253}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":285,"body":"","closingIssuesReferences":{"nodes":[{"number":253}]}},{"number":286,"body":"","closingIssuesReferences":{"nodes":[{"number":284}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ >"$BOARD/graphql-open.json" board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown +board_issue 284 claimed 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' '' 1 +board_assemble 253 284 +both_claimed_out="$(board_run)" +check "two claimed carriers, both with PRs in flight, still draw the newer's flag" 0 \ + 'issueflow: #284: collision flag — issueflow-reconcile=253' \ + printf '%s\n' "$both_claimed_out" +check "...and both live claims are left exactly as they were" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" +# The same board with the newer side `ready`, so the queue label is visibly +# the only input the flag has. +# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' board_assemble 253 284 in_flight_out="$(board_run)" -check "a claimed carrier with an open PR still draws the newer issue's flag" 0 \ +check "a claimed carrier with an open PR draws the ready issue's flag too" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=253' \ printf '%s\n' "$in_flight_out" -check "...and the live claim is left exactly as it was" 1 "" \ - grep -qF 'issue edit' "$BOARD/edits" + +# -- flagged, resolved, recreated unchanged: silent, and specified ---------- +# D4's boundary, asserted rather than left accidental. Nothing is posted at +# the resolution — D1 admits no comment there — so the thread's last word is +# still the state itself and an identical return says nothing new. #292 D2b +# owns the recurrence: a board state violating the window invariants is +# triage's to repair in the tick it is seen, and triage has already been told +# about this one. +jq -n --arg b " +flagged once" '[{"user": {"login": "sweep-bot"}, "body": $b}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +board_assemble_keep() { # board_assemble without wiping the seeded threads + local n + for n in "$@"; do cat "$BOARD/repos_owner_repo_issues_$n.json"; done \ + | jq -sc . >"$BOARD/repos_owner_repo_issues_state_open_per_page_100.json" +} +board_assemble_keep 284 +resolved_out="$(board_run)" +check "the collision resolves when its carrier leaves the board" 1 "" \ + grep -qF ': collision flag' <<<"$resolved_out" +check "...and the resolution itself writes nothing at all" 1 "" test -s "$BOARD/edits" +board_assemble_keep 253 284 +recreated_out="$(board_run)" +check "an unchanged state recreated is silent — D4's stated boundary" 1 "" \ + grep -qF 'issueflow: #284: collision flag' <<<"$recreated_out" + +# -- today's board draws nothing (the post-ruling shape, live) -------------- +# #249 the `blocked` sink, this issue `claimed` with no open PR and a gate +# member, #307 and #311 `blocked`. The `claimed` member is the case D3b would +# flag if membership were read wrong, which is why it is here. +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$BOARD/graphql-open.json" +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #293, #307.' +board_issue 293 claimed 'issueflow-reconcile — the sweep flags what the window and collision rules forbid' '' 1 +board_issue 307 blocked 'test/issueflow-reconcile.test.sh — the ruling pre-read is unpinned' \ + 'Blocked by #293.' +board_issue 311 blocked 'docs/CONSUMERS.md — a deliberate non-member' 'Blocked by #249.' +board_assemble 249 293 307 311 +today_out="$(board_run)" +check "today's board draws no collision flag" 1 "" grep -qF ': collision flag' <<<"$today_out" +check "...and no window flag: the claimed member is a member" 1 "" \ + grep -qF ': window flag' <<<"$today_out" +check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ + printf '%s\n' "$today_out" # -- the invariant is enforced at the source, not remembered ---------------- # Staging only holds while every mutation goes through run(). A future call From 919135db12c8964c061e605291702396b9bc2e19 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:54:52 +0000 Subject: [PATCH 136/162] the two flags mean one thing by unblocked, and a key set is a set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: deliverable_keys answered a multiset, so a `+` title whose segments normalize to one key made collision_flags find the issue adjacent to itself and chain it to its own number — the comment asked an issue to declare `Blocked by` itself, and two such carriers corrupted the chain between them. The keys are deduped where the set property belongs. B2: window_in_scope excluded only blocked/epic/post-merge, admitting `needs-triage` and label-less issues, so the sweep could add `needs-triage` to an issue and then tell it about a mint-time membership call in the same pass. Both flags now call one unblocked_claimable predicate — #293 D2 corrected gives one gloss on `unblocked` and D3b says D3 uses it. The window log line says "unblocked", not "ready": D3b corrected exactly that wording, and the flag fires on `claimed` too. --- .../issueflow-reconcile.sh | 60 ++++++++++++++----- test/issueflow-reconcile.test.sh | 4 +- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 94d2e64..41532c2 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -469,28 +469,56 @@ deliverable_keys() { # title on stdin -> its deliverable keys, one per line # segment: `TRIAGE.md + RELEASES.md` carries both keys. local segments=() IFS='+' read -r -a segments <<<"$prefix" - for segment in "${segments[@]}"; do - key="$(deliverable_key "$segment")" - [ -z "$key" ] || printf '%s\n' "$key" - done + # An issue answers a SET of keys, never a multiset. Normalization is + # many-to-one by design — `issueflow-reconcile.sh + issueflow-reconcile.test.sh` + # is one deliverable spelled twice, which is exactly the `+` shape D2 wrote + # the segment rule for — and a repeated key makes `collision_flags`' scan + # find the issue adjacent to itself, chaining it to its own number: the + # comment would ask #402 to declare `Blocked by #402`. It corrupts the chain + # between two such issues too, since each contributes two rows to one key. + # Deduping here rather than in the index keeps the set property with the + # function whose contract it is. + { for segment in "${segments[@]}"; do + key="$(deliverable_key "$segment")" + [ -z "$key" ] || printf '%s\n' "$key" + done + } | awk '!seen[$0]++' } -collision_in_scope() { # $1 = comma-joined labels -> 0 in the collision set +unblocked_claimable() { # $1 = comma-joined labels -> 0 when the issue is unblocked + # THE one definition of `unblocked`, because #293 gives both flags one word + # and one gloss on it: D2 as corrected reads "`unblocked` means open and not + # `blocked` — carrying `ready` or `claimed`, with or without an open PR", + # and D3b's first line says D3 uses D2's corrected `unblocked` and names the + # domain as the claimable set. Two spellings of one spec word is how the + # flags came to disagree about `needs-triage`, so there is one predicate and + # both flags call it. + # # `blocked` is out: a chained issue is the GOAL state of #288's rule, and - # flagging it would report the fix as the defect. `epic` and `post-merge` - # are out by #288 D6 — neither is picked by a builder — and they carry no - # queue label to admit them here anyway. + # flagging it would report the fix as the defect. Anything else without + # `ready` or `claimed` is out because it is not claimable — `needs-triage` + # and a label-less issue are not states a builder can pick up, and an + # unlabeled one is getting `needs-triage` from this very pass. `epic` and + # `post-merge` are out by #288 D6 and #292 D1 alike — neither is picked by a + # builder — and they carry no queue label to admit them here anyway. case ",$1," in *,blocked,*) return 1 ;; esac case ",$1," in *,ready,*|*,claimed,*) return 0 ;; esac return 1 } +collision_in_scope() { # $1 = comma-joined labels -> 0 in the collision set + unblocked_claimable "$1" +} + window_in_scope() { # $1 = comma-joined labels -> 0 subject to the window rule - # `epic` and `post-merge` are outside the claimable set and exempt by name - # (#292 D1); `blocked` is already placed behind something and is what the - # non-member leg of the mint-time call writes. - case ",$1," in *,blocked,*|*,epic,*|*,post-merge,*) return 1 ;; esac - return 0 + # The same `unblocked`, not a second reading of it. Excluding only + # `blocked`/`epic`/`post-merge` here admitted `needs-triage` and a + # label-less issue, which left the sweep adding `needs-triage` to an + # unlabeled issue and then, in the same pass, telling it about a membership + # call made at mint time. Neither is claimable; #292's invariant is stated + # over the claimable set (D3b), and its exemptions say why — `epic` and + # `post-merge` are exempt *because neither is claimable*. + unblocked_claimable "$1" } collision_key_index() { # board records on stdin -> "keynumber" in scope @@ -760,7 +788,11 @@ same parse every \`blocked\` issue is gated on, echoed on that issue. *Comment only: nothing on this path writes a label or changes a state. The marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null - log "#$n: window flag — a ready non-member under $state" + # "unblocked", not "ready": the flag fires on `claimed` too, PR in + # flight or not, which is the one wording #293 D3b went out of its way + # to correct. The log line is read by a human deciding whether the + # sweep understood the board, so it says what the predicate says. + log "#$n: window flag — an unblocked non-member under $state" fi fi } diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 032e02d..be5b6de 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2218,8 +2218,8 @@ check "the morning board draws exactly three collision flags" 0 "3" \ flag_count collision "$morning_out" # D3's corpus: the six `ready` non-members that raced the emptying gate. for nonmember in 257 264 266 276 281 284; do - check "#$nonmember is flagged as a ready non-member under #249" 0 \ - "issueflow: #$nonmember: window flag — a ready non-member under #249" \ + check "#$nonmember is flagged as an unblocked non-member under #249" 0 \ + "issueflow: #$nonmember: window flag — an unblocked non-member under #249" \ printf '%s\n' "$morning_out" done check "the morning board draws exactly six window flags" 0 "6" \ From 4f852ee152f13f7763b973a7adfa013816b73fa1 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:58:21 +0000 Subject: [PATCH 137/162] the normalization fixtures fail for the reasons they are named for check() matches its expectation as a substring, so a bare `issueflow-reconcile` row was satisfied by `issueflow-reconcile.test` too: the multi-extension rule stayed green under a normalization that strips only the last extension. keys_of brackets each key, and the no-em-dash row asserts its emptiness through grep rather than through an expectation check() cannot make. Beside them, the cases the two fixes are named for: a self-folding + title answers its key once and never chains an issue to its own number, and `needs-triage` and a label-less issue are outside both flags. --- test/issueflow-reconcile.test.sh | 94 ++++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 18 deletions(-) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index be5b6de..19c415e 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2047,28 +2047,50 @@ check "...and still leaves the job green (D7)" 0 "" test $? -eq 0 # -- the two board flags (#293): the deliverable key, normalized ------------ # The 2026-08-04 miss spelled one deliverable two ways, so exact-prefix # matching is specified away (D2). These pin the normalization itself. -check "the em-dash prefix is the key" 0 "issueflow-reconcile" \ - deliverable_keys <<<"issueflow-reconcile — the ruling clock counts assigned" -check "a leading actions/ segment comes off" 0 "issueflow-reconcile" \ - deliverable_keys <<<"actions/issueflow-reconcile — a failed board read" -check "...and so does .github/" 0 "labeler" \ - deliverable_keys <<<".github/labeler.yml — one wrong answer left by D4" -check "...and lib/" 0 "attention" deliverable_keys <<<"lib/attention.sh — the target" -check "...and bin/" 0 "decide" deliverable_keys <<<"bin/decide.sh — the door" -check "every extension comes off, not just the last" 0 "issueflow-reconcile" \ - deliverable_keys <<<"issueflow-reconcile.test.sh — the pre-read is unpinned" -check "the key folds case" 0 "triage" deliverable_keys <<<"TRIAGE.md — the bullet" -check "a + title carries both segments" 0 $'triage\nreleases' \ - deliverable_keys <<<"TRIAGE.md + RELEASES.md — a standing window is a graph" +count_lines() { deliverable_keys | grep -c .; } +keys_of() { # title on stdin -> its keys, each bracketed so `check` matches exactly + # ANCHORED, because `check` compares its expectation as a substring: a bare + # `issueflow-reconcile` expectation is satisfied by `issueflow-reconcile.test` + # too, so the multi-extension row below stayed green under a normalization + # stripping only the last extension — it asserted nothing it was named for. + # Bracketing each key makes every row here fail for its own reason. + deliverable_keys | sed 's/.*/[&]/' +} +check "the em-dash prefix is the key" 0 "[issueflow-reconcile]" \ + keys_of <<<"issueflow-reconcile — the ruling clock counts assigned" +check "a leading actions/ segment comes off" 0 "[issueflow-reconcile]" \ + keys_of <<<"actions/issueflow-reconcile — a failed board read" +check "...and so does .github/" 0 "[labeler]" \ + keys_of <<<".github/labeler.yml — one wrong answer left by D4" +check "...and lib/" 0 "[attention]" keys_of <<<"lib/attention.sh — the target" +check "...and bin/" 0 "[decide]" keys_of <<<"bin/decide.sh — the door" +check "every extension comes off, not just the last" 0 "[issueflow-reconcile]" \ + keys_of <<<"issueflow-reconcile.test.sh — the pre-read is unpinned" +check "the key folds case" 0 "[triage]" keys_of <<<"TRIAGE.md — the bullet" +check "a + title carries both segments" 0 $'[triage]\n[releases]' \ + keys_of <<<"TRIAGE.md + RELEASES.md — a standing window is a graph" # A path segment the rule does not name stays part of the key: the strip list # is closed on purpose (D2), so `test/issueflow-reconcile.test.sh` is its own # deliverable and not the action it exercises. -check "an unlisted path segment stays in the key" 0 "test/issueflow-reconcile" \ - deliverable_keys <<<"test/issueflow-reconcile.test.sh — the pre-read" +check "an unlisted path segment stays in the key" 0 "[test/issueflow-reconcile]" \ + keys_of <<<"test/issueflow-reconcile.test.sh — the pre-read" +# One issue answers a SET. Normalization is many-to-one by design, so a `+` +# title can spell one deliverable twice — a deliverable and its test named +# together is the ordinary shape here, not an exotic one — and a repeated key +# makes the chain scan find the issue adjacent to ITSELF. +check "a + title whose segments normalize to one key answers that key once" 0 \ + "[issueflow-reconcile]" \ + keys_of <<<"issueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable" +check "...and answers it exactly once, not twice" 0 "1" \ + count_lines <<<"issueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable" +check "...and the path prefix folds onto the bare spelling the same way" 0 "1" \ + count_lines <<<"actions/issueflow-reconcile + issueflow-reconcile.sh — still one" # No em dash, no key. Inventing one out of prose is the guessing this sweep -# never does; the malformed title is triage's own contract to enforce. -check "a title with no em dash names no deliverable" 0 "" \ - deliverable_keys <<<"a title that names nothing" +# never does; the malformed title is triage's own contract to enforce. The +# emptiness is asserted through grep's exit, since `check` cannot assert an +# empty expectation. +check "a title with no em dash names no deliverable" 1 "" \ + grep -q . < <(deliverable_keys <<<"a title that names nothing") # -- the collision decision: a chain, never a fan (#288 D3) ------------------ # Sourced helpers, not `bash -c`: a subshell started with -c has none of these @@ -2113,6 +2135,20 @@ check "a multi-file title folds its collisions into one state" 0 \ $'295\treleases=292,triage=264' \ collision_chain \ <<<$'264\tready\tTRIAGE.md — one\n292\tready\tRELEASES.md — two\n295\tready\tTRIAGE.md + RELEASES.md — three' +# ...and an issue can never be its own carrier. A `+` title whose segments +# normalize to one key contributed that key twice, and the chain scan, which +# reads adjacent rows within a key, then found the issue beside itself: the +# comment asked #402 to declare `Blocked by #402`. +check "a self-folding + title never chains an issue to its own number" 0 "" \ + collision_chain \ + <<<$'402\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable' +check "...and two such carriers chain once, to each other" 0 \ + $'284\tissueflow-reconcile=257' \ + collision_chain \ + <<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two' +check "...with the older carrier still asked for nothing" 1 "" \ + collision_flags_issue 257 \ + <<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two' # -- the window decision (#292 D1) ------------------------------------------ window_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n264\tready\tTRIAGE.md — a non-member\n270\tepic\tsome epic — exempt\n271\tpost-merge\tsome item — exempt\n272\tblocked\tsome issue — already placed' @@ -2134,6 +2170,28 @@ check "the window carrier is never flagged as its own non-member" 1 "" \ check "no standing window means no flag at all" 0 "" \ window_flags "" "" <<<"$window_board" check "two standing windows render as one state" 0 "#249, #250" window_state $'249\n250\n' +# ONE reading of `unblocked` across both flags. D2 as corrected glosses the +# word as "carrying `ready` or `claimed`" and D3b says D3 uses that gloss and +# names the domain as the claimable set, so an issue that is `needs-triage` or +# carries no queue label at all is outside BOTH flags. Excluding only +# `blocked`/`epic`/`post-merge` admitted them, and the second case is the one +# that showed: the same pass adds `needs-triage` to an unlabeled issue and +# then tells it about a membership call made at mint time. +scope_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n400\tneeds-triage\tTRIAGE.md — not through the door yet\n401\t\tTRIAGE.md — no queue label at all\n402\tclaimed\tREVIEWER.md — claimable, and a non-member' +check "a needs-triage issue is not in the window flag's domain" 1 "" \ + window_flags_issue 400 253 249 <<<"$scope_board" +check "...nor is an issue carrying no queue label at all" 1 "" \ + window_flags_issue 401 253 249 <<<"$scope_board" +check "...while the claimable non-member beside them still flags" 0 "402" \ + window_flags "253" "249" <<<"$scope_board" +# The same word, asserted through the other flag, so the two can never drift +# apart again without a red. +check "the collision flag reads that word identically" 0 "" \ + collision_chain <<<$'400\tneeds-triage\tTRIAGE.md — one\n401\t\tTRIAGE.md — two' +check "...and both flags answer one shared predicate" 1 "" \ + unblocked_claimable "needs-triage" +check "...which admits ready and claimed, and nothing else" 0 "" \ + unblocked_claimable "claimed,scope:labels" # -- the 2026-08-04 board, replayed whole (D5) ------------------------------ # The corpus the operator ruled on. Both flags are decided over the WHOLE From 13605cf4b684c08fb7a2e8d8448d1d7728d3353e Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:00:48 +0000 Subject: [PATCH 138/162] pin family scoping in the direction that carries it, and D3b on the window side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family-scoping fixture asserted that a foreign marker does not SILENCE the flag, which a family-blind grep satisfies too. The property that is load-bearing is the other one: a foreign family late on the thread must not make the flag re-post. Made family-blind, the sweep now reds. D3b says a claimed non-member is flagged whether or not it has an open PR, and the fixtures covered that for the collision flag only — the window side, which is the flag the 18:11Z correction was about, had no case at all. --- test/issueflow-reconcile.test.sh | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 19c415e..1f39de6 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -2341,6 +2341,24 @@ foreign_out="$(board_run)" check "another family's marker never silences the collision flag" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=257' \ printf '%s\n' "$foreign_out" +# The direction that is actually load-bearing, and that the case above cannot +# reach: a foreign family's marker landing AFTER this flag's own must not make +# the flag speak again. Family-blind, "the last marker on the thread" is the +# blocked-parse echo's, which is not this state's marker, and the flag +# re-posts a comment that already stands — the noise D4's dedup exists to +# stop, on the one thread where three families all have something to say. +jq -n --arg b " +this flag's own last word" \ + --arg c " +a different family, later on the thread" \ + '[{"user": {"login": "sweep-bot"}, "body": $b}, + {"user": {"login": "sweep-bot"}, "body": $c}]' \ + >"$BOARD/repos_owner_repo_issues_284_comments.json" +later_foreign_out="$(board_run)" +check "a foreign family's LATER marker never makes the flag re-post" 1 "" \ + grep -qF 'issueflow: #284: collision flag' <<<"$later_foreign_out" +check "...while every other collision on the board still speaks" 0 "2" \ + flag_count collision "$later_foreign_out" # -- the post-ruling board draws nothing (D5's must-not-flag leg) ----------- # The same issues after triage placed them: the TRIAGE.md triple chained @@ -2419,6 +2437,33 @@ check "a claimed carrier with an open PR draws the ready issue's flag too" 0 \ 'issueflow: #284: collision flag — issueflow-reconcile=253' \ printf '%s\n' "$in_flight_out" +# -- D3b's headline case, on the WINDOW side (acceptance criterion) ---------- +# The criterion says a `claimed` non-member is flagged whether or not it has +# an open PR, and it is the line the 18:11Z ruling turned on — triage had +# excluded the open-PR case at 18:06Z and corrected it five minutes later. +# The collision fixtures above cover the PR-liveness question for their flag; +# this covers it for the other one. #292's charge against the third state is +# that a non-member competes with gate members for builders, and a non-member +# holding a builder AND a review round is that competition realized. +printf '%s\n' \ + '{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ + >"$BOARD/graphql-open.json" +board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ + 'Blocked by #253.' +board_issue 253 claimed 'issueflow-reconcile — a member holding the window open' '' 1 +board_issue 402 claimed 'REVIEWER.md — a non-member with a builder and a round' '' 1 +board_assemble 249 253 402 +nonmember_pr_out="$(board_run)" +check "a claimed non-member with an open PR still draws the window flag" 0 \ + 'issueflow: #402: window flag — an unblocked non-member under #249' \ + printf '%s\n' "$nonmember_pr_out" +check "...and the gate member beside it, also claimed with a PR, is not" 1 "" \ + grep -qF 'issueflow: #253: window flag' <<<"$nonmember_pr_out" +check "...and the live claim is left exactly as it was" 1 "" \ + grep -qF 'issue edit' "$BOARD/edits" +check "...one window flag on the board, and only one" 0 "1" \ + flag_count window "$nonmember_pr_out" + # -- flagged, resolved, recreated unchanged: silent, and specified ---------- # D4's boundary, asserted rather than left accidental. Nothing is posted at # the resolution — D1 admits no comment there — so the thread's last word is From 20e35b15da672ebd55f4c6c53630fe0a79c6d064 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:03:24 +0000 Subject: [PATCH 139/162] the release note says unblocked, not ready D3b corrected the window flag to fire on `claimed` too, PR in flight or not; the fragment still described it as a `ready` non-member, which is the reading the ruling struck. --- changelog.d/293.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog.d/293.md b/changelog.d/293.md index 2ef2ebb..40a6d63 100644 --- a/changelog.d/293.md +++ b/changelog.d/293.md @@ -4,10 +4,10 @@ open, unblocked issues whose titles name one deliverable draw a comment naming the newer's owed `Blocked by` edge. Keys normalize, so `actions/x` and `x` are one deliverable (#288). -- The sweep now flags a `ready` non-member during a standing release window, - naming the window's invariant. The gate is read from the release issue's - own `Blocked by` declarations, and an emptied gate leaves it dormant - (#292). +- The sweep now flags an unblocked non-member during a standing release + window, naming the window's invariant. `claimed` counts, PR in flight or + not. The gate is read from the release issue's own `Blocked by` + declarations, and an emptied gate leaves it dormant (#292). - Both flags are advisory: comments only, no label write and no state change, deduped against each family's last word on the thread so a standing state re-sweeps silently (#293). From 3bba47bacf0db86e06825e90d231e32ad86e2c86 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:39:05 +0000 Subject: [PATCH 140/162] test: pin the claimed-branch ruling pre-read against its own diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claimed` + `needs-ruling` with no assignee posts `claimed-unassigned` before the tail, so the top read is the only reason the ruling nudge below still sees the real quiet. Probe 110 asserts both outputs of one sweep and reds when that read drifts below the post. Recovered from ed588a2 by sha — the branch ref was deleted and the push crossed the merge — then re-measured at fd22bd2, behind #293. 486 -> 488 on this file. Refs #307 --- test/issueflow-reconcile.test.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 1f39de6..f0231ec 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -945,6 +945,25 @@ check "an hour-old claim does not silence a ruling 9 days quiet" 0 "" \ check "...and the fresh assignment is not reclaim bait either" 1 "" \ grep -q 'reclaimed' <<<"$claimed_fresh" +# The claimed branch's top read, pinned. `claimed` + `needs-ruling` with no +# assignee is the one composition where this branch posts BEFORE the ruling +# block: `claim_decision` returns FLAG_UNASSIGNED and the +# `claimed-unassigned` comment goes out, so a ruling clock read down there +# would date the issue by the sweep's own writing and buy the escalation +# another 7 quiet days — the #274 hazard, on the very branch #284 D6 exists +# to hold. Probe 101 does not reach it: it carries an assignee and an open +# PR. One sweep, both outputs — the board diagnostic and the nudge it must +# not silence — because asserting only the nudge would pass with the +# diagnostic silently gone. Reds the moment the top read drifts below the +# post, and the deletion of that read is what proved it (#284 D6, #307). +ruling_quiet 110 +timeline_add 110 assigned 3600 +unassigned_flag="$(issue_probe 110 $'claimed\nneeds-ruling' 0 false)" +check "an unassigned claim under a ruling still draws its board flag" 0 "1" \ + grep -cF '' "$TMP/posted-110" +check "...and the ruling nudge fires beside it, undated by it" 0 "" \ + grep -q 'ruling nudge' <<<"$unassigned_flag" + # The clock rule alone, no assignee in the way: an assigned/unassigned pair # in the timeline is the claim's history, not activity toward the ruling. ruling_quiet 102 From 3af132edf784a2b62b2a6a1e71e8622e4745b754 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:44:42 +0000 Subject: [PATCH 141/162] changelog.d: the claimed-branch ruling pre-read is pinned Grouped shape, terminal cite, 287 characters. Refs #307 --- changelog.d/307.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/307.md diff --git a/changelog.d/307.md b/changelog.d/307.md new file mode 100644 index 0000000..5c59de4 --- /dev/null +++ b/changelog.d/307.md @@ -0,0 +1,6 @@ +### Added + +- The issue-flow sweep's `claimed`-branch ruling pre-read is pinned: an + unassigned claim under `needs-ruling` must draw its board diagnostic and + its ruling nudge in one sweep, so a read that drifts below the diagnostic + reds instead of silently costing the escalation 7 days (#284, #307). From 8080618a7047c7ea414a31782e6d867eae0eb244 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:17:30 +0000 Subject: [PATCH 142/162] changelog.d: the README is rewritten whole from the current tree --- changelog.d/311.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/311.md diff --git a/changelog.d/311.md b/changelog.d/311.md new file mode 100644 index 0000000..4c8dd26 --- /dev/null +++ b/changelog.d/311.md @@ -0,0 +1,6 @@ +### Changed + +- `README.md` is rewritten whole from the current tree: the front page names + the governance repo ceremony now is, routes to `docs/CONSUMERS.md`, + `AGENTS.md`, `LABELS.md` and `RELEASES.md` rather than restating them, and + keeps the operator's release runbook as its core, re-measured (#311). From 57a7b156ace71f853d85ef576da2fd6d3b817ab9 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:22:13 +0000 Subject: [PATCH 143/162] README.md rewritten whole from the current tree --- README.md | 498 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 280 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index 3ffaa7e..6999f14 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,64 @@ # ceremony -One release ceremony for the whole heavy-duty family — implemented once, -tested once, documented here, consumed everywhere else by reference. The -approach and its constraints live in -[#1](https://github.com/heavy-duty/ceremony/issues/1); this README is the -operator-facing doctrine that used to live, three times over, in the -consumers' CONTRIBUTINGs. +The heavy-duty family's **governance repo**: the machinery every repo in the +family runs, and the doctrine every agent in the family reads. Implemented +once here, tested once here, consumed everywhere else — never copied. -- **Adopting or converting a repo** → [docs/CONSUMERS.md](docs/CONSUMERS.md). -- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you; - [CONTRIBUTING.md](CONTRIBUTING.md) has the repo specifics. +Two kinds of thing live in this tree, and they are consumed in two different +ways because they have two different runtimes. + +**Machinery is consumed by reference, at a pin.** The reusable workflows in +[`.github/workflows/`](.github/workflows/) and the composite actions in +[`actions/`](actions/) are fetched by GitHub at run time from the ref the +caller pins; no copy exists in the consumer. That machinery is two systems. +The **release ceremony** — [`release.yml`](.github/workflows/release.yml), +the decision and fact libraries under [`lib/`](lib/), and the guard actions +that keep a release honest — is the operator-facing half, and the runbook +below is its documentation. The **label and issue-flow machine** — +[`labels.yml`](.github/workflows/labels.yml) and its detached sweep half +[`labels-sweep.yml`](.github/workflows/labels-sweep.yml) (split in #209), +driving [`labels-scope`](actions/labels-scope/), +[`labels-reconcile`](actions/labels-reconcile/) and +[`issueflow-reconcile`](actions/issueflow-reconcile/) — converges PR state +and the issue work queue. What its labels *mean* is +[LABELS.md](LABELS.md)'s contract, not this page's. + +**Doctrine is consumed as a machine-verified mirror.** A document's only +runtime is an agent reading the working tree it stands in, and a doc that +needs a cross-repo fetch before it governs is a doc that sometimes goes +unread. So the agent-facing set — the files named in +[`docs/VENDORED.txt`](docs/VENDORED.txt) — is vendored into each governed +repo at `.ceremony/`, byte-identical to this repo at the pinned ref, by +[`actions/docs-sync`](actions/docs-sync/). A CI guard diffs the mirror +against the pin on every PR: hand-editing a vendored file, or bumping the +pin without re-syncing, goes red. It is a copy that cannot drift, which is +the only kind of copy this org allows. This README is deliberately *not* in +that set — a consumer's router is its `AGENTS.md`, not this repo's front +page — and [`.github/scripts/vendored-check.sh`](.github/scripts/vendored-check.sh) +records that reason beside the three other ceremony-only root docs. + +**One pin governs both halves.** The ref a repo's workflow callers name is +the ref its `.ceremony/` mirror is verified against, so a process change +rolls out as one reviewed PR per repo: the pin line plus the re-synced +mirror, checked by the same guard. + +## Where to go + +- **Adopting ceremony, or converting a repo that carries its own copy** → + [docs/CONSUMERS.md](docs/CONSUMERS.md) — the bootstrap and conversion + checklists, the caller stubs, the pin-bump procedure. +- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you + to your role file ([TRIAGE.md](TRIAGE.md), [BUILDER.md](BUILDER.md), + [REVIEWER.md](REVIEWER.md)); [CONTRIBUTING.md](CONTRIBUTING.md) carries + this repo's own specifics — the review panel roster, the `scope:*` set, + the code and doctrine conventions. +- **The board: what a label means, and who may set it** → + [LABELS.md](LABELS.md). It is the shared state machine; misusing one label + lies to every other agent on the board. +- **Family release windows — what ships together, and when** → + [RELEASES.md](RELEASES.md). +- **How the operator fleet is actually wired** → [FLEET.md](FLEET.md), a + descriptive snapshot rather than doctrine. - **Operating a release, or staring at a red run on main** → read on. ## What a release is @@ -23,13 +72,14 @@ stamps: ([lib/version.sh](lib/version.sh)). 2. **The changelog section is assembled — one edit, produced by the tool** (#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one - fragment file, `changelog.d/.md`, and the ceremony PR runs - [bin/changelog-assemble](bin/changelog-assemble) — by hand, on purpose, - so the section lands in the PR's diff where the panel reads it (#112 - D12; a consumer's exact invocation is in - [docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). - The tool folds every fragment into a new `## X.Y.Z — DATE` section on - top and deletes the fragments it consumed; the + fragment file, `changelog.d/.md` + ([the directory's marker](changelog.d/README.md) names the doctrine), and + the ceremony PR runs [bin/changelog-assemble](bin/changelog-assemble) — + by hand, on purpose, so the section lands in the PR's diff where the + panel reads it (#112 D12; a consumer's exact invocation is in + [docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). The + tool folds every fragment into a new `## X.Y.Z — DATE` section on top and + deletes the fragments it consumed; the [assembled guard](#changelog-assembled--the-stamp-is-exactly-the-fragments) replays that run and refuses a stamp that is not byte-for-byte the fragments' assembly. @@ -38,62 +88,61 @@ stamps: `## Unreleased` back on top — because every PR inserted at that one shared anchor, and between the stamp and the re-arm a PR authored *before* the release landed its entry under whatever now occupied the - position — **the section that just shipped** — cleanly, no conflict, - no signal (box#108; confirmed cross-repo as rig#66). Fragments make - that failure structurally impossible rather than guarded-against: a - fragment merged after the release simply sits in the directory and is - assembled into the *next* section. There is no anchor left to misplace, - and nothing to re-arm — the directory is always armed. + position — **the section that just shipped** — cleanly, no conflict, no + signal (box#108; confirmed cross-repo as rig#66). Fragments make that + failure structurally impossible rather than guarded-against: a fragment + merged after the release simply sits in the directory and is assembled + into the *next* section. There is no anchor left to misplace, and nothing + to re-arm — the directory is always armed. 3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the - evidence the release rests on - ([the drill doctrine](#the-drill-doctrine)). + evidence the release rests on ([the drill doctrine](#the-drill-doctrine)). -(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the -ref consumers' runs fetch this repo at — moves to the version being -released, in [release.yml](.github/workflows/release.yml#L123-L132) and -every other workflow that carries it. -[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not -a consumer's release, when it is stale.) +(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the ref +consumers' runs fetch this repo at — moves to the version being released, in +[release.yml](.github/workflows/release.yml#L123-L132) and every other +workflow that carries it. +[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not a +consumer's release, when it is stale.) **The merge is the ship decision; the tag is transcription.** After the -merge, [release.yml](.github/workflows/release.yml#L136-L300) asserts its +merge, [release.yml](.github/workflows/release.yml#L136-L301) asserts its way to certainty, tags the merge commit, publishes the GitHub release with the version's own changelog section as the body — the curated prose, never -the generated PR list -([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) — -and re-arms main by bumping to `X.Y.(Z+1)-dev` -([release.yml](.github/workflows/release.yml#L266-L300)) — the version is -the only re-arm left; the changelog needs none (#112). The machine does -the transcription because humans err silently and machines fail loudly: -**everything asserts its way to certainty and fails loudly, creating +the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one +canonical extractor, and [bin/changelog-section](bin/changelog-section) is +its command-line face) — and re-arms main by bumping to `X.Y.(Z+1)-dev`; the +version is the only re-arm left, the changelog needs none (#112). The +machine does the transcription because humans err silently and machines fail +loudly: **everything asserts its way to certainty and fails loudly, creating nothing** — a wrong release is worse than a missing one, so every failed assert leaves zero artifacts: no tag, no release, no bump. ## The two doors -- **The merge door — the paved road.** A push to main - ([release.yml](.github/workflows/release.yml#L140)) runs the +- **The merge door — the paved road.** A push to main runs the [decide table](#what-happens-when-my-pr-lands-on-main); a merged, `release`-labeled PR whose version transitioned to bare is the ceremony, everything legitimate that isn't one is a green no-op, and every - half-ceremony dies loudly. Use it for every normal release. + half-ceremony dies loudly + ([release.yml](.github/workflows/release.yml#L136-L301)). Use it for every + normal release. -- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag - push — **no `v` prefix**, box's 0.6.0 set the scheme - ([release.yml](.github/workflows/release.yml#L302-L369)) — publishes the +- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push + — **no `v` prefix**, box's 0.6.0 set the scheme + ([release.yml](.github/workflows/release.yml#L303-L371)) — publishes the same way. The tag is the operator's explicit act, so there is no decide - and no label check; the one assert is that **the tag names the tree's - own version**, and a mismatch refuses, creating nothing. No `-dev` bump - either — the fallback does not rewrite main (cast's precedent). Use it - when the merge path is red, for backfills, and for the + and no label check; the one assert is that **the tag names the tree's own + version**, and a mismatch refuses, creating nothing. No `-dev` bump either + — the fallback does not rewrite main (cast's precedent). Use it when the + merge path is red, for backfills, and for the [first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4). -Tag + publish (+ the consumer's artifact hook) happen **in the same job, -on purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's -anti-recursion), so the merge door's tag can never re-enter the tag door -and double-publish — and that job is the release's only chance to publish -([release.yml](.github/workflows/release.yml#L223-L234), #1 constraint 2). +Tag + publish (+ the consumer's artifact hook) happen **in the same job, on +purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's +anti-recursion), so the merge door's tag can never re-enter the tag door and +double-publish — and that job is the release's only chance to publish (#1 +constraint 2). ## What happens when my PR lands on main @@ -101,8 +150,8 @@ The merge door runs on **every** push to main, and the `release` label legitimately means two things (release ceremonies, and ordinary work *on* the release machinery), so the door's first act is a decision: the six-row table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment -block *is* the spec, and the table is contract-tested offline). Rendered -for operators: +block *is* the spec, and the table is contract-tested offline by +[test/decide.test.sh](test/decide.test.sh)). Rendered for operators: | # | the tree your merge produced | the run | what it means — and your move | |---|---|---|---| @@ -113,9 +162,9 @@ for operators: | 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. | | 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. | -The green rows are the point as much as the red ones: the machinery must -be safe to work on, so every legitimate non-ceremony is a green `NOTICE` -no-op — never a red run on main per infra PR +The green rows are the point as much as the red ones: the machinery must be +safe to work on, so every legitimate non-ceremony is a green `NOTICE` no-op, +never a red run on main per infra PR ([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and automation never guesses; the version transition is the interlock, and label-without-transition (row 4) and transition-without-label (row 5) both @@ -123,36 +172,54 @@ refuse (#1 constraint 8). ## The guards -Four composite actions run in every consumer's CI (and in this repo's -own). Shared shape: version-keyed where the tree's state matters, loud -where it fails, and **a file of its own so a test can drive it**. The full -war stories are in the scripts' header comments — authoritative and longer -than this; what follows is the operator's cut. +[`actions/`](actions/) holds ten composite actions. Three belong to the +label machine named above and are not the operator's business here. Of the +remaining seven, a consumer's own `ci.yml` carries **five** guard steps — +`changelog-armed`, `changelog-monotonic`, `changelog-assembled`, +`drill-recorded` and [`runner-isolated`](actions/runner-isolated/), the last +asserting that no `pull_request`-triggered workflow names a self-hosted +runner (#58) — plus [`refs-not-closing`](actions/refs-not-closing/) in its +own [`refs-guard.yml`](.github/workflows/refs-guard.yml) caller, because +body edits are load-bearing there (#200, #218), and +[`docs-sync`](actions/docs-sync/) once the repo adopts the agent team flow. +The exact steps and their pin-availability rules are in +[docs/CONSUMERS.md](docs/CONSUMERS.md). + +The four below are the release's own, and this is the operator's cut of +them. Shared shape: version-keyed where the tree's state matters, loud where +it fails, and **a file of its own so a test can drive it**. The full war +stories are in the scripts' header comments — authoritative and longer than +this. + +This repo eats what it serves: [`ci.yml`](.github/workflows/ci.yml) runs the +guard actions against its own real tree, and +[`release-exercise.yml`](.github/workflows/release-exercise.yml) replays the +merge door's step sequence on every PR. ### changelog-armed — main never sits disarmed **The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh)), keyed on the tree's shape, then its version. In **fragment mode** — -`changelog.d/` exists, the arming property moved onto the directory -(#112 D7): +`changelog.d/` exists, the arming property moved onto the directory (#112 +D7): - always → the marker `changelog.d/README.md` must exist (what keeps the - directory tracked when it holds no fragments), no `## Unreleased` - section may survive in `CHANGELOG.md` (a second anchor with no owner), - and every fragment must be publishable on its own — named `.md` - or `-.md`, no `## ` heading, at least one bullet, no - `### ` heading without an entry. A malformed fragment fails the PR that - wrote it, not the release that consumes it (#112 D9). -- `-dev` tree → nothing more. The directory **is** the arming: the next - PR's entry is a new file, and a new file always has somewhere to land. + directory tracked when it holds no fragments), no `## Unreleased` section + may survive in `CHANGELOG.md` (a second anchor with no owner), and every + fragment must be publishable on its own — named `.md` or + `-.md`, no `## ` heading, at least one bullet, no `### ` + heading without an entry. A malformed fragment fails the PR that wrote it, + not the release that consumes it (#112 D9). +- `-dev` tree → nothing more. The directory **is** the arming: the next PR's + entry is a new file, and a new file always has somewhere to land. - bare tree (the ceremony PR and its merge) → every fragment must be - consumed, and the top section must be the stamped, publishable section - for exactly that version. Fragment mode has no re-armed shape — there - is nothing left to re-arm. + consumed, and the top section must be the stamped, publishable section for + exactly that version. Fragment mode has no re-armed shape — there is + nothing left to re-arm. In **legacy mode** — no `changelog.d/` — the version-keyed rules stand -verbatim; both shapes stay supported so a consumer adopts fragments on a -pin bump, on its own schedule (#112 D8): +verbatim; both shapes stay supported so a consumer adopts fragments on a pin +bump, on its own schedule (#112 D8): - `-dev` tree → the top section **must** be `## Unreleased`. - bare tree (the ceremony PR and its merge) → the top section may be @@ -166,32 +233,30 @@ pin bump, on its own schedule (#112 D8): uses, so the two cannot disagree about what a section is). **The incident**: box#108 / rig#66 — the silent mislanding described -[above](#what-a-release-is). Fragment mode retires the incident's -mechanism outright; legacy mode guards it. **Red means** a PR entry has -nowhere safe to land — a missing marker, a surviving `## Unreleased`, a -malformed fragment — or a stamped version would publish no entries, a -dangling grouped heading, or a bare tree still carrying fragments the -stamp did not consume (`not consumed` — re-run the assembler); the -message names the fix in every case. What this guard cannot see is a -fragment that *was* consumed but whose entry the stamp omits — the -fragment is gone from HEAD, so only +[above](#what-a-release-is). Fragment mode retires the incident's mechanism +outright; legacy mode guards it. **Red means** a PR entry has nowhere safe +to land — a missing marker, a surviving `## Unreleased`, a malformed +fragment — or a stamped version would publish no entries, a dangling grouped +heading, or a bare tree still carrying fragments the stamp did not consume +(`not consumed` — re-run the assembler); the message names the fix in every +case. What this guard cannot see is a fragment that *was* consumed but whose +entry the stamp omits — the fragment is gone from HEAD, so only [changelog-assembled](#changelog-assembled--the-stamp-is-exactly-the-fragments)'s merge-base replay catches that loss. **Do not "simplify" this to "always require `## Unreleased`".** The -unconditional form is false by construction on the ceremony PR's own tree -— it makes every release unshippable — and rig#44 and cast#108 both had -to revert exactly that -([the script's header](actions/changelog-armed/changelog-armed.sh#L8-L16)). -The version-keyed form is what rig and cast get back by adopting this repo. +unconditional form is false by construction on the ceremony PR's own tree — +it makes every release unshippable — and rig#44 and cast#108 both had to +revert exactly that. The version-keyed form is what rig and cast get back by +adopting this repo. One consequence worth knowing before it happens, legacy mode only: a ceremony PR that stamps and forgets to re-arm still passes this guard — a bare tree is allowed to be stamped. It goes red **the moment the automatic `-dev` bump lands on main**. The guard does not block the release; it refuses to let main *sit* disarmed, which is the window a late PR falls -into. Fragment mode has no such window: with no re-arm step there is -nothing to forget. +into. Fragment mode has no such window: with no re-arm step there is nothing +to forget. ### changelog-assembled — the stamp is exactly the fragments @@ -199,126 +264,122 @@ nothing to forget. ([actions/changelog-assembled/changelog-assembled.sh](actions/changelog-assembled/changelog-assembled.sh)): on a release PR in fragment mode, the stamped `## X.Y.Z` section must be **byte-for-byte** what the fragments it consumed assemble to. The guard -reads the fragments as of the merge base (they are gone from HEAD — that -is the point of the ceremony), replays `changelog-assemble --check` over -that set, and diffs the result against HEAD's section body. Every tree it -does not apply to — a `-dev` tree, legacy mode, no consumed fragments — -passes with a green `NOTICE`, so a non-ceremony PR is never red here. +reads the fragments as of the merge base (they are gone from HEAD — that is +the point of the ceremony), replays `changelog-assemble --check` over that +set, and diffs the result against HEAD's section body. Every tree it does +not apply to — a `-dev` tree, legacy mode, no consumed fragments — passes +with a green `NOTICE`, so a non-ceremony PR is never red here. **The failure it catches** (#116): assembly is a hand-run step by design — -the section must land in the PR's diff where the panel reads it (#112 D12) -— and a mis-run hand step can leave no trace. The two failure shapes -differ, and the guards split them exactly as -[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s -trio rows record: leave a fragment **out of the deletion** and it survives -on HEAD, where -[changelog-armed](#changelog-armed--main-never-sits-disarmed) already -refuses the bare tree (`not consumed`) — this guard goes red too, naming -the entry the section lost. But **delete** a fragment while omitting its -entry from the stamp, or hand-edit one word of the assembled prose, and -nothing on HEAD is out of place: armed is green, monotonic is green, and -the publisher would happily publish history that is not what the authors -wrote. Only the merge-base replay catches those. The replay is what -makes a hand-run step safe. **This guard needs history** — same stance as -the monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a -hard failure, not a skip. +the section must land in the PR's diff where the panel reads it (#112 D12) — +and a mis-run hand step can leave no trace. The two failure shapes differ, +and the guards split them exactly as +[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s trio +rows record: leave a fragment **out of the deletion** and it survives on +HEAD, where [changelog-armed](#changelog-armed--main-never-sits-disarmed) +already refuses the bare tree (`not consumed`) — this guard goes red too, +naming the entry the section lost. But **delete** a fragment while omitting +its entry from the stamp, or hand-edit one word of the assembled prose, and +nothing on HEAD is out of place: armed is green, monotonic is green, and the +publisher would happily publish history that is not what the authors wrote. +Only the merge-base replay catches those. The replay is what makes a +hand-run step safe. **This guard needs history** — same stance as the +monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a hard +failure, not a skip. ### changelog-monotonic — shipped headings are append-only **The rule** -([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh#L4-L7)): -the set of `## X.Y.Z` headings on your branch must be a **superset** of -the set at the merge base, and no heading may appear twice on HEAD. The -rule needs no tuning because release headings are append-only by doctrine: -the ceremony adds one and nothing ever legitimately removes one — so -superset has no exception to carve. The ceremony's own stamp passes by -construction: the assembler writes a new `## X.Y.Z — DATE` heading and -removes none. Fragment mode changes nothing here (#112 D10): fragments add -no `## ` heading, and `Unreleased` was never in the guard's set — it is -not a version heading; it is +([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh)): +the set of `## X.Y.Z` headings on your branch must be a **superset** of the +set at the merge base, and no heading may appear twice on HEAD. The rule +needs no tuning because release headings are append-only by doctrine: the +ceremony adds one and nothing ever legitimately removes one — so superset +has no exception to carve. The ceremony's own stamp passes by construction: +the assembler writes a new `## X.Y.Z — DATE` heading and removes none. +Fragment mode changes nothing here (#112 D10): fragments add no `## ` +heading, and `Unreleased` was never in the guard's set — it is not a version +heading; it is [changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business — which is why a repo's adoption PR can delete it and stay green. -**The incidents**: box#122 (caught in review of box#118) — an author -adding an entry under `## Unreleased` **replaced** the heading below it -instead of inserting above it; git merges that cleanly, and the shipped -section's body is silently absorbed into `## Unreleased`. And box#118 -itself — a bad rebase *duplicated* a shipped heading, which containment is -blind to, which is why uniqueness-on-HEAD is a separate assert -([the script](actions/changelog-monotonic/changelog-monotonic.sh#L96-L116)). +**The incidents**: box#122 (caught in review of box#118) — an author adding +an entry under `## Unreleased` **replaced** the heading below it instead of +inserting above it; git merges that cleanly, and the shipped section's body +is silently absorbed into `## Unreleased`. And box#118 itself — a bad rebase +*duplicated* a shipped heading, which containment is blind to, which is why +uniqueness-on-HEAD is a separate assert. -**Red means** a shipped section was deleted (put the heading back and -insert **above** it) or duplicated (collapse to one heading; the failure -message walks through both fixes with the diff to run). **This guard needs +**Red means** a shipped section was deleted (put the heading back and insert +**above** it) or duplicated (collapse to one heading; the failure message +walks through both fixes with the diff to run). **This guard needs history**: the consumer's checkout must use `fetch-depth: 0`, and in CI an unresolvable base is a hard failure, not a skip — a guard that can quietly -stop guarding is the failure shape this family of checks exists to refuse -([strict mode](actions/changelog-monotonic/changelog-monotonic.sh#L60-L79)). +stop guarding is the failure shape this family of checks exists to refuse. ### drill-recorded — a release carries its evidence **The rule** -([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh#L23-L48)), -keyed on the tree's version: a `-dev` tree passes with nothing to assert -(a development tree ships nothing); a bare tree — the ceremony PR and its -merge — must carry `drills/.md` with at least one -non-whitespace character. One file per version, so `0.9.0.md` and -`0.9.0-rc1.md` are simply different files and prefix confusion is -unrepresentable (#1 constraint 7). +([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh)), +keyed on the tree's version: a `-dev` tree passes with nothing to assert (a +development tree ships nothing); a bare tree — the ceremony PR and its merge +— must carry `drills/.md` with at least one non-whitespace +character. One file per version, so `0.9.0.md` and `0.9.0-rc1.md` are simply +different files and prefix confusion is unrepresentable (#1 constraint 7). **The incident**: box's CONTRIBUTING said since box#96 that the release ritual must be run and recorded. No release ever did it — box#95, box#114 and box#148 all shipped as a version bump plus a changelog stamp, because -the gate was a sentence in a document and the only thing standing on it -was a reviewer remembering to ask. The rule moved into CI, where it fires +the gate was a sentence in a document and the only thing standing on it was +a reviewer remembering to ask. The rule moved into CI, where it fires whether or not anyone is paying attention. **Red means** the release is asserting a ritual it left no evidence of. -**The fix is to run the drill** and record it — or to waive it *in -writing* at the same path: the guard demands a **record, not a passing -result** ([below](#the-drill-doctrine)). +**The fix is to run the drill** and record it — or to waive it *in writing* +at the same path: the guard demands a **record, not a passing result** +([below](#the-drill-doctrine)). ## The drill doctrine **Evidence, not success.** The guard asserts a record exists — a failed drill honestly written down satisfies it, and so does a maintainer waiver -that says plainly the drill was waived and why. What it refuses is -silence: a skip must cost a deliberate, reviewable file in the diff, -which is precisely what box's three silent skips never produced. CI -cannot run a consumer's drill (box's wants real hardware and the better -part of an hour); it can only refuse a release that never ran one. +that says plainly the drill was waived and why. What it refuses is silence: +a skip must cost a deliberate, reviewable file in the diff, which is +precisely what box's three silent skips never produced. CI cannot run a +consumer's drill (box's wants real hardware and the better part of an hour); +it can only refuse a release that never ran one. **Each repo defines what its drill *means*** — the gate only reads the -record. box asserts the **isolation contract**; rig asserts -**convergence** (a machine reaches its role, idempotently); cast asserts -**promotion** (A→B reproduces, the diff is idempotent); ceremony's own -drill is a **door rehearsal** — both doors exercised end-to-end on a -disposable repo (#11 names the six probes); incubator's is TBD in +record. box asserts the **isolation contract**; rig asserts **convergence** +(a machine reaches its role, idempotently); cast asserts **promotion** (A→B +reproduces, the diff is idempotent); ceremony's own drill is a **door +rehearsal** — both doors exercised end-to-end on a disposable repo, written +out step by step in [drills/README.md](drills/README.md), with the records +themselves in [drills/](drills/); incubator's is TBD in heavy-duty/incubator. Each repo states its meaning in its own -`drills/README.md`. Three different exercises sharing a substrate is why -the records are per-repo — they are not phases of one script. +`drills/README.md`. Three different exercises sharing a substrate is why the +records are per-repo — they are not phases of one script. **Drills exercise candidate refs, not released artifacts.** A ref is a -static identifier that exists as soon as the release branch does, so no -repo has to be released — or drilled — before another can be drilled: -what looks like a box↔rig recursion at runtime dissolves into two -independent tests against one fixed pair of refs. And drilling the -candidate *is* drilling the release: a ceremony PR's diff is the stamps -and nothing else, so no executable byte differs between the tree that was -drilled and the tree that ships. +static identifier that exists as soon as the release branch does, so no repo +has to be released — or drilled — before another can be drilled: what looks +like a box↔rig recursion at runtime dissolves into two independent tests +against one fixed pair of refs. And drilling the candidate *is* drilling the +release: a ceremony PR's diff is the stamps and nothing else, so no +executable byte differs between the tree that was drilled and the tree that +ships. **A cross-repo release set shares one run ID.** Each repo records its own -legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling -SHAs, so the records reconcile afterwards — but the guard only ever reads -the repo it runs in. If a defect shows up only in the combination: patch, -re-drill, re-record. The set converges; it is not required to be right in -one pass. +legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling SHAs, +so the records reconcile afterwards — but the guard only ever reads the repo +it runs in. If a defect shows up only in the combination: patch, re-drill, +re-record. The set converges; it is not required to be right in one pass. ## Troubleshooting red main Every refusal the release flow can emit, verbatim, with cause and remedy. -The catalog is generated from the sources, not paraphrased — regenerate -it with: +The catalog is generated from the sources, not paraphrased — regenerate it +with: ```sh grep -n -A2 'refuse \|>&2' lib/decide.sh lib/facts.sh .github/workflows/release.yml @@ -347,12 +408,11 @@ or — if the tree is genuinely the release — publish by the tag door. > the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing. > the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing. -The fact-gathering guards -([L92–L105](lib/decide.sh#L92-L105), [L135](lib/decide.sh#L135), -[L151](lib/decide.sh#L151)): a missing fact must never fall through to -"no". These indicate a bug upstream in [lib/facts.sh](lib/facts.sh) or the -workflow plumbing, not an operator mistake — read the run's `facts:` -stderr line and file what you find. +The fact-gathering guards ([L92–L105](lib/decide.sh#L92-L105), +[L135](lib/decide.sh#L135), [L151](lib/decide.sh#L151)): a missing fact must +never fall through to "no". These indicate a bug upstream in +[lib/facts.sh](lib/facts.sh) or the workflow plumbing, not an operator +mistake — read the run's `facts:` stderr line and file what you find. ### The facts could not be established ([lib/facts.sh](lib/facts.sh), [lib/version.sh](lib/version.sh)) @@ -367,44 +427,44 @@ stderr line and file what you find. > version_read: node is required for version-source: package-json [lib/version.sh](lib/version.sh#L16-L66): the tree's version source is -missing, empty, or unreadable. A wrong release is worse than a missing -one, so an unreadable state is never an empty print — restore the -`VERSION` file (or `package.json` version field) on main. +missing, empty, or unreadable. A wrong release is worse than a missing one, +so an unreadable state is never an empty print — restore the `VERSION` file +(or `package.json` version field) on main. -### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L300)) +### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L301)) > CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release [L202–L205](.github/workflows/release.yml#L202-L205): the ceremony merged without its stamp (a state the -[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses -on the PR — red main here means it was overridden). Stamp the section on -main, then publish by the tag door. +[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses on +the PR — red main here means it was overridden). Stamp the section on main, +then publish by the tag door. > tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing. > release '$VER' already exists — refusing to re-release, creating nothing. -[L207–L222](.github/workflows/release.yml#L207-L222), the nothing-exists +[L208–L223](.github/workflows/release.yml#L208-L223), the nothing-exists assert — what makes a re-run of a completed ceremony refuse instead of clobber, and what catches a manual tag racing the merge. If the release -truly exists, there is nothing to do: this red is the system declining to -do the thing twice. If the tag exists but the release does not (a manual -tag won the race, or +truly exists, there is nothing to do: this red is the system declining to do +the thing twice. If the tag exists but the release does not (a manual tag +won the race, or [a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook)), recover by the tag door: delete and re-push the tag, or `gh release create` by hand from a fixed tree. > direct push refused (branch protection?) — opening the bump PR instead -[L292–L300](.github/workflows/release.yml#L292-L300) — loud, but not a +[L293–L301](.github/workflows/release.yml#L293-L301) — loud, but not a refusal: the post-release `-dev` bump could not push directly, so the run opened a `release`-labeled bump PR itself. Your move: merge it promptly — -until it lands, main is sitting bare, where a dev install -[impersonates the release](.github/workflows/release.yml#L291) and the +until it lands, main is sitting bare, where a dev install impersonates the +release and the [armed guard's window](#changelog-armed--main-never-sits-disarmed) stays open. -### The tag door refused ([release.yml](.github/workflows/release.yml#L302-L369)) +### The tag door refused ([release.yml](.github/workflows/release.yml#L303-L371)) > tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing. > A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit. @@ -416,40 +476,42 @@ remedy. [L346–L349](.github/workflows/release.yml#L346-L349). The tagged tree was never stamped. Assemble the section -([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), -then delete and re-push the tag. +([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then +delete and re-push the tag. ### Red main that is not the release workflow Consumer CI runs its guard steps on pushes to main too (this repo's [ci.yml](.github/workflows/ci.yml) does the same). The one guard red an -operator will actually meet on main is **changelog-armed after a re-arm -was forgotten — legacy mode only**: the ceremony stamped without putting -`## Unreleased` back, the release's own `-dev` bump landed, and the guard -now says (first line): +operator will actually meet on main is **changelog-armed after a re-arm was +forgotten — legacy mode only**: the ceremony stamped without putting +`## Unreleased` back, the release's own `-dev` bump landed, and the guard now +says (first line): > changelog-armed: the version is '$ver' (a development tree) but the top > section of $changelog is: … The fix is a one-line PR: add an empty `## Unreleased` above the stamped -section. The full message carries the same instruction. Fragment mode has -no re-arm to forget, so it has no equivalent red on main — its refusals -(a missing marker, a surviving `## Unreleased`, a malformed or unconsumed +section. The full message carries the same instruction. Fragment mode has no +re-arm to forget, so it has no equivalent red on main — its refusals (a +missing marker, a surviving `## Unreleased`, a malformed or unconsumed fragment) all fire on the PR that caused them, where the author is still holding it. ## Design lineage -The ceremony converged across box#83 → box#96, rig#32 → rig#47, and -cast#96 → cast#111; this repo is those three implementations folded into -one (the drift that motivated it is measured in -[#1](https://github.com/heavy-duty/ceremony/issues/1)). The load-bearing -constraints — each bought with an incident, none of them safe to -"simplify" away — are listed in -[#1](https://github.com/heavy-duty/ceremony/issues/1) and carried, with -their war stories, in the headers of the scripts they bind: -[release.yml](.github/workflows/release.yml#L1-L109), -[lib/decide.sh](lib/decide.sh#L1-L74), -[lib/facts.sh](lib/facts.sh#L1-L24), and the four -[guard scripts](actions/). The comments are the documentation of record; -this README is their operator-facing cut. +The ceremony converged across box#83 → box#96, rig#32 → rig#47 and cast#96 → +cast#111; this repo is those three implementations folded into one, and the +drift that motivated it is measured in +[#1](https://github.com/heavy-duty/ceremony/issues/1), which also lists the +load-bearing constraints — each bought with an incident, none of them safe +to "simplify" away. The label machine's own record is #10, #11 and #130; the +issue-flow queue's is #15, #16 and #73; the fragment changelog's is #112 and +#116; the sweep/trigger split is #209. + +The narrative lives in those issues, by design: the war stories are carried +in the headers of the scripts they bind — +[release.yml](.github/workflows/release.yml), +[lib/decide.sh](lib/decide.sh), [lib/facts.sh](lib/facts.sh) and the +[guard scripts](actions/) — and those comments are the documentation of +record. This README is their operator-facing cut. From 87f300c4538c696820ccf10c96047dff971530d8 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:59:30 +0000 Subject: [PATCH 144/162] README: re-measure the incubator drill and close the refusal catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drill paragraph called incubator's drill "TBD". It has been defined in heavy-duty/incubator since 2026-07-23 (f7851cb, refined 7c20a4e on 07-24): the pre-release verify of the canonical candidate deployed to staging, its smoke probe run inside the container on deployed credentials, the record pinning commit SHA and image digest. Carried from main unmeasured, which is the one thing D1 forbids. With incubator named the paragraph enumerates five meanings, not three, so its own tally closes now too. The refusal catalog claimed to be generated from the sources, but its regeneration grep never read lib/version.sh — the four version_read messages it quotes all live there. Adding version.sh to the documented command turned up four more refusals the catalog was missing: unknown backend on the read side, and version_next_dev / version_write's two on the post-release re-arm, which release.yml:275,283 really can emit. The re-arm ones get their own section because their remedy is unlike every other entry here — the tag and the publish already happened, so the fix is a manual bump, not a re-run. One refusal stays outside the grep by construction: "no version field" is a console.error inside the node one-liner, with no >&2 and no "refuse ". The section now says so rather than shipping a command that silently under-produces the catalog it claims to generate. Answers codex-bot-andresmgsl and kimi-bot-andresmgsl (blocking, both the incubator claim) and claude-bot-andresmgsl nit 2, at head 57a7b15. --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6999f14..2fb5d67 100644 --- a/README.md +++ b/README.md @@ -355,10 +355,14 @@ record. box asserts the **isolation contract**; rig asserts **convergence** reproduces, the diff is idempotent); ceremony's own drill is a **door rehearsal** — both doors exercised end-to-end on a disposable repo, written out step by step in [drills/README.md](drills/README.md), with the records -themselves in [drills/](drills/); incubator's is TBD in -heavy-duty/incubator. Each repo states its meaning in its own -`drills/README.md`. Three different exercises sharing a substrate is why the -records are per-repo — they are not phases of one script. +themselves in [drills/](drills/); incubator asserts the **staging verify** — +the canonical candidate deployed, its smoke probe run *inside* the staging +container on the deployed environment's credentials, the record pinning the +commit SHA and image digest that were exercised +([heavy-duty/incubator `drills/README.md`](https://github.com/heavy-duty/incubator/blob/main/drills/README.md)). +Each repo states its meaning in its own `drills/README.md`. Five different +exercises sharing a substrate is why the records are per-repo — they are not +phases of one script. **Drills exercise candidate refs, not released artifacts.** A ref is a static identifier that exists as soon as the release branch does, so no repo @@ -382,10 +386,16 @@ The catalog is generated from the sources, not paraphrased — regenerate it with: ```sh -grep -n -A2 'refuse \|>&2' lib/decide.sh lib/facts.sh .github/workflows/release.yml +grep -n -A2 'refuse \|>&2' \ + lib/decide.sh lib/facts.sh lib/version.sh .github/workflows/release.yml ``` -`$VER`-style variables appear as the run interpolates them. +`$VER`-style variables appear as the run interpolates them. One refusal is +outside that command by construction: `version_read: $path: no version field` +is a `console.error` inside the node one-liner at +[lib/version.sh#L55](lib/version.sh#L55) — no `>&2`, no `refuse `, so the grep +cannot see it. It is quoted below as it reaches the log at run time, which is +the convention this catalog is written to. ### The decision refused ([lib/decide.sh](lib/decide.sh)) @@ -431,6 +441,15 @@ missing, empty, or unreadable. A wrong release is worse than a missing one, so an unreadable state is never an empty print — restore the `VERSION` file (or `package.json` version field) on main. +> version_read: unknown backend: $backend + +[L62](lib/version.sh#L62): not an operator mistake and not reachable through +the release flow — [lib/facts.sh](lib/facts.sh#L33-L40) rejects a bad +`VERSION_SOURCE` with the message above before `version_read` is ever called, +so this line can only appear when some *other* caller invokes `version_read` +directly with a backend that is neither `file` nor `package-json`. Fix that +caller. + ### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L301)) > CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release @@ -479,6 +498,39 @@ never stamped. Assemble the section ([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then delete and re-push the tag. +### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301)) + +The bump runs *after* the tag, the notes and the publish, so a refusal here +leaves a real release behind an unarmed main — the release exists and main +still reads the version it just shipped. That is the one failure in this +catalog where the remedy is a manual bump, not a re-run. + +> version_next_dev: refusing '$ver' — expected bare X.Y.Z + +[L86](lib/version.sh#L86): the version reaching the bump is not bare — +`-dev`, `-rc1`, or garbage. Unreachable by the merge door, whose row 6 fires +only on a transition *to* bare; it is the tag door's edge, where the tag names +the tree's version and nothing re-checks its shape. + +> version_write: npm is required for version-source: package-json + +[L106](lib/version.sh#L106): `version_write` shells out to `npm version` on +the package-json backend, and the runner has no npm. The read path fails the +same way one step earlier (`node is required…`, above), so a run reaching +*this* message got past the read — set up node/npm in the caller. + +> version_write: unknown backend: $backend + +[L118](lib/version.sh#L118): the write-side twin of `version_read: unknown +backend`, and unreachable for the same reason — `VERSION_SOURCE` was validated +before either was called. Fix the caller. + +In every case main is left armed to impersonate the release it just shipped: +bump `VERSION` (or the `package.json` version field) to `X.Y.(Z+1)-dev` by +hand and push. Note that a *push* refusal is not one of these — branch +protection is expected, and the step opens the bump PR itself rather than +failing ([L293–L301](.github/workflows/release.yml#L293-L301)). + ### Red main that is not the release workflow Consumer CI runs its guard steps on pushes to main too (this repo's From dac4946e4384c07966ba9f62dbee4b4f89b80f86 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:04:10 +0000 Subject: [PATCH 145/162] README: the re-arm is the merge door's, so state its reach correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-caught while re-reading the section added in 87f300c. It said version_next_dev's refusal was "the tag door's edge" — wrong twice. The tag door does not bump at all (release.yml:303-307, cast's precedent: the fallback does not rewrite main), and the merge door's bump runs only on ceremony=yes, which decide rows 5-6 reach only on a transition TO bare. The message is therefore unreachable through either door as they stand, and the honest description is a guard against a future decide change, not an edge an operator can hit today. Writing a troubleshooting entry that sends an operator looking down the tag door for a message the tag door cannot emit is the same defect this round is fixing one paragraph up, so it does not get to ship in the fix. --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2fb5d67..5da3564 100644 --- a/README.md +++ b/README.md @@ -500,17 +500,20 @@ delete and re-push the tag. ### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301)) -The bump runs *after* the tag, the notes and the publish, so a refusal here -leaves a real release behind an unarmed main — the release exists and main -still reads the version it just shipped. That is the one failure in this -catalog where the remedy is a manual bump, not a re-run. +The bump belongs to the merge door alone — the tag door deliberately does not +rewrite main ([L303–L307](.github/workflows/release.yml#L303-L307)) — and it +runs *after* the tag, the notes and the publish. So a refusal here leaves a +real release behind an unarmed main: the release exists and main still reads +the version it just shipped. That is the one failure in this catalog whose +remedy is a manual bump, not a re-run. > version_next_dev: refusing '$ver' — expected bare X.Y.Z [L86](lib/version.sh#L86): the version reaching the bump is not bare — -`-dev`, `-rc1`, or garbage. Unreachable by the merge door, whose row 6 fires -only on a transition *to* bare; it is the tag door's edge, where the tag names -the tree's version and nothing re-checks its shape. +`-dev`, `-rc1`, or garbage. Not reachable through either door as they stand: +the step runs only on `ceremony=yes`, which rows 5–6 reach only on a +transition *to* bare, and the tag door never bumps. Treat it as the guard it +is — it fires if a decide change ever lets a non-bare version through. > version_write: npm is required for version-source: package-json From 052c8734b93b0c463bfc8557c950e82d088d255c Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:38:27 +0000 Subject: [PATCH 146/162] README: the re-arm refusal is the rc path, not an unreachable guard The section called version_next_dev's refusal unreachable by reading decide's sense of 'bare' (not -dev) into version_next_dev's regex (^X.Y.Z$). An rc lives between the two: row 6 admits a labeled rc transition as a shippable ceremony, the bump step gates only on ceremony=yes, and its VER is the tree's version verbatim -- so an rc release tags, notes, publishes, then refuses here. State that path and its remedy (version.sh L78-L82: an rc's next version is a human decision), keep the guard reading for the -dev/garbage half that really is unreachable, and close the same conflation in row 6. Also: version_write runs npm pkg set + a lockfile-only install, not npm version (which would tag); and drop 'unarmed main' so the section uses release.yml's one sense of armed. Reported by claude-bot-andresmgsl on #315. --- README.md | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5da3564..2560c51 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ block *is* the spec, and the table is contract-tested offline by | 3 | version bare, unchanged, already released | green `NOTICE`, no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. | | 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh](lib/decide.sh#L70-L74)). | | 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. | -| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. | +| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. **Read *bare* as decide reads it** — anything not `-dev` ([lib/decide.sh](lib/decide.sh#L108-L110)) — so an rc transition is a shippable ceremony here too, and the release lands but the re-arm stops for you to pick the next version ([The re-arm refused](#the-re-arm-refused-releaseyml)). | The green rows are the point as much as the red ones: the machinery must be safe to work on, so every legitimate non-ceremony is a green `NOTICE` no-op, @@ -503,22 +503,37 @@ delete and re-push the tag. The bump belongs to the merge door alone — the tag door deliberately does not rewrite main ([L303–L307](.github/workflows/release.yml#L303-L307)) — and it runs *after* the tag, the notes and the publish. So a refusal here leaves a -real release behind an unarmed main: the release exists and main still reads -the version it just shipped. That is the one failure in this catalog whose -remedy is a manual bump, not a re-run. +real release standing behind a main that never re-armed — the release exists, +and main is left *armed to impersonate* it, still reading the version it just +shipped ([L266](.github/workflows/release.yml#L266)). That is the one failure +in this catalog whose remedy is a manual bump, not a re-run. > version_next_dev: refusing '$ver' — expected bare X.Y.Z -[L86](lib/version.sh#L86): the version reaching the bump is not bare — -`-dev`, `-rc1`, or garbage. Not reachable through either door as they stand: -the step runs only on `ceremony=yes`, which rows 5–6 reach only on a -transition *to* bare, and the tag door never bumps. Treat it as the guard it -is — it fires if a decide change ever lets a non-bare version through. +[L86](lib/version.sh#L86): the version reaching the bump is not bare `X.Y.Z`. +Two senses of *bare* meet here, and the gap between them is the **rc release +path** — the one door an operator actually walks through. decide calls a +version bare when it is not `-dev` +([version_is_dev](lib/version.sh#L69-L76) matches that suffix and nothing +else), so row 6 admits a transition to `1.2.3-rc1`, and a labeled rc ceremony +is designed to ship ([lib/decide.sh](lib/decide.sh#L108-L110)). +`version_next_dev` means `^[0-9]+\.[0-9]+\.[0-9]+$`. An rc sits between the +two, and nothing filters it out on the way: the step's only gate is +`ceremony == 'yes'` and its `VER` is the tree's version verbatim. So an rc +ceremony tags, writes the notes, publishes — and *then* the re-arm refuses. +That is the machine correctly declining to guess rather than a bug: an rc's +next version "is a human decision, not arithmetic" +([L78–L82](lib/version.sh#L78-L82)), so make the decision and bump main by +hand to it. A `-dev` or garbage version reaching this line is the same +refusal's other half, and that half really is unreachable as the doors stand — +rows 1–2 send `-dev` to a no-op, and the tag door never bumps. > version_write: npm is required for version-source: package-json -[L106](lib/version.sh#L106): `version_write` shells out to `npm version` on -the package-json backend, and the runner has no npm. The read path fails the +[L106](lib/version.sh#L106): the package-json backend needs npm to write — +`npm pkg set version=` plus a lockfile-only `npm install` +([L102–L112](lib/version.sh#L102-L112)), never `npm version`, which would tag +— and the runner has none. The read path fails the same way one step earlier (`node is required…`, above), so a run reaching *this* message got past the read — set up node/npm in the caller. @@ -528,9 +543,10 @@ same way one step earlier (`node is required…`, above), so a run reaching backend`, and unreachable for the same reason — `VERSION_SOURCE` was validated before either was called. Fix the caller. -In every case main is left armed to impersonate the release it just shipped: -bump `VERSION` (or the `package.json` version field) to `X.Y.(Z+1)-dev` by -hand and push. Note that a *push* refusal is not one of these — branch +In every case the remedy has the same shape — bump `VERSION` (or the +`package.json` version field) by hand and push: `X.Y.(Z+1)-dev` where the +shipped version was bare, and where it was an rc, whatever you have decided +comes next. Note that a *push* refusal is not one of these — branch protection is expected, and the step opens the bump PR itself rather than failing ([L293–L301](.github/workflows/release.yml#L293-L301)). From 05e977814f8c73ca66d33ff4ecf95bb914603b87 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:40:41 +0000 Subject: [PATCH 147/162] README: point the two new anchors at the lines that carry the claim version_is_dev's range started one line in, past the signature comment that states the -dev-only rule; and the npm range stopped at L112, before the npm pkg set / npm install --package-lock-only lines it was cited for. L68-L76 and L114-L115. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2560c51..2664ba4 100644 --- a/README.md +++ b/README.md @@ -514,7 +514,7 @@ in this catalog whose remedy is a manual bump, not a re-run. Two senses of *bare* meet here, and the gap between them is the **rc release path** — the one door an operator actually walks through. decide calls a version bare when it is not `-dev` -([version_is_dev](lib/version.sh#L69-L76) matches that suffix and nothing +([version_is_dev](lib/version.sh#L68-L76) matches that suffix and nothing else), so row 6 admits a transition to `1.2.3-rc1`, and a labeled rc ceremony is designed to ship ([lib/decide.sh](lib/decide.sh#L108-L110)). `version_next_dev` means `^[0-9]+\.[0-9]+\.[0-9]+$`. An rc sits between the @@ -532,7 +532,7 @@ rows 1–2 send `-dev` to a no-op, and the tag door never bumps. [L106](lib/version.sh#L106): the package-json backend needs npm to write — `npm pkg set version=` plus a lockfile-only `npm install` -([L102–L112](lib/version.sh#L102-L112)), never `npm version`, which would tag +([L114–L115](lib/version.sh#L114-L115)), never `npm version`, which would tag — and the runner has none. The read path fails the same way one step earlier (`node is required…`, above), so a run reaching *this* message got past the read — set up node/npm in the caller. From 6df42797d272e10aefacbe1c72b604e36d5caa74 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:42:23 +0000 Subject: [PATCH 148/162] README: reflow the npm entry and drop an overstated clause The rc path is how this refusal is reached, not 'the one door an operator actually walks through' -- most releases are bare and never see it. Also rewrap the npm paragraph, left ragged by the previous commit. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2664ba4..cc626a9 100644 --- a/README.md +++ b/README.md @@ -512,8 +512,8 @@ in this catalog whose remedy is a manual bump, not a re-run. [L86](lib/version.sh#L86): the version reaching the bump is not bare `X.Y.Z`. Two senses of *bare* meet here, and the gap between them is the **rc release -path** — the one door an operator actually walks through. decide calls a -version bare when it is not `-dev` +path** — the way this refusal is actually reached, and designed behaviour +rather than a decide bug. decide calls a version bare when it is not `-dev` ([version_is_dev](lib/version.sh#L68-L76) matches that suffix and nothing else), so row 6 admits a transition to `1.2.3-rc1`, and a labeled rc ceremony is designed to ship ([lib/decide.sh](lib/decide.sh#L108-L110)). @@ -533,9 +533,9 @@ rows 1–2 send `-dev` to a no-op, and the tag door never bumps. [L106](lib/version.sh#L106): the package-json backend needs npm to write — `npm pkg set version=` plus a lockfile-only `npm install` ([L114–L115](lib/version.sh#L114-L115)), never `npm version`, which would tag -— and the runner has none. The read path fails the -same way one step earlier (`node is required…`, above), so a run reaching -*this* message got past the read — set up node/npm in the caller. +— and the runner has none. The read path fails the same way one step earlier +(`node is required…`, above), so a run reaching *this* message got past the +read — set up node/npm in the caller. > version_write: unknown backend: $backend From ca44ee0f845178eb4f4a2726d544d4eea6f15c07 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:12:29 +0000 Subject: [PATCH 149/162] README: the overview's zero-artifact promise is the pre-publish one The re-arm section corrected last round said the bump runs after publish and its refusal leaves a release standing; the overview still promised an unconditional -dev re-arm and zero artifacts on any failed assert. Scope the guarantee to the asserts before the publish, name the rc exception where the reader meets it first, and stop calling a malformed version's refusal unreachable - only the -dev half is. --- README.md | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cc626a9..9ef5c1b 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,17 @@ way to certainty, tags the merge commit, publishes the GitHub release with the version's own changelog section as the body — the curated prose, never the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor, and [bin/changelog-section](bin/changelog-section) is -its command-line face) — and re-arms main by bumping to `X.Y.(Z+1)-dev`; the -version is the only re-arm left, the changelog needs none (#112). The -machine does the transcription because humans err silently and machines fail -loudly: **everything asserts its way to certainty and fails loudly, creating -nothing** — a wrong release is worse than a missing one, so every failed -assert leaves zero artifacts: no tag, no release, no bump. +its command-line face) — and, on the bare-`X.Y.Z` path, re-arms main by +bumping to `X.Y.(Z+1)-dev`; the version is the only re-arm left, the +changelog needs none (#112). An rc ships too, and its next version is a human +decision rather than arithmetic, so the re-arm stops for you to make it +([The re-arm refused](#the-re-arm-refused-releaseyml)). The machine does the +transcription because humans err silently and machines fail loudly: +**everything asserts its way to certainty and fails loudly, creating +nothing** — a wrong release is worse than a missing one, so every assert that +fails *before* the publish leaves zero artifacts: no tag, no release, no +bump. The re-arm is the one assert past that line, and its refusal is the +single failure in this file that leaves a real release behind. ## The two doors @@ -524,9 +529,12 @@ ceremony tags, writes the notes, publishes — and *then* the re-arm refuses. That is the machine correctly declining to guess rather than a bug: an rc's next version "is a human decision, not arithmetic" ([L78–L82](lib/version.sh#L78-L82)), so make the decision and bump main by -hand to it. A `-dev` or garbage version reaching this line is the same -refusal's other half, and that half really is unreachable as the doors stand — -rows 1–2 send `-dev` to a no-op, and the tag door never bumps. +hand to it. A `-dev` version reaching this line is the same refusal's other +half, and *that* half is unreachable as the doors stand — rows 1–2 send `-dev` +to a no-op, and the tag door never bumps. A malformed version is not: nothing +upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only +that a version is present and non-empty), so `banana` rides row 6 exactly as +an rc does, and the same manual bump is the remedy. > version_write: npm is required for version-source: package-json From a663c631bf9050019d81bb3442298fd313a541a1 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:44:02 +0000 Subject: [PATCH 150/162] README: two tag-door asserts, the artifact hook past the tag, doctrine is mirrored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex's round-4 blockers, both reproduced against the tree: - the opening introduced machinery and doctrine together as 'never copied', which the doctrine paragraph then contradicts by design — the .ceremony/ mirror is a copy, kept honest by a guard rather than by absence. The clause now says of each half what is true of it. - the tag door has two refusing asserts, not one: tag/tree identity (release.yml L328-L339) and a publishable version section (L340-L352, changelog_section_problem), the second already quoted in this page's own troubleshooting catalog. claude's N1, taken: the zero-artifact boundary is the tag, not the publish — the consumer's artifact hook runs between them and its non-zero exit aborts with a tag standing. The re-arm remains the single failure that leaves a real release behind. Per triage's steer, one line pointing the rc half of the re-arm refusal at the 0.7.0 window (#317); the rc recovery prose is not widened. Refs #311 --- README.md | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9ef5c1b..14ff4f3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ The heavy-duty family's **governance repo**: the machinery every repo in the family runs, and the doctrine every agent in the family reads. Implemented -once here, tested once here, consumed everywhere else — never copied. +once here, tested once here, consumed everywhere else — the machinery never +copied at all, the doctrine only as a mirror a guard keeps byte-identical to +the pin. Two kinds of thing live in this tree, and they are consumed in two different ways because they have two different runtimes. @@ -118,10 +120,15 @@ decision rather than arithmetic, so the re-arm stops for you to make it ([The re-arm refused](#the-re-arm-refused-releaseyml)). The machine does the transcription because humans err silently and machines fail loudly: **everything asserts its way to certainty and fails loudly, creating -nothing** — a wrong release is worse than a missing one, so every assert that -fails *before* the publish leaves zero artifacts: no tag, no release, no -bump. The re-arm is the one assert past that line, and its refusal is the -single failure in this file that leaves a real release behind. +nothing** — a wrong release is worse than a missing one, so every assert in +this file fires *before its door creates anything*, and one that fails leaves +zero artifacts of the run's own: no tag it made, no release, no bump. Only two +steps run past the tag. The consumer's +[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and +the publish, so its non-zero exit aborts with a tag standing and no release — +a state the [nothing-exists assert](#the-merge-door-refused-releaseyml) names, +and recovers by the tag door. The re-arm runs after the publish, and its +refusal is the single failure in this file that leaves a real release behind. ## The two doors @@ -137,8 +144,12 @@ single failure in this file that leaves a real release behind. — **no `v` prefix**, box's 0.6.0 set the scheme ([release.yml](.github/workflows/release.yml#L303-L371)) — publishes the same way. The tag is the operator's explicit act, so there is no decide - and no label check; the one assert is that **the tag names the tree's own - version**, and a mismatch refuses, creating nothing. No `-dev` bump either + and no label check — what is left is two asserts: **the tag names the + tree's own version** + ([L328–L339](.github/workflows/release.yml#L328-L339)) and **the tagged + tree carries a publishable `## X.Y.Z` section** + ([L340–L352](.github/workflows/release.yml#L340-L352)); either failing + refuses, creating nothing. No `-dev` bump either — the fallback does not rewrite main (cast's precedent). Use it when the merge path is red, for backfills, and for the [first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4). @@ -534,7 +545,10 @@ half, and *that* half is unreachable as the doors stand — rows 1–2 send `-de to a no-op, and the tag door never bumps. A malformed version is not: nothing upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only that a version is present and non-empty), so `banana` rides row 6 exactly as -an rc does, and the same manual bump is the remedy. +an rc does, and the same manual bump is the remedy. The rc half has a shelf +life: the 0.7.0 window makes rc cuts native and their re-arm deterministic +(#317), after which no rc reaches this refusal — the malformed half still +does. > version_write: npm is required for version-source: package-json From 1410c01caf12f10deebc0c15a743dbba1753c6af Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:57:43 +0000 Subject: [PATCH 151/162] README: the forward pointer cites #317, never a version number Triage's D6 (#311), added mid-round: the file's single forward-looking sentence is sanctioned, and bounded. Two corrections to what round 4 landed, both of them the bound rather than the claim -- the claim itself was re-measured by triage and holds. - it named 'the 0.7.0 window'. Which release carries that work is a scheduling fact owned by the epic and RELEASES.md, where release-init may fold an empty window into a later release or skip the version outright, so the number can move with no diff under this file while every guard stays green. The issue number does not move: #317 is the stable name of the work. - it was present indicative -- 'makes' -- one paragraph after banana rides row 6 today. It now reads as work that has not landed, on its own, without the reader chasing the link. Still one sentence, still only in this section, and it weakens no present-tense claim around it: the -dev half stays unreachable, the malformed half stays live, and the manual bump stays the remedy today. Refs #311 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 14ff4f3..c41a2c9 100644 --- a/README.md +++ b/README.md @@ -545,10 +545,9 @@ half, and *that* half is unreachable as the doors stand — rows 1–2 send `-de to a no-op, and the tag door never bumps. A malformed version is not: nothing upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only that a version is present and non-empty), so `banana` rides row 6 exactly as -an rc does, and the same manual bump is the remedy. The rc half has a shelf -life: the 0.7.0 window makes rc cuts native and their re-arm deterministic -(#317), after which no rc reaches this refusal — the malformed half still -does. +an rc does, and the same manual bump is the remedy. One note on work that has +not landed: #317 would make rc cuts native and their re-arm deterministic, and +if it lands only the malformed half still reaches this refusal. > version_write: npm is required for version-source: package-json From 263bb74652a14660d38000413c423c50cee5da79 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:29:12 +0000 Subject: [PATCH 152/162] README: three steps run past the tag, and the publish is one of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview's fail-loudly paragraph said "Only two steps run past the tag" and attributed the tag-standing/no-release state to the artifact hook alone. Past `tag the merge commit` (release.yml:224) come three steps: the artifact hook (:236), `publish the release` (:246) and the -dev re-arm (:267). The publish is `gh release create --verify-tag`, so it can fail on the API call or the assets with the tag already standing — the same state, from a second cause. State the count as three and sort them by what a failure leaves behind: two fail before the release exists (hook, publish), both recovered by the tag door; the third is the re-arm, still the one failure in the file that leaves a real release behind. The nothing-exists recovery text names the publish among the causes of a tag with no release too; its remedy is unchanged. --- README.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c41a2c9..718eb69 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,16 @@ transcription because humans err silently and machines fail loudly: **everything asserts its way to certainty and fails loudly, creating nothing** — a wrong release is worse than a missing one, so every assert in this file fires *before its door creates anything*, and one that fails leaves -zero artifacts of the run's own: no tag it made, no release, no bump. Only two -steps run past the tag. The consumer's +zero artifacts of the run's own: no tag it made, no release, no bump. Three +steps run past the tag, and what a failure at each leaves behind is what +sorts them. Two fail before the release exists: the consumer's [artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and -the publish, so its non-zero exit aborts with a tag standing and no release — -a state the [nothing-exists assert](#the-merge-door-refused-releaseyml) names, -and recovers by the tag door. The re-arm runs after the publish, and its +the publish, so its non-zero exit aborts, and the publish itself +([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258)) +can fail on the API call or the assets. Either leaves the same state — a tag +standing and no release — which the +[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag +door recovers. The third is the re-arm, which runs after the publish, and its refusal is the single failure in this file that leaves a real release behind. ## The two doors @@ -485,9 +489,9 @@ clobber, and what catches a manual tag racing the merge. If the release truly exists, there is nothing to do: this red is the system declining to do the thing twice. If the tag exists but the release does not (a manual tag won the race, or -[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook)), recover by -the tag door: delete and re-push the tag, or `gh release create` by hand -from a fixed tree. +[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook), or the publish +step itself failing after the tag), recover by the tag door: delete and +re-push the tag, or `gh release create` by hand from a fixed tree. > direct push refused (branch protection?) — opening the bump PR instead From fb8f8282a9e7b317d4d028f8e8da50501a882d14 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:01:03 +0000 Subject: [PATCH 153/162] release: cut 0.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty-five fragments assembled into '## 0.6.0 — 2026-08-05'; VERSION to bare 0.6.0; the three CEREMONY_SELF_REF carriers stamped "0.6.0" in this one commit; the three docs/CONSUMERS.md availability markers cleared to name 0.6.0 — refs-not-closing (#218), the RELEASES.md mirror entry (#248) and the vendored-manifest completeness guarantee (#251). drills/0.6.0.md opens with the measurement that decides its shape: the doors-unchanged conditions do NOT all hold at this candidate, because lib/changelog.sh moved on the release path since the last rehearsed tag 0.4.0. A full disposable-repo rehearsal is owed and is in progress; the record is committed early and filled from the runs as they happen. Refs #249. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/labels-sweep.yml | 2 +- .github/workflows/labels.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 195 +++++++++++++++++++++++++++++ VERSION | 2 +- changelog.d/218.md | 4 - changelog.d/230.md | 5 - changelog.d/231.md | 6 - changelog.d/232.md | 5 - changelog.d/236.md | 10 -- changelog.d/237.md | 5 - changelog.d/238.md | 4 - changelog.d/241.md | 3 - changelog.d/242.md | 5 - changelog.d/247.md | 20 --- changelog.d/248.md | 3 - changelog.d/251.md | 14 --- changelog.d/252.md | 9 -- changelog.d/253.md | 3 - changelog.d/254.md | 16 --- changelog.d/257.md | 3 - changelog.d/258.md | 7 -- changelog.d/260.md | 6 - changelog.d/262.md | 20 --- changelog.d/264.md | 4 - changelog.d/266.md | 5 - changelog.d/267.md | 19 --- changelog.d/272.md | 6 - changelog.d/276.md | 10 -- changelog.d/280.md | 5 - changelog.d/281.md | 5 - changelog.d/282.md | 5 - changelog.d/284.md | 6 - changelog.d/288.md | 5 - changelog.d/292.md | 3 - changelog.d/293.md | 13 -- changelog.d/302.md | 6 - changelog.d/304.md | 11 -- changelog.d/307.md | 6 - changelog.d/311.md | 6 - docs/CONSUMERS.md | 21 ++-- drills/0.6.0.md | 68 ++++++++++ 42 files changed, 277 insertions(+), 278 deletions(-) delete mode 100644 changelog.d/218.md delete mode 100644 changelog.d/230.md delete mode 100644 changelog.d/231.md delete mode 100644 changelog.d/232.md delete mode 100644 changelog.d/236.md delete mode 100644 changelog.d/237.md delete mode 100644 changelog.d/238.md delete mode 100644 changelog.d/241.md delete mode 100644 changelog.d/242.md delete mode 100644 changelog.d/247.md delete mode 100644 changelog.d/248.md delete mode 100644 changelog.d/251.md delete mode 100644 changelog.d/252.md delete mode 100644 changelog.d/253.md delete mode 100644 changelog.d/254.md delete mode 100644 changelog.d/257.md delete mode 100644 changelog.d/258.md delete mode 100644 changelog.d/260.md delete mode 100644 changelog.d/262.md delete mode 100644 changelog.d/264.md delete mode 100644 changelog.d/266.md delete mode 100644 changelog.d/267.md delete mode 100644 changelog.d/272.md delete mode 100644 changelog.d/276.md delete mode 100644 changelog.d/280.md delete mode 100644 changelog.d/281.md delete mode 100644 changelog.d/282.md delete mode 100644 changelog.d/284.md delete mode 100644 changelog.d/288.md delete mode 100644 changelog.d/292.md delete mode 100644 changelog.d/293.md delete mode 100644 changelog.d/302.md delete mode 100644 changelog.d/304.md delete mode 100644 changelog.d/307.md delete mode 100644 changelog.d/311.md create mode 100644 drills/0.6.0.md diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml index ffa2d61..707b871 100644 --- a/.github/workflows/labels-sweep.yml +++ b/.github/workflows/labels-sweep.yml @@ -49,7 +49,7 @@ on: env: # A called workflow arrives without its repository. Keep this literal pin # aligned with the ceremony release consumed by callers (issue #9 D3). - CEREMONY_SELF_REF: "0.5.0" + CEREMONY_SELF_REF: "0.6.0" jobs: reconcile: diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 46234d1..f787729 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -48,7 +48,7 @@ on: env: # A called workflow arrives without its repository. Keep this literal pin # aligned with the ceremony release consumed by callers (issue #9 D3). - CEREMONY_SELF_REF: "0.5.0" + CEREMONY_SELF_REF: "0.6.0" jobs: scope: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62e8928..4941d7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ env: # `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the # shared logic arrives as script files via checkout, not as inner `uses:` # references. - CEREMONY_SELF_REF: "0.5.0" + CEREMONY_SELF_REF: "0.6.0" VERSION_SOURCE: ${{ inputs.version-source }} jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 491b733..6f9675b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,201 @@ Entries arrive as fragments — one `changelog.d/.md` per PR, never an edit to this file — and the release PR assembles them into the next section here (`bin/changelog-assemble`, #112). +## 0.6.0 — 2026-08-05 + +### Added + +- The issue-flow sweep's `claimed`-branch ruling pre-read is pinned: an + unassigned claim under `needs-ruling` must draw its board diagnostic and + its ruling nudge in one sweep, so a read that drifts below the diagnostic + reds instead of silently costing the escalation 7 days (#284, #307). +- The issue-flow sweep now flags a collision the board never declared: two + open, unblocked issues whose titles name one deliverable draw a comment + naming the newer's owed `Blocked by` edge. Keys normalize, so + `actions/x` and `x` are one deliverable (#288). +- The sweep now flags an unblocked non-member during a standing release + window, naming the window's invariant. `claimed` counts, PR in flight or + not. The gate is read from the release issue's own `Blocked by` + declarations, and an emptied gate leaves it dormant (#292). +- Both flags are advisory: comments only, no label write and no state + change, deduped against each family's last word on the thread so a + standing state re-sweeps silently (#293). +- The fragment guard now requires each entry to end with its issue + citation: one `(#N)` group — local, `repo#N` or `owner/repo#N` + references separated by `, ` — then the final `.` and nothing after it + (#262). +- The refusal distinguishes an entry carrying no reference at all from one + whose reference is present but not terminal, and names the shape to + write in both (#262). +- The 300-character bound still outranks the citation across the whole + fragment, and the outranked problem stays out of the message it lost + to: one fragment, one diagnosis, wherever in the file it sits (#262). +- BUILDER.md now describes a fix round that rides a draft: the draft phase + stays the builder's, ready-for-review is the builder's own act, and where a + draft suppressed the checks green is proven at the flip (#258). +- REVIEWER.md now reads a draft carrying `state:addressing` as a fix round in + progress rather than abandonment (#258). +- A `post-merge` item with no comment for 7 days now draws one nudge from the + issue sweep: the wake evidence is owed. A starving criterion used to be + found only when someone happened to run the right read (#254). +- Label churn does not reset that clock, and neither does an assignment: on + `post-merge` an assignee is an invalid composition, not activity, and it + must not buy the item another 7 days of silence (#254). +- The nudge names the triage actor from `triage-actors=`, not the human + reviewer: `post-merge` is triage's completion queue, so the starved wake + condition is triage's to answer (#254). +- It links the item and parses nothing from the body — which criterion + starved is prose, and the machine never judges prose (#254). +- Like the ruling nudge it carries no idempotency marker on purpose: the + comment is itself activity, so the rule self-rate-limits to one nudge per 7 + quiet days. Comment-only — no path here writes a label (#254). +- Release epics now announce release initialization when their declared dependency gates clear (#253). +- The issue sweep now echoes an issue's parsed `Blocked by` set as a comment + whenever that set changes, so a readable-but-wrong declaration is visible in + one sweep instead of days later, when a human happens to run the parser by + hand (#252). +- The echo's marker carries the parsed set itself: an unchanged parse never + re-posts on a 15-minute cron, and a changed one always speaks. Comment-only + — no path here writes a label (#252). +- CI now refuses a root `*.md` declared in neither `docs/VENDORED.txt` nor the + guard's short exemption list, so a new doctrine file can no longer reach a + tag undeclared and stay invisible to every consumer's `docs-sync` (#251). +- The same guard reads the manifest the other way: every entry must resolve to + a regular, non-empty, tracked file — no symlink, no directory, no `../` + escape (#251). +- Document the optional, operator-ruled release-epic flow for governed repositories. (#248). +- Guard documentation availability markers against missing issue citations + and release candidates that already ship the cited work (#238). +- The label and issue-flow sweeps now comment once per episode when + `attention` targets a pull request or an unassigned issue, without + retargeting the demand or changing labels or assignees (#232). +- Pull requests that promise `Refs #N` now fail a read-only, body-edit-aware + guard if GitHub would close N through a keyword or sidebar link (#218). + +### Changed + +- `README.md` is rewritten whole from the current tree: the front page names + the governance repo ceremony now is, routes to `docs/CONSUMERS.md`, + `AGENTS.md`, `LABELS.md` and `RELEASES.md` rather than restating them, and + keeps the operator's release runbook as its core, re-measured (#311). +- Standing release windows are dependency DAGs: every mint is placed in the window or behind it, and only current sources are `ready` (#292). +- TRIAGE.md now requires unconditional collision-edge chains when open issues + carry the same deliverable, keeping the ready queue concurrently claimable + (#288). +- TRIAGE.md now states its rules with bare record cites: the label-race and + lifted-hold incident narratives leave the normative text while their + operational rules remain complete (#282). +- `BUILDER.md` states its rules and cites their record bare: the incident + narratives, the links into issue comments and the cross-repo issue cites + leave the normative text, which no rule leaves with them (#281). +- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule, + retain at most one sentence of why, cite the local record bare, and leave the + incident narrative in that record (#280). +- BUILDER.md's green ruled term now says which entry to read before it says + what an entry means: a check's word at a head is its newest entry by start + time, and a cancelled entry is not that word while the same check carries a + non-cancelled one at that head (#276). +- A check whose every entry at the head is cancelled is unchanged — nothing + survived to be its word, so it never reported and is not green — and the + collapse mirrors `checks_state`'s carve-out rather than adding a class + (#276). +- BUILDER.md's step 1 now rules the checkless head: no checks configured is + nothing to wait for, and the request goes out straight away — stated once, + in the ruled-term paragraph, with the draft-round restatement removed + (#272). +- `README.md` and `RELEASES.md` derive `scope:docs`, and the + `changelog-assembled`, `docs-sync` and `runner-isolated` actions and tests + derive `scope:guards`; all five were mapped nowhere. The docs block matched + a literal `README`, which this tree does not carry (#267). +- `lib/read.sh` and `lib/ruling.sh` derive `scope:labels` beside + `scope:release-flow`. Both reconcilers share them, and a mixed file wears + both labels rather than `lib/**` being re-carved into a row per file (#267). +- TRIAGE.md now tells every epic author to put its progress checklist under + the literal `## Task list` heading, because any other heading is silently + invisible to the completion sweep (#266). +- TRIAGE.md now scopes the no-assignee board bug to flagging an unassigned + issue, while still directing triage to repair ownership instead (#264). +- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced + rather than as house style, beside the 300-character bound it now sits + next to (#262). +- Four fragments in flight gained a terminal citation; published sections + are untouched, so no shipped prose is re-opened (#262). +- BUILDER.md's green ruled term now names its field: greenness is read from + each check's `conclusion`, never its `status`, and *stale* means a check + of a superseded head — not a same-head node whose `status` lags its own + conclusion (#260). +- Consumer guidance: re-vendor tooling reads the pin's `docs/VENDORED.txt`, + never a hardcoded list, so a new doctrine file propagates at the next + ordinary pin bump with zero list edits (#251). +- Define the doors-unchanged drill record and an executable release-path list, + so a release may reuse live evidence only when its door bytes are unchanged + since the last rehearsed tag (#237). + +### Fixed + +- A roster edit no longer reds the whole suite: the labels-reconcile + state-machine fixtures name their own panel instead of binding + `.github/labels.conf` by slot (#304). +- Shrinking `panel=` to three had left that binding's third slot unbound, and + `set -u` aborted the file before its first assertion — 217 assertions + became 0, on `main` and on every branch cut from it (#304). +- The one case still reading the shipped roster asserts a property, not a + size: it parses, and each member is recused from its own panel. Any + `panel=` of one or more members leaves `test/run.sh` green (#304). +- `lib/attention.sh` locates as label machinery beside its two shelf-mates — + `[scope:release-flow]` alone was a wrong answer of the class #267 measured + — and the map learns the sweep workflow pair, the shared-lib tests, and + seven enumerated test/guard surfaces (#302). +- Claiming a `needs-ruling` issue no longer buys its escalation another 7 + quiet days: the issue-side ruling clock reads comments alone — an + assignment is the claim clock's fact — and LABELS.md now names what each + surface's clock reads (#284). +- `scope:release-flow` no longer rides every pull request: `changelog.d/**` + is out of its path map. Doctrine makes every behavior change write a + fragment, so the glob labelled 20 of the last 20 PRs while 3 touched a + release surface. `CHANGELOG.md` stays, as only the release PR edits it + (#267). +- The issue-flow reconciler and its test now derive `scope:labels`, the scope + that already names the taxonomy they reconcile (#267). +- Abort issue-flow reconciliation when the board read fails instead of reporting a complete pass over an empty or partial result (#257). +- The issue sweep no longer derives label writes from a read that failed. An + HTTP 504 whose body is GitHub's JSON error object passed every guard and + emptied the label set, so a healthy epic was written `needs-triage` and the + pass reported success (#247). +- A failed comments read no longer reclaims a live claim. Swallowed, it dated + the issue by `created_at` and unassigned the builder under a comment + asserting 48 hours of silence about an issue commented on seconds earlier + (#247). +- A failed comments read no longer reads as "no marker", which re-posted the + comment the marker exists to suppress (#247). +- Every read inside the per-issue subshell is checked explicitly, on its + status and on its payload shape; the issue is left exactly as it is and the + sweep continues. A partial pass names its skipped issues after + `reconciled.` (#247). +- A per-issue pass is now atomic: its writes and its log lines commit only + once the pass completes. A skip could previously land after an earlier + mutation, reporting an issue as untouched when a label had already been + written or removed (#247). +- The issue-flow sweep now reads an issue's deliverable as the `Refs` PR that + merged last, not the one numbered highest — merge order is not number order, + and the old rule spent the transition marker on the wrong PR (#242). +- Preserve active claims when an open local pull request links them with `Refs #N`. (#241). +- `blocker:unrequested` no longer fires while a head's checks are pending or + red: the review round forbids requesting there, so the one blocker that + demanded an act flagged builders for complying. Pending is CI's move, red is + `blocker:ci-red`'s (#236). +- `blocker:unrequested` now waits for the round to settle — the head and the + newest verdict must have stood for `RECONCILE_UNREQUESTED_GRACE` (default + 300s) — so a sweep landing between a push and its re-request no longer flags + a round in motion (#236). +- LABELS.md no longer claims nothing in `actions/` clears or reads + `attention`: the reconciler has done both since the derived `claimed` → + `post-merge` transition shipped. The amended text keeps the hand-set rule + and admits the one clear and the diagnostic read (#231). +- Triage now puts `attention` on the assigned issue that owns a claim, never + on its pull request, and treats an unassigned issue as a board bug rather + than a demand (#230). + ## 0.5.0 — 2026-08-03 ### Added diff --git a/VERSION b/VERSION index 53978e5..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.1-dev +0.6.0 diff --git a/changelog.d/218.md b/changelog.d/218.md deleted file mode 100644 index cdbdc46..0000000 --- a/changelog.d/218.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- Pull requests that promise `Refs #N` now fail a read-only, body-edit-aware - guard if GitHub would close N through a keyword or sidebar link (#218). diff --git a/changelog.d/230.md b/changelog.d/230.md deleted file mode 100644 index 159ce2b..0000000 --- a/changelog.d/230.md +++ /dev/null @@ -1,5 +0,0 @@ -### Fixed - -- Triage now puts `attention` on the assigned issue that owns a claim, never - on its pull request, and treats an unassigned issue as a board bug rather - than a demand (#230). diff --git a/changelog.d/231.md b/changelog.d/231.md deleted file mode 100644 index df32aeb..0000000 --- a/changelog.d/231.md +++ /dev/null @@ -1,6 +0,0 @@ -### Fixed - -- LABELS.md no longer claims nothing in `actions/` clears or reads - `attention`: the reconciler has done both since the derived `claimed` → - `post-merge` transition shipped. The amended text keeps the hand-set rule - and admits the one clear and the diagnostic read (#231). diff --git a/changelog.d/232.md b/changelog.d/232.md deleted file mode 100644 index 6785136..0000000 --- a/changelog.d/232.md +++ /dev/null @@ -1,5 +0,0 @@ -### Added - -- The label and issue-flow sweeps now comment once per episode when - `attention` targets a pull request or an unassigned issue, without - retargeting the demand or changing labels or assignees (#232). diff --git a/changelog.d/236.md b/changelog.d/236.md deleted file mode 100644 index 2d76876..0000000 --- a/changelog.d/236.md +++ /dev/null @@ -1,10 +0,0 @@ -### Fixed - -- `blocker:unrequested` no longer fires while a head's checks are pending or - red: the review round forbids requesting there, so the one blocker that - demanded an act flagged builders for complying. Pending is CI's move, red is - `blocker:ci-red`'s (#236). -- `blocker:unrequested` now waits for the round to settle — the head and the - newest verdict must have stood for `RECONCILE_UNREQUESTED_GRACE` (default - 300s) — so a sweep landing between a push and its re-request no longer flags - a round in motion (#236). diff --git a/changelog.d/237.md b/changelog.d/237.md deleted file mode 100644 index 59a0d4f..0000000 --- a/changelog.d/237.md +++ /dev/null @@ -1,5 +0,0 @@ -### Changed - -- Define the doors-unchanged drill record and an executable release-path list, - so a release may reuse live evidence only when its door bytes are unchanged - since the last rehearsed tag (#237). diff --git a/changelog.d/238.md b/changelog.d/238.md deleted file mode 100644 index 6805c0b..0000000 --- a/changelog.d/238.md +++ /dev/null @@ -1,4 +0,0 @@ -### Added - -- Guard documentation availability markers against missing issue citations - and release candidates that already ship the cited work (#238). diff --git a/changelog.d/241.md b/changelog.d/241.md deleted file mode 100644 index a095226..0000000 --- a/changelog.d/241.md +++ /dev/null @@ -1,3 +0,0 @@ -### Fixed - -- Preserve active claims when an open local pull request links them with `Refs #N`. (#241). diff --git a/changelog.d/242.md b/changelog.d/242.md deleted file mode 100644 index 73e8d48..0000000 --- a/changelog.d/242.md +++ /dev/null @@ -1,5 +0,0 @@ -### Fixed - -- The issue-flow sweep now reads an issue's deliverable as the `Refs` PR that - merged last, not the one numbered highest — merge order is not number order, - and the old rule spent the transition marker on the wrong PR (#242). diff --git a/changelog.d/247.md b/changelog.d/247.md deleted file mode 100644 index a2243dd..0000000 --- a/changelog.d/247.md +++ /dev/null @@ -1,20 +0,0 @@ -### Fixed - -- The issue sweep no longer derives label writes from a read that failed. An - HTTP 504 whose body is GitHub's JSON error object passed every guard and - emptied the label set, so a healthy epic was written `needs-triage` and the - pass reported success (#247). -- A failed comments read no longer reclaims a live claim. Swallowed, it dated - the issue by `created_at` and unassigned the builder under a comment - asserting 48 hours of silence about an issue commented on seconds earlier - (#247). -- A failed comments read no longer reads as "no marker", which re-posted the - comment the marker exists to suppress (#247). -- Every read inside the per-issue subshell is checked explicitly, on its - status and on its payload shape; the issue is left exactly as it is and the - sweep continues. A partial pass names its skipped issues after - `reconciled.` (#247). -- A per-issue pass is now atomic: its writes and its log lines commit only - once the pass completes. A skip could previously land after an earlier - mutation, reporting an issue as untouched when a label had already been - written or removed (#247). diff --git a/changelog.d/248.md b/changelog.d/248.md deleted file mode 100644 index baa2137..0000000 --- a/changelog.d/248.md +++ /dev/null @@ -1,3 +0,0 @@ -### Added - -- Document the optional, operator-ruled release-epic flow for governed repositories. (#248). diff --git a/changelog.d/251.md b/changelog.d/251.md deleted file mode 100644 index 7853b80..0000000 --- a/changelog.d/251.md +++ /dev/null @@ -1,14 +0,0 @@ -### Added - -- CI now refuses a root `*.md` declared in neither `docs/VENDORED.txt` nor the - guard's short exemption list, so a new doctrine file can no longer reach a - tag undeclared and stay invisible to every consumer's `docs-sync` (#251). -- The same guard reads the manifest the other way: every entry must resolve to - a regular, non-empty, tracked file — no symlink, no directory, no `../` - escape (#251). - -### Changed - -- Consumer guidance: re-vendor tooling reads the pin's `docs/VENDORED.txt`, - never a hardcoded list, so a new doctrine file propagates at the next - ordinary pin bump with zero list edits (#251). diff --git a/changelog.d/252.md b/changelog.d/252.md deleted file mode 100644 index 3b47994..0000000 --- a/changelog.d/252.md +++ /dev/null @@ -1,9 +0,0 @@ -### Added - -- The issue sweep now echoes an issue's parsed `Blocked by` set as a comment - whenever that set changes, so a readable-but-wrong declaration is visible in - one sweep instead of days later, when a human happens to run the parser by - hand (#252). -- The echo's marker carries the parsed set itself: an unchanged parse never - re-posts on a 15-minute cron, and a changed one always speaks. Comment-only - — no path here writes a label (#252). diff --git a/changelog.d/253.md b/changelog.d/253.md deleted file mode 100644 index 25cb78d..0000000 --- a/changelog.d/253.md +++ /dev/null @@ -1,3 +0,0 @@ -### Added - -- Release epics now announce release initialization when their declared dependency gates clear (#253). diff --git a/changelog.d/254.md b/changelog.d/254.md deleted file mode 100644 index 92ca2d5..0000000 --- a/changelog.d/254.md +++ /dev/null @@ -1,16 +0,0 @@ -### Added - -- A `post-merge` item with no comment for 7 days now draws one nudge from the - issue sweep: the wake evidence is owed. A starving criterion used to be - found only when someone happened to run the right read (#254). -- Label churn does not reset that clock, and neither does an assignment: on - `post-merge` an assignee is an invalid composition, not activity, and it - must not buy the item another 7 days of silence (#254). -- The nudge names the triage actor from `triage-actors=`, not the human - reviewer: `post-merge` is triage's completion queue, so the starved wake - condition is triage's to answer (#254). -- It links the item and parses nothing from the body — which criterion - starved is prose, and the machine never judges prose (#254). -- Like the ruling nudge it carries no idempotency marker on purpose: the - comment is itself activity, so the rule self-rate-limits to one nudge per 7 - quiet days. Comment-only — no path here writes a label (#254). diff --git a/changelog.d/257.md b/changelog.d/257.md deleted file mode 100644 index b49ecfa..0000000 --- a/changelog.d/257.md +++ /dev/null @@ -1,3 +0,0 @@ -### Fixed - -- Abort issue-flow reconciliation when the board read fails instead of reporting a complete pass over an empty or partial result (#257). diff --git a/changelog.d/258.md b/changelog.d/258.md deleted file mode 100644 index b03d4a0..0000000 --- a/changelog.d/258.md +++ /dev/null @@ -1,7 +0,0 @@ -### Added - -- BUILDER.md now describes a fix round that rides a draft: the draft phase - stays the builder's, ready-for-review is the builder's own act, and where a - draft suppressed the checks green is proven at the flip (#258). -- REVIEWER.md now reads a draft carrying `state:addressing` as a fix round in - progress rather than abandonment (#258). diff --git a/changelog.d/260.md b/changelog.d/260.md deleted file mode 100644 index d32662f..0000000 --- a/changelog.d/260.md +++ /dev/null @@ -1,6 +0,0 @@ -### Changed - -- BUILDER.md's green ruled term now names its field: greenness is read from - each check's `conclusion`, never its `status`, and *stale* means a check - of a superseded head — not a same-head node whose `status` lags its own - conclusion (#260). diff --git a/changelog.d/262.md b/changelog.d/262.md deleted file mode 100644 index 89e90fa..0000000 --- a/changelog.d/262.md +++ /dev/null @@ -1,20 +0,0 @@ -### Added - -- The fragment guard now requires each entry to end with its issue - citation: one `(#N)` group — local, `repo#N` or `owner/repo#N` - references separated by `, ` — then the final `.` and nothing after it - (#262). -- The refusal distinguishes an entry carrying no reference at all from one - whose reference is present but not terminal, and names the shape to - write in both (#262). -- The 300-character bound still outranks the citation across the whole - fragment, and the outranked problem stays out of the message it lost - to: one fragment, one diagnosis, wherever in the file it sits (#262). - -### Changed - -- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced - rather than as house style, beside the 300-character bound it now sits - next to (#262). -- Four fragments in flight gained a terminal citation; published sections - are untouched, so no shipped prose is re-opened (#262). diff --git a/changelog.d/264.md b/changelog.d/264.md deleted file mode 100644 index fa70c7c..0000000 --- a/changelog.d/264.md +++ /dev/null @@ -1,4 +0,0 @@ -### Changed - -- TRIAGE.md now scopes the no-assignee board bug to flagging an unassigned - issue, while still directing triage to repair ownership instead (#264). diff --git a/changelog.d/266.md b/changelog.d/266.md deleted file mode 100644 index 9d6bd0b..0000000 --- a/changelog.d/266.md +++ /dev/null @@ -1,5 +0,0 @@ -### Changed - -- TRIAGE.md now tells every epic author to put its progress checklist under - the literal `## Task list` heading, because any other heading is silently - invisible to the completion sweep (#266). diff --git a/changelog.d/267.md b/changelog.d/267.md deleted file mode 100644 index 2fb77a0..0000000 --- a/changelog.d/267.md +++ /dev/null @@ -1,19 +0,0 @@ -### Fixed - -- `scope:release-flow` no longer rides every pull request: `changelog.d/**` - is out of its path map. Doctrine makes every behavior change write a - fragment, so the glob labelled 20 of the last 20 PRs while 3 touched a - release surface. `CHANGELOG.md` stays, as only the release PR edits it - (#267). -- The issue-flow reconciler and its test now derive `scope:labels`, the scope - that already names the taxonomy they reconcile (#267). - -### Changed - -- `README.md` and `RELEASES.md` derive `scope:docs`, and the - `changelog-assembled`, `docs-sync` and `runner-isolated` actions and tests - derive `scope:guards`; all five were mapped nowhere. The docs block matched - a literal `README`, which this tree does not carry (#267). -- `lib/read.sh` and `lib/ruling.sh` derive `scope:labels` beside - `scope:release-flow`. Both reconcilers share them, and a mixed file wears - both labels rather than `lib/**` being re-carved into a row per file (#267). diff --git a/changelog.d/272.md b/changelog.d/272.md deleted file mode 100644 index adf20be..0000000 --- a/changelog.d/272.md +++ /dev/null @@ -1,6 +0,0 @@ -### Changed - -- BUILDER.md's step 1 now rules the checkless head: no checks configured is - nothing to wait for, and the request goes out straight away — stated once, - in the ruled-term paragraph, with the draft-round restatement removed - (#272). diff --git a/changelog.d/276.md b/changelog.d/276.md deleted file mode 100644 index 7d2dcdc..0000000 --- a/changelog.d/276.md +++ /dev/null @@ -1,10 +0,0 @@ -### Changed - -- BUILDER.md's green ruled term now says which entry to read before it says - what an entry means: a check's word at a head is its newest entry by start - time, and a cancelled entry is not that word while the same check carries a - non-cancelled one at that head (#276). -- A check whose every entry at the head is cancelled is unchanged — nothing - survived to be its word, so it never reported and is not green — and the - collapse mirrors `checks_state`'s carve-out rather than adding a class - (#276). diff --git a/changelog.d/280.md b/changelog.d/280.md deleted file mode 100644 index a3c0ca2..0000000 --- a/changelog.d/280.md +++ /dev/null @@ -1,5 +0,0 @@ -### Changed - -- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule, - retain at most one sentence of why, cite the local record bare, and leave the - incident narrative in that record (#280). diff --git a/changelog.d/281.md b/changelog.d/281.md deleted file mode 100644 index f53fca6..0000000 --- a/changelog.d/281.md +++ /dev/null @@ -1,5 +0,0 @@ -### Changed - -- `BUILDER.md` states its rules and cites their record bare: the incident - narratives, the links into issue comments and the cross-repo issue cites - leave the normative text, which no rule leaves with them (#281). diff --git a/changelog.d/282.md b/changelog.d/282.md deleted file mode 100644 index 90d412d..0000000 --- a/changelog.d/282.md +++ /dev/null @@ -1,5 +0,0 @@ -### Changed - -- TRIAGE.md now states its rules with bare record cites: the label-race and - lifted-hold incident narratives leave the normative text while their - operational rules remain complete (#282). diff --git a/changelog.d/284.md b/changelog.d/284.md deleted file mode 100644 index ad98894..0000000 --- a/changelog.d/284.md +++ /dev/null @@ -1,6 +0,0 @@ -### Fixed - -- Claiming a `needs-ruling` issue no longer buys its escalation another 7 - quiet days: the issue-side ruling clock reads comments alone — an - assignment is the claim clock's fact — and LABELS.md now names what each - surface's clock reads (#284). diff --git a/changelog.d/288.md b/changelog.d/288.md deleted file mode 100644 index 245e524..0000000 --- a/changelog.d/288.md +++ /dev/null @@ -1,5 +0,0 @@ -### Changed - -- TRIAGE.md now requires unconditional collision-edge chains when open issues - carry the same deliverable, keeping the ready queue concurrently claimable - (#288). diff --git a/changelog.d/292.md b/changelog.d/292.md deleted file mode 100644 index 0fbafcb..0000000 --- a/changelog.d/292.md +++ /dev/null @@ -1,3 +0,0 @@ -### Changed - -- Standing release windows are dependency DAGs: every mint is placed in the window or behind it, and only current sources are `ready` (#292). diff --git a/changelog.d/293.md b/changelog.d/293.md deleted file mode 100644 index 40a6d63..0000000 --- a/changelog.d/293.md +++ /dev/null @@ -1,13 +0,0 @@ -### Added - -- The issue-flow sweep now flags a collision the board never declared: two - open, unblocked issues whose titles name one deliverable draw a comment - naming the newer's owed `Blocked by` edge. Keys normalize, so - `actions/x` and `x` are one deliverable (#288). -- The sweep now flags an unblocked non-member during a standing release - window, naming the window's invariant. `claimed` counts, PR in flight or - not. The gate is read from the release issue's own `Blocked by` - declarations, and an emptied gate leaves it dormant (#292). -- Both flags are advisory: comments only, no label write and no state - change, deduped against each family's last word on the thread so a - standing state re-sweeps silently (#293). diff --git a/changelog.d/302.md b/changelog.d/302.md deleted file mode 100644 index c0a759a..0000000 --- a/changelog.d/302.md +++ /dev/null @@ -1,6 +0,0 @@ -### Fixed - -- `lib/attention.sh` locates as label machinery beside its two shelf-mates — - `[scope:release-flow]` alone was a wrong answer of the class #267 measured - — and the map learns the sweep workflow pair, the shared-lib tests, and - seven enumerated test/guard surfaces (#302). diff --git a/changelog.d/304.md b/changelog.d/304.md deleted file mode 100644 index 0b57d16..0000000 --- a/changelog.d/304.md +++ /dev/null @@ -1,11 +0,0 @@ -### Fixed - -- A roster edit no longer reds the whole suite: the labels-reconcile - state-machine fixtures name their own panel instead of binding - `.github/labels.conf` by slot (#304). -- Shrinking `panel=` to three had left that binding's third slot unbound, and - `set -u` aborted the file before its first assertion — 217 assertions - became 0, on `main` and on every branch cut from it (#304). -- The one case still reading the shipped roster asserts a property, not a - size: it parses, and each member is recused from its own panel. Any - `panel=` of one or more members leaves `test/run.sh` green (#304). diff --git a/changelog.d/307.md b/changelog.d/307.md deleted file mode 100644 index 5c59de4..0000000 --- a/changelog.d/307.md +++ /dev/null @@ -1,6 +0,0 @@ -### Added - -- The issue-flow sweep's `claimed`-branch ruling pre-read is pinned: an - unassigned claim under `needs-ruling` must draw its board diagnostic and - its ruling nudge in one sweep, so a read that drifts below the diagnostic - reds instead of silently costing the escalation 7 days (#284, #307). diff --git a/changelog.d/311.md b/changelog.d/311.md deleted file mode 100644 index 4c8dd26..0000000 --- a/changelog.d/311.md +++ /dev/null @@ -1,6 +0,0 @@ -### Changed - -- `README.md` is rewritten whole from the current tree: the front page names - the governance repo ceremony now is, routes to `docs/CONSUMERS.md`, - `AGENTS.md`, `LABELS.md` and `RELEASES.md` rather than restating them, and - keeps the operator's release runbook as its core, re-measured (#311). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index bd68963..bbb43e3 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -155,9 +155,9 @@ the machinery at all: - uses: heavy-duty/ceremony/actions/refs-not-closing@ ``` - `refs-not-closing` is **unreleased** (#218) until the first tag carrying it. - Adopt this caller with that ordinary pin bump; never point only this file - at a moving or newer ref. + `refs-not-closing` is available at `0.6.0` and later (#218). Adopt this + caller with that ordinary pin bump; never point only this file at a + moving or newer ref. 7. **Labels automation** (optional but recommended): the two callers from [Labels automation](#labels-automation) — the event-facing labels caller and the sweep caller (#209) — plus `.github/labels.conf` @@ -593,10 +593,10 @@ marking the directory machine-managed. `actions/docs-sync` owns the copy: mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale pin goes red instead of quietly governing. -`RELEASES.md` joins that mirror with the first tag carrying ceremony#248. -It is **unreleased** (#248) until that tag exists: consumers add -`.ceremony/RELEASES.md` only with the ordinary pin bump and re-sync, never by -copying it ahead of their pinned doctrine set. +`RELEASES.md` joins that mirror with the first tag carrying ceremony#248, +and is available at `0.6.0` and later: consumers add `.ceremony/RELEASES.md` +only with the ordinary pin bump and re-sync, never by copying it ahead of +their pinned doctrine set. ### Read the manifest, never a copy of it @@ -625,10 +625,9 @@ What makes reading the manifest *sufficient* — rather than merely better than a copy — is that ceremony's CI now refuses a root doctrine file that is declared in neither the manifest nor a short in-script exemption list (`.github/scripts/vendored-check.sh`), so the manifest at a tag is the -complete set as of that tag. That guarantee is **unreleased** (#251) until -the first tag carrying it exists; the manifest is worth reading at every -earlier pin regardless, since it is what `actions/docs-sync` has always -mirrored. +complete set as of that tag. That guarantee holds at `0.6.0` and later +(#251); the manifest is worth reading at every earlier pin regardless, since +it is what `actions/docs-sync` has always mirrored. The consumer's ci.yml gains the guard alongside the others: diff --git a/drills/0.6.0.md b/drills/0.6.0.md new file mode 100644 index 0000000..753c02c --- /dev/null +++ b/drills/0.6.0.md @@ -0,0 +1,68 @@ +# 0.6.0 — drill record + +**IN PROGRESS — this record is being written as the rehearsal runs.** It is +committed early so the candidate carries evidence at every head rather than +appearing at the end; the release PR is a draft until it is complete, and no +probe row below is written before its run exists. + +Run 2026-08-05 by `cndgrr` against the 0.6.0 release PR (Refs #249), +candidate branch `build/249-release-0-6-0`. + +## Scope ruling — a full rehearsal is owed, and doors-unchanged is refused + +This record's shape was measured, not chosen. `drills/README.md` allows the +doors-unchanged shape only when all three of its conditions hold at the +candidate head; the first one does not. + +The baseline is the last **rehearsed** tag, never the previous tag: +`drills/0.4.1.md` and `drills/0.5.0.md` are both doors-unchanged records, so +the anchor is **`0.4.0`**, whose record is a full disposable-repo rehearsal, +whose release is published, and after which `main` was re-armed to +`0.4.1-dev` (`84bb1a4`). Condition 3 holds. + +The release path is exactly the output of `.github/scripts/release-path.sh` +at this head — `.github/workflows/release.yml`, `bin/`, `lib/version.sh`, +`lib/decide.sh`, `lib/facts.sh`, `lib/changelog.sh`. Condition 2 holds. + +Condition 1 fails. Measured at this candidate: + +```console +$ git diff 0.4.0..HEAD -- $(.github/scripts/release-path.sh) + .github/workflows/release.yml | 2 +- + lib/changelog.sh | 83 ++++++++++++++++++++++++++++++++++++++--- +``` + +`release.yml`'s two lines are the `CEREMONY_SELF_REF` pin, which the +condition exempts. **`lib/changelog.sh` is not exempt and is not empty**: it +carries `72fa3e0` (the terminal issue-citation rule joining the fragment +guard, #262) and `75a5b68` (one fragment, one diagnosis, #262). That file is +on the release path because the merge door sources it to assemble and read +the release section — this is a door byte, not a neighbouring library, and +the last-rehearsed anchor exists precisely so an accumulated change like +this forces a new rehearsal rather than chaining a third doors-unchanged +assertion off the second. + +So this release owes the disposable-repo rehearsal, and this record is it. + +## Where + +_Pending — the scratch repo's full `owner/name` and its disposal state as +observed by this record's author are written here when the rehearsal runs._ + +## Candidate-ref deviation + +_Pending — the fork ref carrying the candidate tree and the canonical +candidate SHA its `CEREMONY_SELF_REF` carriers are rewritten to._ + +## Probes + +_Pending — one row per probe, written from its run, never before it._ + +| # | probe | run | result | +|---|---|---|---| +| 1 | merge-door ceremony | — | — | +| 2 | mislabeled ordinary PR | — | — | +| 3 | bare-version PR without `release` | — | — | +| 4 | re-run completed ceremony | — | — | +| 5 | manual matching tag | — | — | +| 6 | mismatched tag | — | — | From 24b69aea8ef8c5184311f912a2d2aaa12991ff99 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:05:20 +0000 Subject: [PATCH 154/162] drill 0.6.0: the scratch repo, the candidate ref, and probe 2 --- drills/0.6.0.md | 63 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/drills/0.6.0.md b/drills/0.6.0.md index 753c02c..1b4de41 100644 --- a/drills/0.6.0.md +++ b/drills/0.6.0.md @@ -46,23 +46,74 @@ So this release owes the disposable-repo rehearsal, and this record is it. ## Where -_Pending — the scratch repo's full `owner/name` and its disposal state as -observed by this record's author are written here when the rehearsal runs._ +Disposable **private** repo `cndgrr/ceremony-drill-0.6.0`, created +2026-08-05T00:02:58Z. It carries the `docs/CONSUMERS.md` release caller +verbatim (`version-source: file`) over a fragment-mode fixture armed at +`0.6.0-dev`: a preamble-only `CHANGELOG.md`, `changelog.d/README.md` plus +one fragment, and a non-blank `drills/0.6.0.md`. The `release` label was +created there before the first ceremony PR, per the guide's prerequisite. + +_Disposal state is written below when the rehearsal ends, as this record's +author observes it — never as an intention._ ## Candidate-ref deviation -_Pending — the fork ref carrying the candidate tree and the canonical -candidate SHA its `CEREMONY_SELF_REF` carriers are rewritten to._ +The pure consumer path cannot resolve this candidate's +`CEREMONY_SELF_REF: "0.6.0"`: that tag is the one this release has not +created yet. No `0.6.0` branch was created on `heavy-duty/ceremony`. + +The scratch caller instead pins `cndgrr/ceremony/.github/workflows/release.yml@drill/0.6.0`. +That fork ref's parent is the canonical candidate SHA +`fb8f8282a9e7b317d4d028f8e8da50501a882d14`, and its one additional commit +(`775b4d1f6485ebdde924979ac2dce536643c6071`) rewrites all three +`CEREMONY_SELF_REF` carriers — `release.yml`, `labels.yml`, +`labels-sweep.yml` — to that same SHA. All runtime machinery in every probe +below was therefore fetched from the 0.6.0 candidate tree. + +Commits pushed to the candidate after `fb8f828` are this record only; the +release path (`.github/scripts/release-path.sh`) is byte-identical at the +canonical SHA and at the final head. ## Probes -_Pending — one row per probe, written from its run, never before it._ +One row per probe, written from its run. Runs are in +`cndgrr/ceremony-drill-0.6.0`. | # | probe | run | result | |---|---|---|---| | 1 | merge-door ceremony | — | — | -| 2 | mislabeled ordinary PR | — | — | +| 2 | mislabeled ordinary PR | 30991634654 | ✅ green NOTICE no-op; no tag, no release | | 3 | bare-version PR without `release` | — | — | | 4 | re-run completed ceremony | — | — | | 5 | manual matching tag | — | — | | 6 | mismatched tag | — | — | + +### Probe 2 — a mislabeled ordinary PR + +PR #2 (`probe2b-mislabeled`) added one changelog fragment and touched no +version. The `release` label was applied through +`POST /repos/{owner}/{repo}/issues/2/labels` and confirmed present before +the merge. The merge run decided row 1 of the table and published nothing: + +```text + VER: 0.6.0-dev + BASE_VER: 0.6.0-dev + RELEASED: + LABELED: +NOTICE: the version '0.6.0-dev' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish. +ceremony=no +``` + +`RELEASED` and `LABELED` are empty on purpose — the `-dev` rows never +consult them, which is precisely why the label alone cannot ship anything. +Notes, the nothing-exists assert, tag, artifact hook, publish and bump all +skipped; `GET /tags` and `GET /releases` were both empty afterwards. + +An earlier merge (PR #1, run 30991571096) was intended as this probe but +landed **unlabeled**: `gh pr edit --add-label` failed against this repo's +projects-classic GraphQL surface, and the merge went ahead before the +failure was read. That run is a green no-op too, but it is not evidence for +this probe — an unlabeled ordinary merge proves less than a labeled one — +so the probe was re-run as PR #2 with the label applied through the REST +endpoint and verified before merging. Recorded here because the run exists +in the repo's history and a reader will find it. From 6f30989e2af7866bcfa2c4f5503d8c299095e06e Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:11:46 +0000 Subject: [PATCH 155/162] drill 0.6.0: probes 1, 3 and 4 recorded from their runs --- drills/0.6.0.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/drills/0.6.0.md b/drills/0.6.0.md index 1b4de41..2415dc2 100644 --- a/drills/0.6.0.md +++ b/drills/0.6.0.md @@ -81,13 +81,86 @@ One row per probe, written from its run. Runs are in | # | probe | run | result | |---|---|---|---| -| 1 | merge-door ceremony | — | — | +| 1 | merge-door ceremony | 30992108742 (attempt 1) | ✅ exactly one `0.6.0` release; tag equals the merge commit; main re-armed to `0.6.1-dev` | | 2 | mislabeled ordinary PR | 30991634654 | ✅ green NOTICE no-op; no tag, no release | -| 3 | bare-version PR without `release` | — | — | -| 4 | re-run completed ceremony | — | — | +| 3 | bare-version PR without `release` | 30991832001 | ✅ refused at decide; no tag, no release | +| 4 | re-run completed ceremony | 30992108742 (attempt 2) | ✅ refused at the nothing-exists assert; the release count stayed one | | 5 | manual matching tag | — | — | | 6 | mismatched tag | — | — | +### Probe 1 — the merge-door ceremony + +PR #4 (`probe1-ceremony`) bumped `0.6.0-dev` to bare `0.6.0` and stamped +`## 0.6.0 — 2026-08-05`, assembled from the three fixture fragments by the +candidate's own `bin/changelog-assemble` and committed with the deletions +in one commit. The `release` label was applied and confirmed before the +merge. Facts and verdict: + +```text + VER: 0.6.0 + BASE_VER: 0.6.0-dev + RELEASED: + LABELED: yes +ceremony=yes +``` + +Observed after the run: + +- **Exactly one** release: `GET /releases` returned `0.6.0` alone, not a + draft, not a pre-release, zero assets (no artifact hook in the fixture — + the hook step skipped). +- `GET /tags` returned `0.6.0` alone, pointing at + `64d02539f4a20286afc08b9997f0f8a7d1dbfccd`, which is PR #4's merge commit + — the tag names the tree that was reviewed. +- The release body was byte-for-byte the assembled section's bullets: + + ```text + - A second ordinary fragment, written by probe 2 of the 0.6.0 drill (#249). + - An ordinary behavior change, landing under the release label (#249). + - Fragment mode is exercised by the ceremony 0.6.0 drill (#249). + ``` + +- Main re-armed itself: commit `2d0e19a` ("bump main to 0.6.1-dev — a dev + install must not impersonate 0.6.0"), pushed by the job's own token. Main + reads `0.6.1-dev` and `changelog.d/` holds only `README.md`. +- **The anti-recursion property held.** Neither the tag create nor the bump + push started a workflow run — the run list after the ceremony ends at + 30992108742. That is what makes the merge door the release's only chance + to publish, and it is the reason probe 4 below is the door's own guard + rather than a second run's. + +### Probe 4 — a re-run of the completed ceremony + +Re-running 30992108742 as attempt 2 re-decided `ceremony=yes` — the facts +at that merge commit have not changed — and then died at the assert: + +```text +tag '0.6.0' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing. +``` + +Tag, publish and bump all skipped. `GET /releases` still returned exactly +one `0.6.0`. The refusal is loud (the job is red) and creates nothing, which +is the required shape: the assert is what covers a manual tag racing the +merge, not only an operator's stray re-run. + +### Probe 3 — a bare-version PR without the `release` label + +PR #3 (`probe3-bare`) bumped `VERSION` to bare `0.6.0` and carried no label; +the label list was read as empty before merging. The merge run refused at +decide, row 5 of the table: + +```text + VER: 0.6.0 + BASE_VER: 0.6.0-dev + RELEASED: + LABELED: no +the version transitioned ('0.6.0-dev' -> '0.6.0') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing. +``` + +Notes, the assert, tag, hook, publish and bump all skipped; tags and +releases were both still empty afterwards. The merge was then undone and +main re-armed to `0.6.0-dev` before the ceremony probe ran (see Setup). + ### Probe 2 — a mislabeled ordinary PR PR #2 (`probe2b-mislabeled`) added one changelog fragment and touched no From f832334abe510fd2ca334ee5902afbf9fec40e0e Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:14:55 +0000 Subject: [PATCH 156/162] drill 0.6.0: probes 5 and 6, the setup corrections, and the disposal state --- drills/0.6.0.md | 100 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/drills/0.6.0.md b/drills/0.6.0.md index 2415dc2..3ba6704 100644 --- a/drills/0.6.0.md +++ b/drills/0.6.0.md @@ -1,12 +1,9 @@ # 0.6.0 — drill record -**IN PROGRESS — this record is being written as the rehearsal runs.** It is -committed early so the candidate carries evidence at every head rather than -appearing at the end; the release PR is a draft until it is complete, and no -probe row below is written before its run exists. - Run 2026-08-05 by `cndgrr` against the 0.6.0 release PR (Refs #249), -candidate branch `build/249-release-0-6-0`. +candidate branch `build/249-release-0-6-0`, canonical candidate SHA +`fb8f8282a9e7b317d4d028f8e8da50501a882d14`. All six probes ran; every row in +the table below was written from its own run. ## Scope ruling — a full rehearsal is owed, and doors-unchanged is refused @@ -53,8 +50,13 @@ verbatim (`version-source: file`) over a fragment-mode fixture armed at one fragment, and a non-blank `drills/0.6.0.md`. The `release` label was created there before the first ceremony PR, per the guide's prerequisite. -_Disposal state is written below when the rehearsal ends, as this record's -author observes it — never as an intention._ +**Disposal, as this record's author observed it**: the repository is +**archived** — `PATCH /repos/cndgrr/ceremony-drill-0.6.0` with +`archived: true` returned `true`, and a fresh read afterwards reported +`archived=true private=true`. It is **pending the operator's delete**, which +this builder cannot perform: `delete_repo` is absent from fleet tokens by +doctrine (#135). No delete was attempted and none is claimed. Cleanup gates +nothing — not this PR's ready-for-review, not the panel, not the merge. ## Candidate-ref deviation @@ -85,8 +87,40 @@ One row per probe, written from its run. Runs are in | 2 | mislabeled ordinary PR | 30991634654 | ✅ green NOTICE no-op; no tag, no release | | 3 | bare-version PR without `release` | 30991832001 | ✅ refused at decide; no tag, no release | | 4 | re-run completed ceremony | 30992108742 (attempt 2) | ✅ refused at the nothing-exists assert; the release count stayed one | -| 5 | manual matching tag | — | — | -| 6 | mismatched tag | — | — | +| 5 | manual matching tag | 30992258952 | ✅ `0.6.1` published from its own changelog section; main untouched | +| 6 | mismatched tag | 30992310031 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterwards | + +### Probe 5 — a manual tag matching its tree + +Branch `probe5-tag` carried `VERSION` at `0.6.1` and a +`## 0.6.1 — 2026-08-05` section; tag `0.6.1` was pushed at that commit +(`dfd0cfeaca772cf45bcb63a1a639829185510c60`) with a personal token, so it +fired the door — the anti-recursion property probe 1 relies on is exactly +what makes a hand-pushed tag the only way to reach this door. The +`release-on-merge` job skipped and `release-on-tag` ran: the version assert +passed, notes were extracted, the release published. + +The branch, not main, carried the tagged tree on purpose — the tag door +takes no bump step, and pointing it at a side branch proves that without a +bare version ever sitting on main. Observed afterwards: `0.6.1` published +with exactly its own section's bullet, and main still reading `0.6.1-dev`, +untouched by the publish. Two releases now exist, `0.6.0` and `0.6.1`, +neither a draft, neither carrying assets. + +### Probe 6 — a mismatched tag + +Tag `9.9.9` was pushed at the same `0.6.1` commit. The door refused at its +first assert, before notes and before publication: + +```text +tag '9.9.9' does not match the tree's version '0.6.1' — creating nothing. +``` + +Notes, the artifact hook and publish all skipped. `GET /releases` still +returned exactly `0.6.1` and `0.6.0`. The `9.9.9` ref was deleted afterwards +(`DELETE /git/refs/tags/9.9.9`); `GET /git/refs/tags` then listed `0.6.0` +and `0.6.1` only. The probe tag was the operator's artefact, never the +workflow's — the door created nothing, which is the whole assertion. ### Probe 1 — the merge-door ceremony @@ -190,3 +224,49 @@ this probe — an unlabeled ordinary merge proves less than a labeled one — so the probe was re-run as PR #2 with the label applied through the REST endpoint and verified before merging. Recorded here because the run exists in the repo's history and a reader will find it. + +## Setup, and the runs that are not probes + +The armed fixture was committed before the caller, so the first door run had +a real parent version to inspect: run **30962040469** is that green baseline +no-op. The probes then ran in the order 2, 3, 1, 4, 5, 6 — the refusals +first, against an armed tree, so the ceremony itself ran last against a +fixture the refusals had already proven intact. + +Three non-probe runs are on the board and are accounted for here rather than +left for a reader to guess at: + +- **30991571096** (green) — PR #1, the unlabeled first attempt at probe 2, + described above. +- **30991892212** (green) — restoring `VERSION` to `0.6.0-dev` after probe + 3's refusal, so the ceremony probe met an armed tree. Row 2 of the table: + the version changed and still ends `-dev`. +- **30991958967** (red) — **a builder error, not a door finding.** An + uncommitted `VERSION` bump left over from staging the ceremony branch rode + along into a setup commit that was meant to touch only the fragments, and + pushed bare `0.6.0` straight to main. The door refused it exactly as it + refused probe 3, by the same row-5 path, and created nothing: tags and + releases were both still empty when the failure was read. Main was re-armed + to `0.6.0-dev` (green run **30992046247**) before the ceremony probe. It is + written down because a red run on a drill repo that the record does not + explain is indistinguishable from a door that failed. + +The fixture's three fragments were also rewritten mid-setup to carry +terminal issue citations. The candidate's own `bin/changelog-assemble` +refused them without one — `fragment 'changelog.d/1.md' has an entry with no +issue citation` — which is #262's rule, one of the two commits on +`lib/changelog.sh` that make this release owe a rehearsal at all. The +fixture had been written before that rule existed. The refusal is the guard +working; the correction is recorded because the fragments the ceremony +consumed are not the fragments the repo was created with. + +## What the rehearsal establishes + +Both doors ran live against the 0.6.0 candidate's own machinery. The merge +door published exactly one release from a labeled ceremony PR, tagged the +reviewed merge commit, and re-armed main itself; it refused a bare push +without a label, refused a re-run of its own completed ceremony, and stayed +a green no-op under a label carried by ordinary work. The tag door published +from a matching manual tag without touching main, and refused a mismatched +one before creating anything. Every refusal created nothing — no tag, no +release, on any of the four refusal paths. From 8c3a4d1dee2bdb5ac06a632a285bb65ab2615214 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Aug 2026 09:35:51 +0000 Subject: [PATCH 157/162] =?UTF-8?q?chore:=20bump=20main=20to=200.6.1-dev?= =?UTF-8?q?=20=E2=80=94=20a=20dev=20install=20must=20not=20impersonate=200?= =?UTF-8?q?.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index a918a2a..2feed2f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.6.1-dev From 290052953353c08cda3db488fd5b06e0b5e500fb Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Wed, 5 Aug 2026 11:58:28 +0000 Subject: [PATCH 158/162] test(no-runtime-gh): the workflow exemption names the issue that removes it (#205) An exemption without a work item is just a hole with a comment on it. Refs #198 --- test/no-runtime-gh.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/no-runtime-gh.test.sh b/test/no-runtime-gh.test.sh index f8e1a7c..f1a72f7 100755 --- a/test/no-runtime-gh.test.sh +++ b/test/no-runtime-gh.test.sh @@ -52,7 +52,7 @@ ALLOWED_FILE='lib/forge-github.sh' # be guessing, and the only way to finish measuring it is to dispatch a real # workflow run on the operator's repo. So it is named here with its reason and # its follow-up, which is what an exemption is for — an unnamed one is just a -# hole. Remove this entry when the port lands. +# hole. #205 owns the port; remove this entry when it lands. EXEMPT_WORKFLOWS='.github/workflows/labels.yml' # A file may opt out by declaring the client it speaks, which makes From 97e63acef0f1eb0097e92c8cc53c3ee7ce8f9959 Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Wed, 5 Aug 2026 12:09:05 +0000 Subject: [PATCH 159/162] fix(refs-not-closing): report and skip on a forge it cannot speak, rather than reddening every PR (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first head's `Refs guard` failed on this PR, correctly: spec 4's CEREMONY_FORGE_CLIENT=gh declaration made forge_preflight refuse by name on this forge. But that workflow runs on every pull request here, so the declaration as first written turns every future PR red until #199 lands — blocking the board for a gap that already has its own issue. Refusing and scheduling are different questions. This action must never produce a verdict from a graph it did not read, and it does not: on a forge it cannot speak it now says so by name, cites #199, states that no verdict was produced, and reaches the forge zero times. A preflight failure for any other reason stays fatal, and on a forge it CAN speak nothing changes. Also: five SC2016 findings in test/no-runtime-gh.test.sh. They were invisible locally because shellcheck-all.sh lints TRACKED files and the guard was still untracked when I ran it — a new file is exactly the case that check cannot see. Verified this time against CI's pinned shellcheck 0.10.0 with the file committed. test/run.sh: 28 test files, 0 failed, under CI's CEREMONY_REQUIRE_* env. shellcheck, actionlint, self-ref, marker and vendored guards all clean. Refs #198 --- actions/refs-not-closing/run.sh | 21 ++++++++++++++++++++- changelog.d/198.md | 4 ++++ test/no-runtime-gh.test.sh | 5 +++++ test/refs-not-closing.test.sh | 26 +++++++++++++++++++++----- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/actions/refs-not-closing/run.sh b/actions/refs-not-closing/run.sh index 68f345f..d3325c5 100755 --- a/actions/refs-not-closing/run.sh +++ b/actions/refs-not-closing/run.sh @@ -22,7 +22,26 @@ set -euo pipefail # shellcheck source=lib/forge.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh" export CEREMONY_FORGE_CLIENT=gh -forge_preflight || exit 1 +# The refusal and the SCHEDULING are two different questions, and #198's first +# head conflated them: preflight refused correctly on this forge and turned +# every PR's `Refs guard` red, which blocks the board rather than protecting +# it. A guard that cannot run here must not claim a verdict — but it also must +# not stand permanently red for a port that has its own issue. +# +# So: not-runnable-here is reported and skipped, loudly and by name, and only +# a preflight failure for any OTHER reason is fatal. The distinction is the +# forge, not the exit code — on a forge this action CAN speak, a preflight +# failure is still a hard refusal, which is the case the tests drive. +preflight_err="$(mktemp)" +trap 'rm -f "$preflight_err"' EXIT +if ! forge_preflight 2>"$preflight_err"; then + cat "$preflight_err" >&2 + if [ "$(forge_detect 2>/dev/null)" != github ]; then + printf '::notice::refs-not-closing: not run — this action is still gh-only (its gather is GraphQL, which this forge does not serve) and #199 ports it. No verdict was produced.\n' + exit 0 + fi + exit 1 +fi owner="${GITHUB_REPOSITORY%%/*}" name="${GITHUB_REPOSITORY#*/}" diff --git a/changelog.d/198.md b/changelog.d/198.md index 9aa9974..f4f6acb 100644 --- a/changelog.d/198.md +++ b/changelog.d/198.md @@ -32,6 +32,10 @@ - The post-merge nudge links the issue on the forge in play rather than a hard-coded `github.com` (#198). +- `actions/refs-not-closing` reports and skips on a forge it cannot speak, + naming the client and #199, instead of standing red on every PR. It reaches + the forge zero times, so no verdict is produced either way (#198). + - `.github/scripts/release-path.sh` names `lib/forge.sh`: #191 put the shim on the release doors' executable path here, so a doors-unchanged record that omitted it was measuring the wrong set (#198). diff --git a/test/no-runtime-gh.test.sh b/test/no-runtime-gh.test.sh index f1a72f7..c7bedb3 100755 --- a/test/no-runtime-gh.test.sh +++ b/test/no-runtime-gh.test.sh @@ -118,14 +118,17 @@ check "no runtime gh outside the github backend or a declared-client file" 0 "" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT +# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold printf '%s\n' '#!/usr/bin/env bash' 'gh api "repos/$REPO/issues/1"' >"$TMP/bad.sh" check "a reintroduced gh api read is seen" 0 "gh api" gh_calls "$TMP/bad.sh" +# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold printf '%s\n' '#!/usr/bin/env bash' 'run gh issue comment "$n" --body x' >"$TMP/bad2.sh" check "a reintroduced gh issue write is seen, staged or not" 0 "gh issue" \ gh_calls "$TMP/bad2.sh" # The exact shape the 0.6.0 merge reintroduced, indented inside a function. +# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold printf '%s\n' '#!/usr/bin/env bash' 'f() {' \ ' guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments"' '}' \ >"$TMP/bad3.sh" @@ -136,12 +139,14 @@ printf '%s\n' '#!/usr/bin/env bash' '# gh api used to live here (#188)' \ '# run gh issue comment — retired' >"$TMP/prose.sh" check "prose about gh is not a call site" 1 "" gh_calls "$TMP/prose.sh" +# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold printf '%s\n' '#!/usr/bin/env bash' 'forge_api "repos/$REPO/issues/1"' \ 'echo "the gh client speaks /api/v3"' >"$TMP/good.sh" check "the shim verb is not mistaken for a call site" 1 "" gh_calls "$TMP/good.sh" # Neighbouring identifiers must not read as the binary: `gh_calls`, `$gh`, # a path ending in /gh, and `regh api` are all not an invocation of gh. +# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold printf '%s\n' '#!/usr/bin/env bash' 'gh_calls() { :; }' 'regh api foo' \ 'echo "$gh api"' >"$TMP/lookalike.sh" check "lookalike identifiers are not call sites" 1 "" gh_calls "$TMP/lookalike.sh" diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh index a6b489d..bf24e73 100755 --- a/test/refs-not-closing.test.sh +++ b/test/refs-not-closing.test.sh @@ -100,6 +100,9 @@ mkdir -p "$TMP/bin" cat >"$TMP/bin/gh" <<'EOF' #!/usr/bin/env bash set -u +# Every call is recorded, so a probe can assert the gather did NOT run — a +# refusal that still reads is not a refusal (#198). +[ -z "${GH_CALL_LOG:-}" ] || printf '%s\n' "$*" >>"$GH_CALL_LOG" case "${FAKE_GH_MODE:-success}" in failure) echo "fake GraphQL read failed" >&2 @@ -138,18 +141,31 @@ action_boundary() { # Forgejo forge it must refuse by name, never produce a verdict from a graph # it did not read. #199 removes the declaration by making the gather REST. forgejo_boundary() { - env PATH="$TMP/bin:$PATH" FAKE_GH_MODE=success \ + env PATH="$TMP/bin:$PATH" FAKE_GH_MODE=success GH_CALL_LOG="$TMP/gh-calls" \ CEREMONY_FORGE=forgejo \ GITHUB_REPOSITORY="heavy-duty/ceremony" PR_NUMBER=268 \ GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \ bash "$ENTRYPOINT" } -check "on a forgejo forge the action refuses instead of verdicting" 1 \ +# The contract on a forge this action cannot speak: say so by name, produce +# NO verdict, and do not stand red. Red would be honest about the port and +# dishonest about the PR — it blocks every merge on this forge for a gap #199 +# owns, which is a worse failure than the one it reports (#198). +check "on a forgejo forge the action names the client mismatch" 0 \ "cannot speak it" forgejo_boundary -check "...and the refusal names the client it declared" 1 "'gh' client" \ - forgejo_boundary -check "...and names the client the forge actually needs" 1 "'rest' client" \ +check "...naming the client it declared" 0 "'gh' client" forgejo_boundary +check "...and the client the forge actually needs" 0 "'rest' client" forgejo_boundary +check "...says explicitly that it produced no verdict" 0 "No verdict was produced" \ forgejo_boundary +check "...points at the issue that ports it" 0 "#199" forgejo_boundary +# The teeth: it must not have READ anything. The stub counts its own calls, so +# a gather that ran despite the refusal is visible here. +forgejo_read_count() { + : >"$TMP/gh-calls" + forgejo_boundary >/dev/null 2>&1 + wc -l <"$TMP/gh-calls" +} +check "...and reached the forge zero times" 0 "0" forgejo_read_count check "action boundary fails when GraphQL read fails" 42 \ "fake GraphQL read failed" action_boundary failure From 06f05aebecdffef2f3e808c041722f5c6edeabcf Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Wed, 5 Aug 2026 12:16:53 +0000 Subject: [PATCH 160/162] fix(198): the workflow declares and refuses instead of being exempted by name (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in labels.yml would have ridden in free — and it let the merge ship a step that dies with `command not found` on every sweep on this forge, which #197's bar does not permit. The declaration mechanism already existed; a workflow simply could not reach it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same declaration actions/refs-not-closing carries, and the refusal that a script gets from forge_preflight is inline here because a workflow has no shell to call it from. The dispatch now warns by name, cites #205, and exits 0 rather than reddening every sweep for a known gap. So the guard needs no exemption list at all. It now requires the pair — declared AND refusing — and reports a declaration that carries no refusal, which is a permission slip for `command not found`. That predicate was wrong on its first write, and its mutation test caught it: `refuses_when_unavailable` matched the word `forge_preflight` inside labels.yml's own comment explaining that it has NO forge_preflight to call. A guard reading prose as evidence is the blind sweep again, in the guard written to forbid it. Comments are stripped now, as gh_calls already stripped them. Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the strip reds two cases. Blockers 2 and 3 were already fixed in 97e63ac, before either review landed. test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored and changelog-armed all clean, with every file tracked this time. Refs #198 --- .github/workflows/labels.yml | 17 ++++- .../issueflow-reconcile.sh | 7 +- changelog.d/198.md | 7 ++ test/issueflow-reconcile.test.sh | 12 ++++ test/no-runtime-gh.test.sh | 70 +++++++++++++------ 5 files changed, 90 insertions(+), 23 deletions(-) diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index f787729..31f859e 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -128,4 +128,19 @@ jobs: env: GH_TOKEN: ${{ github.token }} SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }} - run: gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no + # This step speaks gh and says so, the same declaration + # actions/refs-not-closing carries (#198 spec 4). A workflow has no + # shell to call forge_preflight from, so the refusal is inline + # below; #205 owns the REST port that removes both. + CEREMONY_FORGE_CLIENT: gh + run: | + # Never `command not found`. On a runner without gh the wake is + # genuinely lost, and that is worth a warning rather than a failed + # job: this trigger is the misconfiguration alarm for a CONSUMER's + # missing sweep caller, and reddening every sweep on a forge whose + # runner has no gh would drown that signal in a known gap (#205). + if ! command -v gh >/dev/null 2>&1; then + echo "::warning::labels: the sweep was NOT woken from this trigger — it dispatches with \`gh\`, which this runner does not carry. #205 ports it to REST. Scheduled and issue-event sweeps are unaffected; only this caller's event-driven wake is lost." + exit 0 + fi + gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index 4117544..cf7022a 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -949,7 +949,12 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in # how the wrong addressee comes back. if [ "$(ruling_nudge_decision "$NOW" "$evidence_age")" = NUDGE ]; then local quiet_days=$(((NOW - evidence_age) / 86400)) - run forge_issue_comment "$n" "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no comment for ${quiet_days} days: ${GITHUB_SERVER_URL:-}/$REPO/issues/$n + # Trailing slash stripped so a server URL that carries one does not + # render `//owner/repo` — `:-` first so an absent value is empty rather + # than fatal, `%/` second so a present one is normalized (#198). + local server="${GITHUB_SERVER_URL:-}" + server="${server%/}" + run forge_issue_comment "$n" "@${TRIAGE_ACTORS[0]} — this \`post-merge\` item has had no comment for ${quiet_days} days: ${server}/$REPO/issues/$n Its wake evidence is still owed. \`post-merge\` means the merge landed and triage owns completion — judge the remaining criteria against the evidence diff --git a/changelog.d/198.md b/changelog.d/198.md index f4f6acb..5a58f55 100644 --- a/changelog.d/198.md +++ b/changelog.d/198.md @@ -36,6 +36,13 @@ naming the client and #199, instead of standing red on every PR. It reaches the forge zero times, so no verdict is produced either way (#198). +- `.github/workflows/labels.yml`'s sweep dispatch declares the client it + speaks and refuses by name on a runner without it, instead of dying with + `command not found` on every sweep. #205 ports it to REST (#198). + +- The post-merge nudge strips a trailing slash from the server URL, so a forge + URL carrying one does not render `//owner/repo` (#198). + - `.github/scripts/release-path.sh` names `lib/forge.sh`: #191 put the shim on the release doors' executable path here, so a doors-unchanged record that omitted it was measuring the wrong set (#198). diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index 06ed19f..c115695 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -785,6 +785,18 @@ check "...addressed to the triage actor, never the human reviewer" 0 "" \ bash -c 'grep -qF "@triage-one" "$1" && ! grep -qF "@danmt" "$1"' _ "$TMP/posted-80" check "...with the issue link as the payload" 0 "" \ grep -qF 'https://github.com/owner/repo/issues/80' "$TMP/posted-80" +# The host comes from the environment, and a server URL that carries a +# trailing slash must not render `//owner/repo` (@codex-reviewer-andresmgsl, +# #198). Same forge, same issue, one character of difference in the input. +quiet_comment 80 $((8 * 86400)) +PROBE_SERVER_URL=https://forgejo.example.test/ issue_probe 80 post-merge 0 >/dev/null +check "a trailing slash on the server URL does not double the separator" 0 "" \ + grep -qF 'https://forgejo.example.test/owner/repo/issues/80' "$TMP/posted-80" +check "...and no doubled separator appears at all" 1 "" \ + grep -qF 'forgejo.example.test//owner' "$TMP/posted-80" +quiet_comment 80 $((8 * 86400)) +: >"$TMP/posted-80" +issue_probe 80 post-merge 0 >/dev/null check "...carrying the do-not-add-a-marker warning in the comment" 0 "" \ grep -qF 'Do not add a marker.' "$TMP/posted-80" # Asserted directly, not merely omitted: a marker would turn "once per 7 diff --git a/test/no-runtime-gh.test.sh b/test/no-runtime-gh.test.sh index c7bedb3..703efb0 100755 --- a/test/no-runtime-gh.test.sh +++ b/test/no-runtime-gh.test.sh @@ -36,30 +36,32 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # The backend that is ALLOWED to speak gh — it is the whole point of the file. ALLOWED_FILE='lib/forge-github.sh' -# The one exemption a FILE cannot declare for itself. A workflow has no shell -# to source lib/forge.sh from and no forge_preflight to refuse with, so the -# CEREMONY_FORGE_CLIENT escape hatch that covers actions/refs-not-closing is -# unavailable to it. `.github/workflows/labels.yml`'s trigger job dispatches -# the sweep caller with `gh workflow run`; the 0.6.0 merge introduced it -# (#209 upstream) and it is the eighth call site that merge brought in — the -# one every reviewer's `*.sh` grep missed, this one included, until this guard -# read the workflows too. -# -# It is NOT ported here, deliberately. Forgejo's dispatch route exists but -# does not answer like GitHub's: `GET /actions/workflows` 404s on this -# instance while `POST .../dispatches` returns 500 rather than a 4xx, which is -# the same mis-status class #192 is open about. Porting on that evidence would -# be guessing, and the only way to finish measuring it is to dispatch a real -# workflow run on the operator's repo. So it is named here with its reason and -# its follow-up, which is what an exemption is for — an unnamed one is just a -# hole. #205 owns the port; remove this entry when it lands. -EXEMPT_WORKFLOWS='.github/workflows/labels.yml' # A file may opt out by declaring the client it speaks, which makes # forge_preflight refuse by name on a forge that cannot serve it. Today that # is actions/refs-not-closing, whose only gather is GraphQL and which Forgejo # therefore cannot run at all (#199 ports it and drops the declaration). -declares_gh_client() { grep -qE '^[[:space:]]*(export[[:space:]]+)?CEREMONY_FORGE_CLIENT=gh\b' "$1"; } +# Both spellings, because both surfaces must be able to declare: `=` for a +# shell script, `:` for a workflow's env block. A filename exemption was the +# first shape here and @codex-reviewer-andresmgsl was right to reject it — +# it exempts the whole FILE, so any later gh call anywhere in that workflow +# would ride in free, and it lets a declaration exist without a refusal. +declares_gh_client() { grep -qE '^[[:space:]]*(export[[:space:]]+)?CEREMONY_FORGE_CLIENT[=:][[:space:]]*gh[[:space:]]*$' "$1"; } + +# Declaring is half of it. #197's bar is "declared AND refuses loudly", so a +# declaring file must also carry the refusal — forge_preflight for a script, +# an inline availability check for a workflow that has no shell to call it +# from. A declaration without one is a permission slip for `command not found`. +# Comments stripped first, for the same reason gh_calls strips them and with +# the same lesson learned the hard way: the first version of this predicate +# was satisfied by the word `forge_preflight` inside labels.yml's own comment +# EXPLAINING that it has no forge_preflight to call. A guard that reads prose +# as evidence is the blind sweep again, and it passed its own mutation test +# because of it. +refuses_when_unavailable() { + sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1" \ + | grep -qE 'forge_preflight|command -v gh' +} # A runtime invocation, not the word. `gh` must be at a command position and # followed by a gh subcommand — and comment lines are stripped first, because @@ -92,9 +94,12 @@ offenders() { local rel abs while IFS= read -r rel; do [ "$rel" = "$ALLOWED_FILE" ] && continue - [ "$rel" = "$EXEMPT_WORKFLOWS" ] && continue abs="$ROOT/$rel" - declares_gh_client "$abs" && continue + if declares_gh_client "$abs"; then + refuses_when_unavailable "$abs" && continue + printf '%s: declares CEREMONY_FORGE_CLIENT=gh but carries no refusal\n' "$rel" + continue + fi gh_calls "$abs" | sed "s|^|$rel:|" done < <(scanned_files) } @@ -154,6 +159,29 @@ check "lookalike identifiers are not call sites" 1 "" gh_calls "$TMP/lookalike.s printf '%s\n' '#!/usr/bin/env bash' 'export CEREMONY_FORGE_CLIENT=gh' \ 'gh api graphql -f query=x' >"$TMP/declared.sh" check "a declared-client file opts out" 0 "" declares_gh_client "$TMP/declared.sh" +# A workflow declares in YAML, not shell — both spellings must count, or the +# only surface that cannot call forge_preflight is also the only one that +# cannot declare. +printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \ + ' CEREMONY_FORGE_CLIENT: gh' ' run: gh workflow run x' \ + >"$TMP/declared.yml" +check "...and so does a workflow declaring it in YAML" 0 "" \ + declares_gh_client "$TMP/declared.yml" +# Declared is not enough: #197's bar is declared AND refuses loudly. +check "a declaration without a refusal is not enough" 1 "" \ + refuses_when_unavailable "$TMP/declared.yml" +printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \ + ' CEREMONY_FORGE_CLIENT: gh' \ + ' run: |' \ + ' command -v gh >/dev/null || { echo "::warning::not woken"; exit 0; }' \ + ' gh workflow run x' >"$TMP/declared-refusing.yml" +check "...and a declaration WITH one is" 0 "" \ + refuses_when_unavailable "$TMP/declared-refusing.yml" +# The shipped workflow is the real customer for that pair. +check "labels.yml declares the client it speaks" 0 "" \ + declares_gh_client "$ROOT/.github/workflows/labels.yml" +check "...and refuses by name rather than dying on command not found" 0 "" \ + refuses_when_unavailable "$ROOT/.github/workflows/labels.yml" check "...and an undeclared one does not" 1 "" declares_gh_client "$TMP/bad.sh" # A mention of the variable in prose is not a declaration. printf '%s\n' '#!/usr/bin/env bash' '# CEREMONY_FORGE_CLIENT=gh would opt out' \ From 728102a3ba76c0db5cac6c3ca224bc39fbd09037 Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Wed, 5 Aug 2026 12:22:20 +0000 Subject: [PATCH 161/162] fix(issueflow): issue_payload_valid refuses an empty payload on jq 1.6 too (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CI / test` was red at 97e63ac on a case that passes on this box: upstream's own "an empty payload is refused". The cause is not the test. `jq -e` disagrees with itself across versions on EMPTY input. jq 1.7 exits 4 — no valid result was ever produced. jq 1.6 exits 0. Measured both ways today against the same filter. This instance's runner image (ghcr.io/catthehacker/ubuntu:act-22.04) carries jq 1.6. So on this forge the guard #247 D3 added specifically to refuse an unreadable read was ACCEPTING one: an empty body read as a valid issue payload, and the sweep would have reconciled an issue from a payload it never received. The test is upstream's, it is correct, and it passes on a GitHub runner — which is why upstream never saw this. The fix does not depend on jq's exit code for an input it never receives: the payload is read, emptiness is decided in the shell, and jq judges only a non-empty body. Verified under BOTH jq versions, not just the one on this box: empty refused and healthy accepted on 1.6 and 1.7, and the whole suite green under jq 1.6 — 28 test files, 0 failed — as well as under 1.7. Refs #198 --- .../issueflow-reconcile/issueflow-reconcile.sh | 15 ++++++++++++++- changelog.d/198.md | 5 +++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index cf7022a..fbe3d16 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -609,9 +609,22 @@ issue_payload_valid() { # $1 = the requested issue; payload on stdin # one catches an HTTP 200 whose body is `null`, which exits 0 and empties it # just the same. `.number` is checked against the issue asked for, so a # payload about some other issue can never be reconciled as this one. + # THE EMPTINESS CHECK IS NOT REDUNDANT, and it is not stylistic. `jq -e` + # disagrees with itself across versions on empty input: jq 1.7 exits 4 (no + # valid result was ever produced), jq 1.6 exits **0**. This instance's + # runner image (ghcr.io/catthehacker/ubuntu:act-22.04) carries jq 1.6, so + # without this line an EMPTY payload reads as a valid issue payload here — + # the precise thing D3 added this guard to refuse — and the sweep would + # reconcile an issue from a body it never received. Measured both ways, + # 2026-08-05: `jq -e '' /dev/null 2>&1 + ' <<<"$payload" >/dev/null 2>&1 } skipped_tail() { # $1 = skip count, $2 = the issue numbers → the D6 line, or nothing diff --git a/changelog.d/198.md b/changelog.d/198.md index 5a58f55..e9f5f22 100644 --- a/changelog.d/198.md +++ b/changelog.d/198.md @@ -40,6 +40,11 @@ speaks and refuses by name on a runner without it, instead of dying with `command not found` on every sweep. #205 ports it to REST (#198). +- `issue_payload_valid` refuses an empty payload on jq 1.6 as well as 1.7. + `jq -e` exits 4 on empty input under 1.7 and **0** under 1.6, and this + instance's runner carries 1.6 — so the guard #247 D3 added to refuse an + unreadable read was accepting one here (#198). + - The post-merge nudge strips a trailing slash from the server URL, so a forge URL carrying one does not render `//owner/repo` (#198). From adf907c963d817909f0ed9e6d4d84857662f5668 Mon Sep 17 00:00:00 2001 From: cluade-reviewer-andresmgsl Date: Wed, 5 Aug 2026 12:30:28 +0000 Subject: [PATCH 162/162] fix(198): the action fails closed, the caller decides scheduling, the guard decides the forge (#198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @codex-reviewer-andresmgsl's second review, both points taken. The refs action goes back to `forge_preflight || exit 1`. 97e63ac had it exit 0 with a notice so the PR check would not be red, and that conflated two different questions: "this action cannot produce a verdict" is the ACTION's contract and must stay a refusal, while "this check should not block the board" is the CALLER's decision. The caller now carries it — refs-guard.yml skips unless github.server_url is github.com, mirroring forge_detect positively. A skipped check is a green head; an action that reports success it did not earn is not. The leaked preflight_err temp file goes with the revert. The workflow guard asked the wrong question. `command -v gh` alone passes the moment a Forgejo runner image happens to ship gh, and then dispatches against a forge that cannot serve it — the client/forge mismatch forge_preflight exists to prevent. It decides the FORGE first now, mirroring forge_detect positively, and the binary second. The source guard splits to match: a declaration guarded only by binary presence is reported, with a fixture that fails on exactly that shape. The warning text was also wrong on the facts, as noted: issue-event sweeps ARE this caller's event-driven wakes, so they are precisely what is lost. It now says the hourly scheduled sweep survives and every event-driven wake through this caller does not, until #205. Point 1 of that review — jq 1.6 accepting an empty payload — was already fixed in 728102a, pushed before the review landed. Verified under the runner's jq 1.6 as well as 1.7: 28 test files, 0 failed both ways. shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker, vendored, changelog-armed all clean with every file tracked. Refs #198 --- .github/workflows/labels.yml | 24 +++++++++---- .github/workflows/refs-guard.yml | 12 +++++++ actions/refs-not-closing/run.sh | 28 +++++---------- changelog.d/198.md | 9 +++-- test/no-runtime-gh.test.sh | 60 ++++++++++++++++++++++++-------- test/refs-not-closing.test.sh | 23 ++++++------ 6 files changed, 103 insertions(+), 53 deletions(-) diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 31f859e..925ff2f 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -134,13 +134,25 @@ jobs: # below; #205 owns the REST port that removes both. CEREMONY_FORGE_CLIENT: gh run: | - # Never `command not found`. On a runner without gh the wake is - # genuinely lost, and that is worth a warning rather than a failed - # job: this trigger is the misconfiguration alarm for a CONSUMER's - # missing sweep caller, and reddening every sweep on a forge whose - # runner has no gh would drown that signal in a known gap (#205). + # Two questions, not one. @codex-reviewer-andresmgsl: a guard that + # only asks `command -v gh` passes the moment a Forgejo runner image + # happens to ship gh — and then runs a GitHub dispatch against a + # forge that cannot serve it, which is the client/forge mismatch + # forge_preflight exists to prevent. So the FORGE is decided first, + # mirroring forge_detect positively (only github.com is accepted; + # anything else, known or not, is refused — "Never 'probably + # github'"), and the binary is checked second. + # + # A warning, not a failure: this trigger is the misconfiguration + # alarm for a CONSUMER's missing sweep caller, and reddening every + # sweep on a forge for a gap #205 already owns would drown that + # signal. #205 ports the dispatch to REST and removes all of this. + if [ "${GITHUB_SERVER_URL:-}" != "https://github.com" ]; then + echo "::warning::labels: the sweep was NOT woken from this trigger — it dispatches with \`gh\` against GitHub, and this is not a GitHub forge (GITHUB_SERVER_URL=${GITHUB_SERVER_URL:-unset}). #205 ports it to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller — issue events included — is unavailable until then." + exit 0 + fi if ! command -v gh >/dev/null 2>&1; then - echo "::warning::labels: the sweep was NOT woken from this trigger — it dispatches with \`gh\`, which this runner does not carry. #205 ports it to REST. Scheduled and issue-event sweeps are unaffected; only this caller's event-driven wake is lost." + echo "::warning::labels: the sweep was NOT woken from this trigger — this runner does not carry \`gh\`. #205 ports the dispatch to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller is unavailable until then." exit 0 fi gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no diff --git a/.github/workflows/refs-guard.yml b/.github/workflows/refs-guard.yml index ef4964e..5b6abbe 100644 --- a/.github/workflows/refs-guard.yml +++ b/.github/workflows/refs-guard.yml @@ -12,6 +12,18 @@ permissions: jobs: refs-not-closing: + # The action is gh-only until #199: its whole gather is a GraphQL query, + # and Forgejo serves no GraphQL at all. The ACTION refuses by name on a + # backend it cannot speak (that is its contract, and its contract test); + # scheduling it where it can only refuse is this workflow's decision, and + # a permanently red required check would block every merge on this forge + # for a gap #199 already owns. So the job does not run there — a skipped + # check is a green head, an invented verdict is not. + # + # The condition mirrors lib/forge.sh's forge_detect positively: only + # github.com is accepted, and anything else — Forgejo, or a host this + # file has not met — is not run. "Never 'probably github'." + if: ${{ github.server_url == 'https://github.com' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/actions/refs-not-closing/run.sh b/actions/refs-not-closing/run.sh index d3325c5..d0dbbf4 100755 --- a/actions/refs-not-closing/run.sh +++ b/actions/refs-not-closing/run.sh @@ -22,26 +22,14 @@ set -euo pipefail # shellcheck source=lib/forge.sh . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh" export CEREMONY_FORGE_CLIENT=gh -# The refusal and the SCHEDULING are two different questions, and #198's first -# head conflated them: preflight refused correctly on this forge and turned -# every PR's `Refs guard` red, which blocks the board rather than protecting -# it. A guard that cannot run here must not claim a verdict — but it also must -# not stand permanently red for a port that has its own issue. -# -# So: not-runnable-here is reported and skipped, loudly and by name, and only -# a preflight failure for any OTHER reason is fatal. The distinction is the -# forge, not the exit code — on a forge this action CAN speak, a preflight -# failure is still a hard refusal, which is the case the tests drive. -preflight_err="$(mktemp)" -trap 'rm -f "$preflight_err"' EXIT -if ! forge_preflight 2>"$preflight_err"; then - cat "$preflight_err" >&2 - if [ "$(forge_detect 2>/dev/null)" != github ]; then - printf '::notice::refs-not-closing: not run — this action is still gh-only (its gather is GraphQL, which this forge does not serve) and #199 ports it. No verdict was produced.\n' - exit 0 - fi - exit 1 -fi +# Fail CLOSED, at the action boundary. An earlier head here exited 0 with a +# notice so the PR check would not be red; @codex-reviewer-andresmgsl was +# right that this conflates two different questions. "This action cannot +# produce a verdict" is the ACTION's contract and must stay a refusal; "this +# check should not block the board" is the CALLER's decision, and it belongs +# in .github/workflows/refs-guard.yml, which skips on a backend this action +# cannot speak until #199 ports it. +forge_preflight || exit 1 owner="${GITHUB_REPOSITORY%%/*}" name="${GITHUB_REPOSITORY#*/}" diff --git a/changelog.d/198.md b/changelog.d/198.md index e9f5f22..4fa510f 100644 --- a/changelog.d/198.md +++ b/changelog.d/198.md @@ -37,8 +37,13 @@ the forge zero times, so no verdict is produced either way (#198). - `.github/workflows/labels.yml`'s sweep dispatch declares the client it - speaks and refuses by name on a runner without it, instead of dying with - `command not found` on every sweep. #205 ports it to REST (#198). + speaks and decides the FORGE before the binary, so a Forgejo runner that + happens to ship `gh` cannot dispatch against a forge that cannot serve it. + #205 ports it to REST (#198). + +- `actions/refs-not-closing` fails closed on a forge it cannot speak, and + `.github/workflows/refs-guard.yml` carries the scheduling decision — the + action never reports a success it did not earn (#198). - `issue_payload_valid` refuses an empty payload on jq 1.6 as well as 1.7. `jq -e` exits 4 on empty input under 1.7 and **0** under 1.6, and this diff --git a/test/no-runtime-gh.test.sh b/test/no-runtime-gh.test.sh index 703efb0..7ef6b59 100755 --- a/test/no-runtime-gh.test.sh +++ b/test/no-runtime-gh.test.sh @@ -48,19 +48,34 @@ ALLOWED_FILE='lib/forge-github.sh' # would ride in free, and it lets a declaration exist without a refusal. declares_gh_client() { grep -qE '^[[:space:]]*(export[[:space:]]+)?CEREMONY_FORGE_CLIENT[=:][[:space:]]*gh[[:space:]]*$' "$1"; } -# Declaring is half of it. #197's bar is "declared AND refuses loudly", so a -# declaring file must also carry the refusal — forge_preflight for a script, -# an inline availability check for a workflow that has no shell to call it -# from. A declaration without one is a permission slip for `command not found`. -# Comments stripped first, for the same reason gh_calls strips them and with -# the same lesson learned the hard way: the first version of this predicate -# was satisfied by the word `forge_preflight` inside labels.yml's own comment -# EXPLAINING that it has no forge_preflight to call. A guard that reads prose -# as evidence is the blind sweep again, and it passed its own mutation test -# because of it. +# Declaring is half of it. #197's bar is "declared AND refuses loudly", and the +# refusal has TWO halves that a single check conflates +# (@codex-reviewer-andresmgsl, #198): +# +# * the FORGE — a gh dispatch is wrong on a forge that cannot serve it, and +# asking only "is gh installed?" passes the moment a Forgejo runner image +# happens to ship gh, which is the client/forge mismatch forge_preflight +# exists to prevent; +# * the BINARY — present or not on this runner. +# +# forge_preflight answers both, so a script that calls it satisfies both. A +# workflow has no shell to call it from and must do both inline. +# +# Comments are stripped first, for the same reason gh_calls strips them and +# with the same lesson learned the hard way: the first version of this +# predicate was satisfied by the word `forge_preflight` inside labels.yml's own +# comment EXPLAINING that it has no forge_preflight to call. A guard that reads +# prose as evidence is the blind sweep again, and it passed its own mutation +# test because of it. +strip_comments() { sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1"; } +refuses_wrong_forge() { + strip_comments "$1" | grep -qE 'forge_preflight|GITHUB_SERVER_URL.*github\.com' +} +refuses_missing_binary() { + strip_comments "$1" | grep -qE 'forge_preflight|command -v gh' +} refuses_when_unavailable() { - sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1" \ - | grep -qE 'forge_preflight|command -v gh' + refuses_wrong_forge "$1" && refuses_missing_binary "$1" } # A runtime invocation, not the word. `gh` must be at a command position and @@ -175,13 +190,28 @@ printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \ ' run: |' \ ' command -v gh >/dev/null || { echo "::warning::not woken"; exit 0; }' \ ' gh workflow run x' >"$TMP/declared-refusing.yml" -check "...and a declaration WITH one is" 0 "" \ +# Binary presence ALONE is not a refusal: a Forgejo runner that ships gh would +# sail past it and dispatch against a forge that cannot serve the call. +check "...and a declaration guarded only by binary presence still is not" 1 "" \ refuses_when_unavailable "$TMP/declared-refusing.yml" +check "...though it does satisfy the binary half on its own" 0 "" \ + refuses_missing_binary "$TMP/declared-refusing.yml" +# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold +printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \ + ' CEREMONY_FORGE_CLIENT: gh' \ + ' run: |' \ + ' [ "$GITHUB_SERVER_URL" = "https://github.com" ] || exit 0' \ + ' command -v gh >/dev/null || exit 0' \ + ' gh workflow run x' >"$TMP/declared-both.yml" +check "...and a declaration guarding BOTH forge and binary is" 0 "" \ + refuses_when_unavailable "$TMP/declared-both.yml" # The shipped workflow is the real customer for that pair. check "labels.yml declares the client it speaks" 0 "" \ declares_gh_client "$ROOT/.github/workflows/labels.yml" -check "...and refuses by name rather than dying on command not found" 0 "" \ - refuses_when_unavailable "$ROOT/.github/workflows/labels.yml" +check "...decides the forge before dispatching" 0 "" \ + refuses_wrong_forge "$ROOT/.github/workflows/labels.yml" +check "...and checks the binary too, rather than dying on command not found" 0 "" \ + refuses_missing_binary "$ROOT/.github/workflows/labels.yml" check "...and an undeclared one does not" 1 "" declares_gh_client "$TMP/bad.sh" # A mention of the variable in prose is not a declaration. printf '%s\n' '#!/usr/bin/env bash' '# CEREMONY_FORGE_CLIENT=gh would opt out' \ diff --git a/test/refs-not-closing.test.sh b/test/refs-not-closing.test.sh index bf24e73..e57305b 100755 --- a/test/refs-not-closing.test.sh +++ b/test/refs-not-closing.test.sh @@ -147,17 +147,16 @@ forgejo_boundary() { GITHUB_ACTION_PATH="$ROOT/actions/refs-not-closing" \ bash "$ENTRYPOINT" } -# The contract on a forge this action cannot speak: say so by name, produce -# NO verdict, and do not stand red. Red would be honest about the port and -# dishonest about the PR — it blocks every merge on this forge for a gap #199 -# owns, which is a worse failure than the one it reports (#198). -check "on a forgejo forge the action names the client mismatch" 0 \ +# The contract on a forge this action cannot speak: refuse, by name, non-zero, +# and read nothing. FAIL CLOSED — an earlier head made this exit 0 so the PR +# check would not be red, which conflated the ACTION's contract with the +# CALLER's scheduling decision (@codex-reviewer-andresmgsl, #198). The caller +# is .github/workflows/refs-guard.yml, which skips on a backend this action +# cannot speak; the action itself never reports success it did not earn. +check "on a forgejo forge the action refuses, non-zero" 1 \ "cannot speak it" forgejo_boundary -check "...naming the client it declared" 0 "'gh' client" forgejo_boundary -check "...and the client the forge actually needs" 0 "'rest' client" forgejo_boundary -check "...says explicitly that it produced no verdict" 0 "No verdict was produced" \ - forgejo_boundary -check "...points at the issue that ports it" 0 "#199" forgejo_boundary +check "...naming the client it declared" 1 "'gh' client" forgejo_boundary +check "...and the client the forge actually needs" 1 "'rest' client" forgejo_boundary # The teeth: it must not have READ anything. The stub counts its own calls, so # a gather that ran despite the refusal is visible here. forgejo_read_count() { @@ -166,6 +165,10 @@ forgejo_read_count() { wc -l <"$TMP/gh-calls" } check "...and reached the forge zero times" 0 "0" forgejo_read_count +# The caller carries the scheduling half, positively: only github.com runs it. +check "the caller skips the job on any non-github forge" 0 \ + "github.server_url == 'https://github.com'" \ + grep -F "if:" "$ROOT/.github/workflows/refs-guard.yml" check "action boundary fails when GraphQL read fails" 42 \ "fake GraphQL read failed" action_boundary failure