Merge pull request #211 from dan-claude-bot/build/209-detach-reconcile-sweep
labels: detach the reconcile sweep from PR-triggered runs (#209)
This commit is contained in:
commit
c2987fd8d8
8 changed files with 448 additions and 140 deletions
125
.github/workflows/labels-sweep.yml
vendored
Normal file
125
.github/workflows/labels-sweep.yml
vendored
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
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, 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
|
||||
# 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 }}
|
||||
128
.github/workflows/labels.yml
vendored
128
.github/workflows/labels.yml
vendored
|
|
@ -6,24 +6,44 @@ 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. 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
|
||||
# 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 +55,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 +105,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
|
||||
|
|
|
|||
46
.github/workflows/self-labels-sweep.yml
vendored
Normal file
46
.github/workflows/self-labels-sweep.yml
vendored
Normal file
|
|
@ -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@<pinned-tag>
|
||||
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
|
||||
26
.github/workflows/self-labels.yml
vendored
26
.github/workflows/self-labels.yml
vendored
|
|
@ -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@<pinned-tag>
|
||||
#
|
||||
# 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
|
||||
|
|
|
|||
10
changelog.d/209.md
Normal file
10
changelog.d/209.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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,32 @@ 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 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.
|
||||
|
||||
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 +313,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 +341,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@<pinned-tag>
|
||||
# If the sweep caller below is named anything but labels-sweep.yml,
|
||||
# say so: `with: { sweep_workflow: <filename> }`. 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@<pinned-tag>
|
||||
# If this repo's PR-facing labels caller is named anything but `labels`,
|
||||
# pass that name: `with: { pr_workflow_name: <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 +436,42 @@ 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 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 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 +507,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 <owner>/<repo>
|
||||
gh workflow run labels-sweep.yml -R <owner>/<repo>
|
||||
```
|
||||
|
||||
Ceremony dogfoods the caller under the filename `self-labels.yml`, so the
|
||||
equivalent command in this repository substitutes that filename. Scheduled
|
||||
and PR-triggered runs only reconcile; they do not repeatedly upsert the
|
||||
taxonomy. When a ceremony pin bump adds a core label, bump the pin first and
|
||||
then re-dispatch `workflow_dispatch`; the scheduled sweep warns when the
|
||||
pinned taxonomy declares a core label the repository lacks.
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 "" \
|
||||
|
|
|
|||
Loading…
Reference in a new issue