From 45aa8062073538d28084f5a685ce60fb0cd4e158 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 1 Aug 2026 16:20:00 +0000 Subject: [PATCH 1/2] 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 2/2] 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