Compare commits

...

19 commits

Author SHA1 Message Date
codex-bot-andresmgsl
2539998754 test: wake fork-head labels proof 2026-08-25 00:00:53 +00:00
codex-bot-andresmgsl
e639e67f09 docs(labels): split fork and same-repo wake latency 2026-08-24 23:59:17 +00:00
codex-bot-andresmgsl
ffbc1afc3d fix(labels): defer fork-head writes to sweep 2026-08-24 23:57:07 +00:00
codex-bot-andresmgsl
311ef304fc test(labels): require fork-safe write gating 2026-08-24 23:56:06 +00:00
e55e99663e Merge pull request 'fix: refuse release PRs that strand target fragments' (#255) from build/253-stranded-fragments into main
Reviewed-on: heavy-duty/ceremony#255
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-24 22:55:26 +00:00
codex-bot-andresmgsl
5823f3d7b7 docs: record stranded-fragment refusal 2026-08-24 20:17:36 +00:00
codex-bot-andresmgsl
0f3d3b36eb fix: refuse target-head stranded fragments 2026-08-24 20:15:55 +00:00
codex-bot-andresmgsl
a2b9b30930 test: expose target-head stranded fragments 2026-08-24 20:14:09 +00:00
a1bac15a8b Merge pull request 'fix: exhaust Forgejo timeline pagination' (#254) from build/240-exhaustive-timeline into main
Reviewed-on: heavy-duty/ceremony#254
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-24 19:58:11 +00:00
46458ba8cd Merge pull request 'fix: bind Refs parser to one token' (#252) from build/234-bind-refs-token into main
Reviewed-on: heavy-duty/ceremony#252
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-24 18:15:11 +00:00
codex-bot-andresmgsl
1164640a08 test: close exhaustive pagination review gaps 2026-08-24 18:01:04 +00:00
codex-bot-andresmgsl
40ebcea462 fix: exhaust Forgejo timeline pagination 2026-08-24 17:53:13 +00:00
codex-bot-andresmgsl
8c0f5d53d7 test: expose truncated Forgejo timelines 2026-08-24 17:50:50 +00:00
codex-bot-andresmgsl
4fb01e8b9f chore: merge main development bump (#234) 2026-08-24 17:04:02 +00:00
codex-bot-andresmgsl
d712f0636f fix: preserve Refs keyword boundaries (#234) 2026-08-24 16:13:19 +00:00
codex-bot-andresmgsl
5232027361 test: mark Refs fixture Markdown literal (#234) 2026-08-24 16:06:12 +00:00
codex-bot-andresmgsl
6b2b467b7c docs: record bounded Refs parsing (#234) 2026-08-24 16:04:33 +00:00
codex-bot-andresmgsl
b105939d95 fix: bind Refs declarations to one token (#234) 2026-08-24 16:04:02 +00:00
codex-bot-andresmgsl
b2048f63bd test: pin Refs token boundaries (#234) 2026-08-24 16:02:01 +00:00
16 changed files with 385 additions and 89 deletions

View file

@ -3,9 +3,10 @@ name: labels-sweep
# jobs that rode labels.yml until #209. Triggers and permissions live in the # jobs that rode labels.yml until #209. Triggers and permissions live in the
# caller; docs/CONSUMERS.md carries the complete caller stub # caller; docs/CONSUMERS.md carries the complete caller stub
# (workflow_dispatch plus the hourly cron, which relocated here with the # (workflow_dispatch plus the hourly cron, which relocated here with the
# sweep). Board events still yield a sweep within seconds: labels.yml's # sweep). Issue events and same-repository PR events still yield a sweep within
# trigger job dispatches this workflow's caller on every event it used to # seconds: labels.yml's trigger job dispatches this workflow's caller on those
# run reconcile on. # events. Fork-headed PR events carry a read-only token on this Forgejo and
# wait for the caller's scheduled cadence instead (#241).
# #
# Detached on purpose (#209): every sweep covers every open PR and all # Detached on purpose (#209): every sweep covers every open PR and all
# sweeps serialize through ONE shared concurrency group, so GitHub's # sweeps serialize through ONE shared concurrency group, so GitHub's

View file

@ -2,12 +2,14 @@ name: labels
# Reusable half of the labels automation. Triggers and permissions live in # Reusable half of the labels automation. Triggers and permissions live in
# the caller; docs/CONSUMERS.md carries the complete caller stub. # the caller; docs/CONSUMERS.md carries the complete caller stub.
# #
# The caller uses pull_request_target, not pull_request: every PR in this # The caller uses pull_request_target, not pull_request, so same-repository PRs
# family arrives from a fork, where pull_request runs with a READ-ONLY token # keep the base repository's write token without running PR code. On this
# and cannot label anything. _target is safe in this workflow because no PR # Forgejo, unlike GitHub, fork-headed _target runs still receive a read-only
# code is ever checked out or executed — scope reads changed paths and the # token. Those runs therefore attempt no writes and leave labeling to the
# path mapping via the API and checks out only the ceremony implementation. # scheduled sweep; the explicit fork_head job below records that disposition
# Keep it that way. # as a successful check. Both write paths execute only for same-repository
# heads. Scope reads changed paths and the path mapping through the API and
# checks out only the ceremony implementation. Keep it that way (#241).
# #
# The reconcile sweep lived here until #209. Riding the PR-triggered run # The reconcile sweep lived here until #209. Riding the PR-triggered run
# meant every displacement in the sweep's shared concurrency queue recorded # meant every displacement in the sweep's shared concurrency queue recorded
@ -21,8 +23,11 @@ name: labels
# sweep now lives in labels-sweep.yml behind its own caller, and 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 # trigger job below is its wake: it fires on every event this caller
# subscribes — the exact surface that used to run reconcile directly — so # subscribes — the exact surface that used to run reconcile directly — so
# the wake latency (#137) is unchanged, while a displaced sweep cancels on # same-repository wake latency (#137) remains seconds-scale, while a displaced
# the Actions tab, attached to no PR. PR checks show scope + trigger only. # sweep cancels on the Actions tab, attached to no PR. Fork-headed runs cannot
# dispatch with their read-only token, so their labels arrive on the scheduled
# sweep cadence. PR checks show scope + trigger for same-repository heads, or
# fork_head for fork heads.
# #
# This cannot loop: the trigger's dispatch and the reconciler's label # This cannot loop: the trigger's dispatch and the reconciler's label
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from # writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
@ -60,6 +65,7 @@ jobs:
# scope run is clobbered. # scope run is clobbered.
if: >- if: >-
github.event_name == 'pull_request_target' && github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action != 'labeled' && github.event.action != 'labeled' &&
github.event.action != 'unlabeled' && github.event.action != 'unlabeled' &&
github.event.action != 'review_requested' && github.event.action != 'review_requested' &&
@ -106,15 +112,10 @@ jobs:
CONFIG_REF: ${{ github.sha }} CONFIG_REF: ${{ github.sha }}
trigger: trigger:
# The sweep's wake (#209). No `if:`: reconcile carried none, so the # The sweep's instant wake (#209) keeps the whole non-PR event surface and
# trigger keeps the whole event surface the caller subscribes — # same-repository PRs. Fork-headed PRs are excluded because this Forgejo
# workflow_dispatch of the labels caller itself included. That cannot # gives their pull_request_target run a read-only token (#241); fork_head
# double-fire bootstrap: this dispatch always carries bootstrap=no, so # records the successful deferral to the scheduled sweep instead.
# 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 # LOUD on failure — never `|| true`: a red trigger is the
# misconfiguration alarm. A consumer that bumps the pin without adding # misconfiguration alarm. A consumer that bumps the pin without adding
@ -122,6 +123,9 @@ jobs:
# `bootstrap` input (unexpected input), or without `actions: write` # `bootstrap` input (unexpected input), or without `actions: write`
# on this caller (permission denied) fails HERE, visibly on the PR, # on this caller (permission denied) fails HERE, visibly on the PR,
# instead of silently never sweeping again. # instead of silently never sweeping again.
if: >-
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: dispatch the sweep - name: dispatch the sweep
@ -199,3 +203,13 @@ jobs:
exit 1 exit 1
fi fi
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)" echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"
fork_head:
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
steps:
- name: explain deferred fork labels
run: >-
echo "labels: fork head has a read-only token; writes deferred to the scheduled sweep cadence"

View file

@ -7,12 +7,14 @@ name: labels-sweep
on: on:
# The consumer owns this cadence (#203). Hourly is the recommended default # 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 # 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 # wake for a review verdict landing (there is no
# pull_request_review trigger on the labels caller), blocker:ci-red set or # 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 # cleared (no check_suite/check_run/workflow_run), a blocker:conflict when
# ANOTHER PR merges under this one, and the time-based stale / 48h # 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 # claim-reclaim, plus every fork-headed PR transition on this Forgejo because
# trigger-job dispatch away. Hourly trades ≤1h of latency on those four # its pull_request_target token is read-only (#241). Issue events and
# same-repository PR events carry the rest in seconds, one trigger-job
# dispatch away. Hourly trades ≤1h of latency on the scheduled classes
# while cutting nominal scheduled sweeps from four an hour to one at # 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 # 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 # discovery path. If another engine writes some of those transitions, only
@ -22,8 +24,9 @@ on:
# A manual full-board sweep. A bare dispatch (input default "yes") also # A manual full-board sweep. A bare dispatch (input default "yes") also
# bootstraps the taxonomy on a fresh repo — what dispatching the labels # bootstraps the taxonomy on a fresh repo — what dispatching the labels
# caller did before #209. The reusable's trigger job wakes this workflow # 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 # with bootstrap=no on every issue and same-repository PR event — an
# re-upsert ~20 labels each time — so declaring this input is part of the # 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 # caller contract: a dispatch naming an undeclared input is refused, and
# the trigger job goes loudly red. # the trigger job goes loudly red.
workflow_dispatch: workflow_dispatch:

View file

@ -8,8 +8,10 @@ name: labels
# Since #209 this caller carries the PR/issue event surface only. The # Since #209 this caller carries the PR/issue event surface only. The
# reconcile sweep no longer rides these runs — the reusable's trigger job # reconcile sweep no longer rides these runs — the reusable's trigger job
# dispatches the sweep caller (self-labels-sweep.yml here), which owns the # dispatches the sweep caller (self-labels-sweep.yml here), which owns the
# hourly cron and the manual/bootstrap workflow_dispatch. A board event # hourly cron and the manual/bootstrap workflow_dispatch. Issue events and
# below still yields a sweep within seconds, one dispatch hop later. # same-repository PR events below still yield a sweep within seconds, one
# dispatch hop later. Fork-headed PRs carry a read-only token on this Forgejo,
# so their successful labels run leaves writes to the hourly sweep (#241).
on: on:
# Narrowed (#199) to the actions that carry a queue-state change the hourly # Narrowed (#199) to the actions that carry a queue-state change the hourly
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/ # cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
@ -26,14 +28,14 @@ on:
issues: issues:
types: [opened, closed, edited, reopened] types: [opened, closed, edited, reopened]
pull_request_target: pull_request_target:
# Every PR arrives from a fork, so these carry the head/draft/review facts # These carry the head/draft/review facts the sweep derives state:* from.
# the sweep derives state:* from. labeled/unlabeled are the handoff wake — # Same-repository heads wake that sweep in seconds; fork heads cannot write
# the author's optimistic state:needs-human write, confirmed or corrected # with this Forgejo's read-only token and wait for its scheduled cadence.
# here in seconds (#11); synchronize re-derives on every push; # labeled/unlabeled are the same-repository handoff wake — the author's
# review_requested/review_request_removed wake the sweep that clears (or # optimistic state:needs-human write, confirmed or corrected here in
# restores) blocker:unrequested — without them the one event that makes # seconds (#11); synchronize re-derives on every push; review_requested/
# the label false could not clear it, and a quiet repo wore the red flag # review_request_removed clear (or restore) blocker:unrequested on that
# until the advisory cron (#137). # same instant path (#137).
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed] types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
permissions: permissions:
contents: read contents: read

View file

@ -33,6 +33,12 @@ set -euo pipefail
# no '## ' heading, and changelog_section extracts the body below HEAD's # no '## ' heading, and changelog_section extracts the body below HEAD's
# heading — so the date HEAD stamped into its heading never enters the # heading — so the date HEAD stamped into its heading never enters the
# comparison, and a date difference can never masquerade as a prose one. # comparison, and a date difference can never masquerade as a prose one.
#
# This guard narrows, but cannot close, the target-movement window: it sees a
# fragment present when CI reads the target ref, but one can still land after
# the final run and before merge. Requiring release PRs to be up to date with
# their target branch before merge is the repository setting that closes that
# residual window (#253).
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}" base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}" changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
@ -169,6 +175,37 @@ frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
failures=0 failures=0
# Refusal: the target branch gained a fragment after this release PR's merge
# base, so the ceremony could not have consumed it. Merging this tree would
# strand that fragment for the next release and misattribute when it shipped.
stranded=""
while IFS= read -r -d '' entry; do
meta="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
otype="$(printf '%s\n' "$meta" | awk '{ print $2 }')"
name="${path##*/}"
case "$otype:$name" in
blob:README.md) ;;
blob:*.md)
if ! printf '%s' "$base_frags" | grep -Fxq "$path"; then
stranded="${stranded} ${path}"$'\n'
fi
;;
esac
done < <(git ls-tree -z "$base_ref" -- "$dir/")
if [ -n "$stranded" ]; then
{
echo "changelog-assembled: fragment(s) on target '$base_ref' were not consumed by this release PR:"
echo
printf '%s' "$stranded"
echo
echo " Merging now would strand these entries for the next release and"
echo " misattribute when they shipped."
echo " Fix: rebase onto the target head and re-run bin/changelog-assemble '$ver'."
} >&2
failures=$((failures + 1))
fi
# Refusal: a fragment the ceremony consumed is still present on HEAD. The # Refusal: a fragment the ceremony consumed is still present on HEAD. The
# ceremony deletes exactly what it assembles (#112) — a fragment that # ceremony deletes exactly what it assembles (#112) — a fragment that
# survives its own release sits in the directory and is assembled AGAIN # survives its own release sits in the directory and is assembled AGAIN

View file

@ -208,14 +208,14 @@ claim_reclaim_marker() { # $1 = last activity epoch
refs_references() { # PR body on stdin -> local issue numbers named by Refs refs_references() { # PR body on stdin -> local issue numbers named by Refs
awk ' awk '
{ {
line = $0 rest = tolower($0)
lower = tolower(line) while (match(rest, /(^|[^[:alnum:]_-])refs[[:space:]:]+(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/)) {
if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) { token = substr(rest, RSTART, RLENGTH)
line = substr(line, RSTART + RLENGTH) sub(/^.*refs[[:space:]:]+/, "", token)
if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) { print token
sub(/[.(;].*/, "", line) # Retain the token final byte so ^ cannot turn a concatenated
print line # alphanumeric suffix into a fresh keyword boundary (#234).
} rest = substr(rest, RSTART + RLENGTH - 1)
} }
} }
' | issue_references \ ' | issue_references \

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

@ -0,0 +1,3 @@
### Fixed
- Refs-based issue-flow transitions now bind each declaration to its immediately following reference token, so later issue prose cannot release or preserve unrelated claims (#234).

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

@ -0,0 +1,3 @@
### Fixed
- Read Forgejo timelines to exhaustion so busy issues retain their newest label events despite dishonest total-count headers (#240).

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

@ -0,0 +1,3 @@
### Fixed
- Fork-headed label runs now stay green without attempting writes their read-only token cannot make, while same-repository heads keep instant labeling (#241).

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

@ -0,0 +1,3 @@
### Fixed
- Release checks now refuse a target-head fragment that the candidate did not consume, preventing late merges from misattributing shipped changes (#253).

View file

@ -337,14 +337,16 @@ The labels automation is two reusable workflows since #209, adopted
together at the same pin: together at the same pin:
- **`labels.yml`** — the event-facing half, called on PR and issue events. - **`labels.yml`** — the event-facing half, called on PR and issue events.
Two jobs: additive path-based `scope:*` labels, and a few-seconds Same-repository PRs keep two write-capable jobs: additive path-based
`trigger` job that wakes the sweep by dispatching the consumer's sweep `scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by
caller (a REST `POST` to the forge's own dispatching the consumer's sweep caller (a REST `POST` to the forge's own
`${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`, `${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`,
plain `GITHUB_TOKEN``workflow_dispatch` is plain `GITHUB_TOKEN``workflow_dispatch` is
one of the two documented exemptions from the token's no-retrigger rule, 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 so no PAT anywhere in the path and no loop: the sweep dispatches
nothing). nothing). On this Forgejo a fork-headed `pull_request_target` token is
read-only, so those two jobs do not run; a successful `fork_head` job names
the deferral, and the scheduled sweep owns their labels.
- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers, - **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
handoff, stale status, the issue work queue, and the `needs-ruling` handoff, stale status, the issue work queue, and the `needs-ruling`
invariants on both surfaces — the bare-flag check and the 7-day invariants on both surfaces — the bare-flag check and the 7-day
@ -356,8 +358,9 @@ together at the same pin:
as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun` 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). and its `--failed`/`--job` forms all decline a queue-displaced run).
Behind its own caller, a displaced sweep cancels on the Behind its own caller, a displaced sweep cancels on the
Actions tab, attached to no PR; PR checks show `scope` and the green Actions tab, attached to no PR. Same-repository PR checks show `scope` and
`trigger` only. the green `trigger`; fork-headed PRs show the green `fork_head` disposition
and wait for the scheduled sweep cadence.
The consumer keeps its path mapping in `.github/labeler.yml` and its The consumer keeps its path mapping in `.github/labeler.yml` and its
review panel plus scope taxonomy in `.github/labels.conf`. review panel plus scope taxonomy in `.github/labels.conf`.
@ -384,11 +387,12 @@ The complete event-facing caller is:
name: labels name: labels
on: on:
pull_request_target: pull_request_target:
# Fork PRs; these carry the head/draft/review facts state:* derives from. # These carry the head/draft/review facts state:* derives from. Same-repo
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here); # heads take the instant write + sweep-dispatch path; this Forgejo gives
# synchronize re-derives on every push. review_requested/review_request_removed # fork heads a read-only token, so they wait for the scheduled sweep.
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears # labeled/unlabeled are the same-repo handoff wake; synchronize re-derives
# blocker:unrequested when the panel is asked. # on every push. review_requested/review_request_removed shipped in 0.3.0
# (ceremony#137) and wake the same-repo sweep when the panel is asked.
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed] types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a # Available at 0.2.0 and later (the first tag carrying ceremony#32); a
# consumer pinned to 0.1.0 omits this block. # consumer pinned to 0.1.0 omits this block.
@ -425,20 +429,21 @@ name: labels-sweep
on: on:
# The consumer owns this cadence (#203). Hourly is the recommended default # 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 # 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 # wake for a review verdict landing (no
# pull_request_review trigger on the labels caller), blocker:ci-red # pull_request_review trigger on the labels caller), blocker:ci-red
# set/cleared, blocker:conflict when another PR merges under this one, and # 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 # time-based stale / 48h claim-reclaim, plus every fork-headed PR transition
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of # on this Forgejo. Issue events and same-repo PR
# latency on those four while cutting nominal scheduled sweeps from four an # events carry the rest in seconds, one trigger-job dispatch away. Hourly
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their # trades ≤1h of latency on the scheduled classes while cutting nominal
# discovery path. If another engine writes some of those transitions, only # sweeps from four an hour to one at GitHub's 1-minute floor. Do not delete
# the classes with no other writer bound the cadence; relax it only as that # the cron: it is their discovery path. If another engine writes some of
# list shrinks. # those transitions, only the classes with no other writer bound the cadence;
# relax it only as that list shrinks.
schedule: [{cron: "0 * * * *"}] schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep. A bare dispatch (input default "yes") also # 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 # 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 # wakes this workflow with bootstrap=no on every issue and same-repo PR event, so the
# declared input is part of the contract: a dispatch naming an undeclared # declared input is part of the contract: a dispatch naming an undeclared
# input is refused, and the trigger job goes loudly red. # input is refused, and the trigger job goes loudly red.
workflow_dispatch: workflow_dispatch:
@ -542,16 +547,18 @@ 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 and issue event — the loud failure mode above — so never split these
four edits across PRs. four edits across PRs.
`pull_request_target` is intentional: fork PRs need the base repository's `pull_request_target` is intentional: same-repository PRs keep the base
token to write labels. The reusable workflows execute no PR code. They check repository's write token without executing PR code. This Forgejo still gives
out only the consumer's base branch and the pinned ceremony implementation. fork-headed `_target` runs a read-only token, so they attempt no writes and
leave labels to the scheduled sweep. The reusable workflows 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 The #52 ruling invariants ride exactly these triggers — but the caller above
is no longer the #18 shape, so adopting current triggers is a stub edit, not is no longer the #18 shape, so adopting current triggers is a stub edit, not
a bare pin bump. `review_requested` and `review_request_removed` on a bare pin bump. `review_requested` and `review_request_removed` on
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that `pull_request_target:` shipped in `0.3.0` (ceremony#137). It clears
clears `blocker:unrequested` the moment the panel is asked, without which a `blocker:unrequested` the moment the panel is asked on a same-repository head;
quiet repo wears that flag until the backstop cron; a consumer picks them up fork heads wait for the sweep cadence on this Forgejo. A consumer picks the
by pinning `0.3.0` or later, never through mixed refs. events up by pinning `0.3.0` or later, never through mixed refs.
`.github/labels.conf` has one mandatory panel setting, one mandatory `.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, zero or more optional per-author panel rows, and `triage-actors` setting, zero or more optional per-author panel rows, and

View file

@ -84,7 +84,7 @@ forgejo_page_url() {
esac esac
} }
# forge_api [--paginate] <endpoint> [--jq <expr>] # forge_api [--paginate | --paginate-exhaustive] <endpoint> [--jq <expr>]
# #
# --paginate walks page= until a short page, then PROVES the walk was # --paginate walks page= until a short page, then PROVES the walk was
# complete by comparing what it collected against the server's declared # complete by comparing what it collected against the server's declared
@ -94,11 +94,26 @@ forgejo_page_url() {
# make the completeness check compare null to a number — the guard itself # make the completeness check compare null to a number — the guard itself
# degrading silently, which is the failure class re-entering through the # degrading silently, which is the failure class re-entering through the
# door built to stop it. # door built to stop it.
#
# --paginate-exhaustive is the narrow alternative for an endpoint whose
# x-total-count is known not to describe the collection. It proves completion
# by reading through the first short page and never consults that header.
forge_api() { forge_api() {
local paginate=false endpoint="" jqexpr="" have_jq=false local paginate=false paginate_exhaustive=false method=GET endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--paginate) paginate=true ;; --paginate) paginate=true ;;
--paginate-exhaustive) paginate_exhaustive=true ;;
-X | --method)
[ "$#" -ge 2 ] || { echo "forge_api: $1 requires a value" >&2; return 1; }
method="$2"
shift
;;
-X?*) method="${1#-X}" ;;
--method=*)
method="${1#*=}"
[ -n "$method" ] || { echo "forge_api: --method requires a value" >&2; return 1; }
;;
--jq) jqexpr="$2"; have_jq=true; shift ;; --jq) jqexpr="$2"; have_jq=true; shift ;;
-*) ;; -*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;; *) [ -n "$endpoint" ] || endpoint="$1" ;;
@ -106,6 +121,14 @@ forge_api() {
shift shift
done done
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; } [ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
if [ "$paginate" = true ] && [ "$paginate_exhaustive" = true ]; then
echo "forge_api: --paginate and --paginate-exhaustive are mutually exclusive" >&2
return 1
fi
if { [ "$paginate" = true ] || [ "$paginate_exhaustive" = true ]; } && [ "$method" != GET ]; then
echo "forge_api: pagination is available only for GET requests" >&2
return 1
fi
local base token local base token
base="$(forgejo_api_base)" || return 1 base="$(forgejo_api_base)" || return 1
@ -116,7 +139,7 @@ forge_api() {
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose # shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
trap "rm -f '$hdr' '$body'" RETURN trap "rm -f '$hdr' '$body'" RETURN
if [ "$paginate" = false ]; then if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
if ! curl -sS -D "$hdr" -o "$body" \ if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \ -H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$endpoint"; then "$base/$endpoint"; then
@ -142,6 +165,7 @@ forge_api() {
fi fi
forgejo_http_ok "$hdr" "GET $endpoint" || return 1 forgejo_http_ok "$hdr" "GET $endpoint" || return 1
if [ "$paginate_exhaustive" = false ]; then
# Re-read on EVERY page, not once (#4712). A board that changes size # Re-read on EVERY page, not once (#4712). A board that changes size
# under the walk was invisible: page 1 declaring 4 and page 2 declaring # under the walk was invisible: page 1 declaring 4 and page 2 declaring
# 9 stopped at 4 believing itself whole. A moving total means the read # 9 stopped at 4 believing itself whole. A moving total means the read
@ -158,6 +182,7 @@ forge_api: the declared total for '$endpoint' changed between pages — $total t
EOF EOF
return 1 return 1
fi fi
fi
pagejson="$(cat "$body")" pagejson="$(cat "$body")"
# A 200 whose body is not a collection counted as zero items (#4712), # A 200 whose body is not a collection counted as zero items (#4712),
@ -175,13 +200,17 @@ EOF
[ "$n" -gt 0 ] || break [ "$n" -gt 0 ] || break
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")" all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
got=$((got + n)) got=$((got + n))
if [ "$paginate_exhaustive" = true ]; then
[ "$n" -eq 50 ] || break
else
[ "$got" -lt "$total" ] || break [ "$got" -lt "$total" ] || break
fi
page=$((page + 1)) page=$((page + 1))
done done
# The assert. A short read here is the silent-truncation bug arriving by # The assert. A short read here is the silent-truncation bug arriving by
# another route, so it is fatal rather than a warning. # another route, so it is fatal rather than a warning.
if [ "$got" -ne "$total" ]; then if [ "$paginate_exhaustive" = false ] && [ "$got" -ne "$total" ]; then
cat >&2 <<EOF cat >&2 <<EOF
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188). forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
Refusing rather than reconciling a partial board: a sweep over part of the Refusing rather than reconciling a partial board: a sweep over part of the
@ -561,7 +590,13 @@ forge_request_reviewer() {
# empty timeline — the two states the ruling ladder must tell apart (#4853). # empty timeline — the two states the ruling ladder must tell apart (#4853).
forge_timeline() { forge_timeline() {
local n="${1:?forge_timeline: number required}" raw local n="${1:?forge_timeline: number required}" raw
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1 # Measured on this instance: limit=10 reports x-total-count=10 and limit=50
# reports 50, while crew!96 held 151 events and strict pagination returned
# only its first 50. No other measured endpoint echoes its page size this
# way. Timelines are append-only, so exhaustion can include concurrent new
# events but cannot create a deletion hole; that is why only this call site
# may bypass the header-bound completeness proof (#240).
raw="$(forge_api --paginate-exhaustive "repos/$REPO/issues/$n/timeline")" || return 1
jq ' jq '
[.[] [.[]
| select(.type == "label") | select(.type == "label")

View file

@ -217,6 +217,49 @@ check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
check "the survivor refusal names the file" 1 "changelog.d/9.md" \ check "the survivor refusal names the file" 1 "changelog.d/9.md" \
run survivor base run survivor base
# A release PR can be faithful to its merge base while the target branch moves
# ahead and gains a fragment during review. That target-only fragment was not
# available to the ceremony, so merging the PR would strand it for the next
# release. The guard must read the target ref as well as their merge base.
seed_flat target-stranded
ceremony target-stranded 0.2.0 2026-07-24
commit_head target-stranded
git -C "$TMP/target-stranded" switch -q base
printf -- '- Landed while the release was under review (#30).\n' \
>"$TMP/target-stranded/changelog.d/30.md"
git -C "$TMP/target-stranded" add -A
git -C "$TMP/target-stranded" commit -qm target-fragment
git -C "$TMP/target-stranded" switch -q main
check "a target-head fragment the release did not consume fails" 1 \
"changelog.d/30.md" run target-stranded base
check "the target-stranding refusal names the rebase remedy" 1 \
"rebase onto the target head" run target-stranded base
check "the target-stranding refusal names the assembler re-run" 1 \
"changelog-assemble '0.2.0'" run target-stranded base
# Removing the target-only fragment makes the same diverged fixture green:
# target drift itself is not the failure, only a stranded fragment is.
git -C "$TMP/target-stranded" switch -q base
rm "$TMP/target-stranded/changelog.d/30.md"
git -C "$TMP/target-stranded" add -A
git -C "$TMP/target-stranded" commit -qm target-fragment-removed
git -C "$TMP/target-stranded" switch -q main
check "the same target fixture is green once no fragment is stranded" 0 \
"byte-for-byte" run target-stranded base
# Spell out the common harmless case independently: the target branch moved,
# but the advancing commit added no fragment.
seed_flat target-unrelated
ceremony target-unrelated 0.2.0 2026-07-24
commit_head target-unrelated
git -C "$TMP/target-unrelated" switch -q base
printf 'unrelated target change\n' >"$TMP/target-unrelated/code.txt"
git -C "$TMP/target-unrelated" add -A
git -C "$TMP/target-unrelated" commit -qm target-unrelated
git -C "$TMP/target-unrelated" switch -q main
check "a target head advanced without a fragment stays green" 0 \
"byte-for-byte" run target-unrelated base
# Fragments consumed, section never stamped: the prose went nowhere. # Fragments consumed, section never stamped: the prose went nowhere.
seed_flat halfdone seed_flat halfdone
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md" rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"

View file

@ -746,6 +746,7 @@ check "no api base refuses" 1 "cannot reach the forge" \
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or # .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
# emitting .user instead of .actor each reds its own case (#4853). # emitting .user instead of .actor each reds its own case (#4853).
timeline_stub() { timeline_stub() {
: >"$timeline_calls"
# shellcheck disable=SC2317 # invoked indirectly, by forge_api # shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() { curl() {
local hdr="" out="" url="" local hdr="" out="" url=""
@ -753,14 +754,44 @@ timeline_stub() {
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift shift
done done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr" printf '%s\n' "$url" >>"$timeline_calls"
local page=1 page_body="$FAKE_TIMELINE" page_total="${FAKE_TL_N:-2}"
case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac
if [ "${#FAKE_TL_PAGES[@]}" -gt 0 ]; then
if [ "$page" -le "${#FAKE_TL_PAGES[@]}" ]; then
page_body="${FAKE_TL_PAGES[$((page - 1))]}"
else
page_body='[]'
fi
page_total="$(jq 'length' <<<"$page_body")"
fi
{
printf 'HTTP/1.1 200 OK\r\n'
[ "${FAKE_TL_HEADERS:-yes}" = no ] || printf 'X-Total-Count: %s\r\n' "$page_total"
printf '\r\n'
} >"$hdr"
case "$url" in case "$url" in
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;; *timeline*) printf '%s' "$page_body" >"$out" ;;
*) printf '[]' >"$out" ;; *) printf '[]' >"$out" ;;
esac esac
return 0 return 0
} }
} }
timeline_page() {
jq -nc --argjson first "$1" --argjson count "$2" '
[range($first; $first + $count)
| {
type: "label",
body: "1",
user: {login: "setter"},
label: {name: "needs-ruling"},
created_at: ("event-" + tostring)
}]
'
}
timeline_calls="$TMP/timeline_calls"
FAKE_TL_PAGES=()
FAKE_TL_HEADERS=yes
FAKE_TIMELINE='[ FAKE_TIMELINE='[
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"}, {"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"}, {"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
@ -777,6 +808,61 @@ check "forge_timeline drops non-label events" 0 "" \
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0 test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \ check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
# Forgejo's timeline endpoint lies consistently: x-total-count echoes the
# current page size. With 151 events its pages declare 50, 50, 50 and 1, so
# strict pagination stops successfully after page 1 and drops the newest 101
# events. Exhaustion is safe only here because timelines are append-only.
FAKE_TL_PAGES=(
"$(timeline_page 1 50)"
"$(timeline_page 51 50)"
"$(timeline_page 101 50)"
"$(timeline_page 151 1)"
)
timeline_stub
tl="$(REPO=o/r forge_timeline 188)"
check "forge_timeline exhausts all pages despite per-page total headers" 0 "" \
test "$(jq 'length' <<<"$tl")" = 151
check "forge_timeline retains the newest event beyond page one" 0 "" \
jq -e 'any(.[]; .created_at == "event-151")' <<<"$tl" >/dev/null
check "a short final page terminates without an extra empty-page read" 0 "" \
test "$(wc -l <"$timeline_calls")" = 4
# A collection exactly divisible by the page size needs one final empty read;
# stopping after the second full page cannot prove exhaustion.
FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 50)")
timeline_stub
exhaustive_count="$(REPO=o/r forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length')"
check "exhaustive pagination terminates after an empty page" 0 "" \
test "$exhaustive_count" = 100
check "an exactly-full exhaustive gather reads the empty third page" 0 "" \
test "$(wc -l <"$timeline_calls")" = 3
# The exhaustive path's completeness proof is the short page itself; it must
# never consult the endpoint's missing or dishonest total header.
FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 1)")
FAKE_TL_HEADERS=no
timeline_stub
check "exhaustive pagination needs no x-total-count header" 0 "" \
eq 51 forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length'
check "the same missing-header fixture is still refused by strict pagination" 1 \
"did not send x-total-count" \
forge_api --paginate 'repos/o/r/issues/188/timeline' --jq 'length'
FAKE_TL_HEADERS=yes
check "strict and exhaustive pagination are mutually exclusive" 1 "mutually exclusive" \
forge_api --paginate --paginate-exhaustive 'repos/o/r/issues/188/timeline'
check "exhaustive pagination refuses a non-GET method" 1 "GET" \
forge_api --paginate-exhaustive -X POST 'repos/o/r/issues/188/timeline'
check "exhaustive pagination refuses compact -XPOST too" 1 "GET" \
forge_api --paginate-exhaustive -XPOST 'repos/o/r/issues/188/timeline'
check "exhaustive pagination refuses --method=POST too" 1 "GET" \
forge_api --paginate-exhaustive --method=POST 'repos/o/r/issues/188/timeline'
check "the exhaustive flag has exactly one production call site" 0 "" \
test "$(grep -c 'paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 5
check "only forge_timeline invokes exhaustive pagination" 0 "" \
test "$(grep -c 'forge_api --paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 1
# Unreadable: curl fails. Status must surface through forge_timeline itself # Unreadable: curl fails. Status must surface through forge_timeline itself
# (not a later jq), or the ruling ladder invents a verdict on a half-read. # (not a later jq), or the ruling ladder invents a verdict on a half-read.
# shellcheck disable=SC2317 # shellcheck disable=SC2317

View file

@ -101,6 +101,28 @@ check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt
refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)' refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)'
check "Refs parser returns only references owned by a valid Refs marker" 0 "" \ check "Refs parser returns only references owned by a valid Refs marker" 0 "" \
test "$(refs_references <<<"$refs_body")" = $'8\n12\n175' test "$(refs_references <<<"$refs_body")" = $'8\n12\n175'
# A Refs declaration binds one token, never the prose that follows it; every
# later occurrence starts a fresh declaration (#234).
# shellcheck disable=SC2016 # backticks are the fixture's literal Markdown
crew_round_line='- **Claude verified the safe `Refs #52`, TDD provenance, and inherited #65 CI failure.**'
check "Refs parser ignores issue prose after the bound token" 0 "" \
test "$(refs_references <<<"$crew_round_line")" = 52
check "Refs comma-list narrowing is deliberate" 0 "" \
test "$(refs_references <<<'Refs #8, #9')" = 8
check "a cross-repo Refs token stays non-local" 0 "" \
test -z "$(refs_references <<<'Refs heavy-duty/rig#4')"
check "prose after a cross-repo Refs token stays non-local" 0 "" \
test -z "$(refs_references <<<'Refs heavy-duty/rig#4, #12')"
check "parenthesized prose after a local Refs token is ignored" 0 "" \
test "$(refs_references <<<'Refs #175 (split from #150)')" = 175
check "every Refs occurrence on one line contributes its bound token" 0 "" \
test "$(refs_references <<<'Refs #8. Refs #9.')" = $'8\n9'
check "a concatenated Refs spelling is not a second keyword occurrence" 0 "" \
test "$(refs_references <<<'Refs #8Refs #9')" = 8
check "lowercase refs in unrelated prose declares nothing" 0 "" \
test -z "$(refs_references <<<'fix refs parsing from #200')"
check "Refs without a following token declares nothing" 0 "" \
test -z "$(refs_references <<<'Closes #40; refs: none')"
open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5' open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5'
check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \ check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \
open_pr_issues <<<"$open_records" open_pr_issues <<<"$open_records"

View file

@ -150,4 +150,38 @@ done
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \ check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
trigger_types "$SELF" pull_request_target trigger_types "$SELF" pull_request_target
# ---- fork heads carry a read-only token on this Forgejo (#241) --------------
# Same-repo heads keep the existing immediate scope + sweep-dispatch path. A
# fork-headed pull_request_target run must attempt no write: both write-capable
# jobs exclude it, while one successful job explains that the scheduled sweep
# owns its labels. These read the parsed workflow fields rather than grepping
# prose, so a comment cannot satisfy the contract.
job_if_contains_all() { # $1 = file, $2 = job, remaining args = predicates
local file="$1" job="$2" expression predicate
shift 2
expression="$(yq -r ".jobs.$job.if // \"\"" "$file")"
for predicate in "$@"; do
grep -qF "$predicate" <<<"$expression" || return 1
done
}
check "scope writes only for a same-repo PR head" 0 "" \
job_if_contains_all "$REUSABLE" scope \
"github.event.pull_request.head.repo.full_name == github.repository"
check "the sweep trigger preserves non-PR events and excludes fork heads" 0 "" \
job_if_contains_all "$REUSABLE" trigger \
"github.event_name != 'pull_request_target'" \
"github.event.pull_request.head.repo.full_name == github.repository"
check "a fork-headed PR selects the successful explanation job" 0 "" \
job_if_contains_all "$REUSABLE" fork_head \
"github.event_name == 'pull_request_target'" \
"github.event.pull_request.head.repo.full_name != github.repository"
fork_head_step() {
yq -r '.jobs.fork_head.steps[] | select(.name == "explain deferred fork labels") | .run' \
"$REUSABLE" | bash
}
check "the fork path names its read-only token and sweep-cadence wake" 0 \
"read-only token; writes deferred to the scheduled sweep cadence" \
fork_head_step
summary summary