From 311ef304fc11b636b02236b1cd9178a9be974b30 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 24 Aug 2026 23:56:06 +0000 Subject: [PATCH 1/7] test(labels): require fork-safe write gating --- test/labels-triggers.test.sh | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/labels-triggers.test.sh b/test/labels-triggers.test.sh index 14847c5..f76ed2d 100644 --- a/test/labels-triggers.test.sh +++ b/test/labels-triggers.test.sh @@ -150,4 +150,38 @@ done check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \ 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 -- 2.45.2 From ffbc1afc3d18d2ce56892bfc836c4093ccdfb047 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 24 Aug 2026 23:57:07 +0000 Subject: [PATCH 2/7] fix(labels): defer fork-head writes to sweep --- .github/workflows/labels.yml | 48 +++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index a98565c..1625527 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -2,12 +2,14 @@ name: labels # Reusable half of the labels automation. Triggers and permissions live in # the caller; docs/CONSUMERS.md carries the complete caller stub. # -# The caller uses pull_request_target, not pull_request: every PR in this -# 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. -# Keep it that way. +# The caller uses pull_request_target, not pull_request, so same-repository PRs +# keep the base repository's write token without running PR code. On this +# Forgejo, unlike GitHub, fork-headed _target runs still receive a read-only +# token. Those runs therefore attempt no writes and leave labeling to the +# scheduled sweep; the explicit fork_head job below records that disposition +# 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 # 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 # 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. +# same-repository wake latency (#137) remains seconds-scale, while a displaced +# 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 # writes both use GITHUB_TOKEN. GitHub does not create workflow runs from @@ -60,6 +65,7 @@ jobs: # scope run is clobbered. if: >- github.event_name == 'pull_request_target' && + github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'review_requested' && @@ -106,15 +112,10 @@ jobs: CONFIG_REF: ${{ github.sha }} 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. + # The sweep's instant wake (#209) keeps the whole non-PR event surface and + # same-repository PRs. Fork-headed PRs are excluded because this Forgejo + # gives their pull_request_target run a read-only token (#241); fork_head + # records the successful deferral to the scheduled sweep instead. # # LOUD on failure — never `|| true`: a red trigger is the # misconfiguration alarm. A consumer that bumps the pin without adding @@ -122,6 +123,9 @@ jobs: # `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. + if: >- + github.event_name != 'pull_request_target' || + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - name: dispatch the sweep @@ -199,3 +203,13 @@ jobs: exit 1 fi 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" -- 2.45.2 From e639e67f09dd109c76dc95bc7f703e8e58303383 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 24 Aug 2026 23:59:17 +0000 Subject: [PATCH 3/7] docs(labels): split fork and same-repo wake latency --- .github/workflows/labels-sweep.yml | 7 +-- .github/workflows/self-labels-sweep.yml | 13 ++++-- .github/workflows/self-labels.yml | 22 +++++---- changelog.d/241.md | 3 ++ docs/CONSUMERS.md | 61 ++++++++++++++----------- 5 files changed, 61 insertions(+), 45 deletions(-) create mode 100644 changelog.d/241.md diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml index d021e2b..7e2e3a1 100644 --- a/.github/workflows/labels-sweep.yml +++ b/.github/workflows/labels-sweep.yml @@ -3,9 +3,10 @@ name: labels-sweep # 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. +# sweep). Issue events and same-repository PR events still yield a sweep within +# seconds: labels.yml's trigger job dispatches this workflow's caller on those +# 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 # sweeps serialize through ONE shared concurrency group, so GitHub's diff --git a/.github/workflows/self-labels-sweep.yml b/.github/workflows/self-labels-sweep.yml index b929cb5..cb04ebe 100644 --- a/.github/workflows/self-labels-sweep.yml +++ b/.github/workflows/self-labels-sweep.yml @@ -7,12 +7,14 @@ 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 (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 # 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 + # claim-reclaim, plus every fork-headed PR transition on this Forgejo because + # 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 # 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 @@ -22,8 +24,9 @@ on: # 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 + # with bootstrap=no on every issue and same-repository PR 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: diff --git a/.github/workflows/self-labels.yml b/.github/workflows/self-labels.yml index 8b6b8c1..cd1c6b2 100644 --- a/.github/workflows/self-labels.yml +++ b/.github/workflows/self-labels.yml @@ -8,8 +8,10 @@ name: labels # 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. +# hourly cron and the manual/bootstrap workflow_dispatch. Issue events and +# 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: # Narrowed (#199) to the actions that carry a queue-state change the hourly # cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/ @@ -26,14 +28,14 @@ on: issues: types: [opened, closed, edited, reopened] pull_request_target: - # Every PR arrives from a fork, so these carry the head/draft/review facts - # the sweep derives state:* from. labeled/unlabeled are the handoff wake — - # the author's optimistic state:needs-human write, confirmed or corrected - # here in seconds (#11); synchronize re-derives on every push; - # review_requested/review_request_removed wake the sweep that clears (or - # restores) blocker:unrequested — without them the one event that makes - # the label false could not clear it, and a quiet repo wore the red flag - # until the advisory cron (#137). + # These carry the head/draft/review facts the sweep derives state:* from. + # Same-repository heads wake that sweep in seconds; fork heads cannot write + # with this Forgejo's read-only token and wait for its scheduled cadence. + # labeled/unlabeled are the same-repository handoff wake — the author's + # optimistic state:needs-human write, confirmed or corrected here in + # seconds (#11); synchronize re-derives on every push; review_requested/ + # review_request_removed clear (or restore) blocker:unrequested on that + # same instant path (#137). types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed] permissions: contents: read diff --git a/changelog.d/241.md b/changelog.d/241.md new file mode 100644 index 0000000..8c52d7e --- /dev/null +++ b/changelog.d/241.md @@ -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). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 9afc321..6573fd5 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -337,14 +337,16 @@ 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 (a REST `POST` to the forge's own + Same-repository PRs keep two write-capable jobs: additive path-based + `scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by + dispatching the consumer's sweep caller (a REST `POST` to the forge's own `${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`, 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). + 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, handoff, stale status, the issue work queue, and the `needs-ruling` 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` 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. + Actions tab, attached to no PR. Same-repository PR checks show `scope` and + 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 review panel plus scope taxonomy in `.github/labels.conf`. @@ -384,11 +387,12 @@ The complete event-facing caller is: name: labels on: 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); - # synchronize re-derives on every push. review_requested/review_request_removed - # (shipped in 0.3.0, ceremony#137) wake the sweep that clears - # blocker:unrequested when the panel is asked. + # These carry the head/draft/review facts state:* derives from. Same-repo + # heads take the instant write + sweep-dispatch path; this Forgejo gives + # fork heads a read-only token, so they wait for the scheduled sweep. + # labeled/unlabeled are the same-repo handoff wake; synchronize re-derives + # 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] # Available at 0.2.0 and later (the first tag carrying ceremony#32); a # consumer pinned to 0.1.0 omits this block. @@ -425,20 +429,21 @@ 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 + # wake for 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. + # time-based stale / 48h claim-reclaim, plus every fork-headed PR transition + # on this Forgejo. Issue events and same-repo PR + # events carry the rest in seconds, one trigger-job dispatch away. Hourly + # trades ≤1h of latency on the scheduled classes while cutting nominal + # 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 + # 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 # input is refused, and the trigger job goes loudly red. 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 four edits across PRs. -`pull_request_target` is intentional: fork PRs need the base repository's -token to write labels. The reusable workflows execute no PR code. They check -out only the consumer's base branch and the pinned ceremony implementation. +`pull_request_target` is intentional: same-repository PRs keep the base +repository's write token without executing PR code. This Forgejo still gives +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 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 -`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that -clears `blocker:unrequested` the moment the panel is asked, without which a -quiet repo wears that flag until the backstop cron; a consumer picks them up -by pinning `0.3.0` or later, never through mixed refs. +`pull_request_target:` shipped in `0.3.0` (ceremony#137). It clears +`blocker:unrequested` the moment the panel is asked on a same-repository head; +fork heads wait for the sweep cadence on this Forgejo. A consumer picks the +events up by pinning `0.3.0` or later, never through mixed refs. `.github/labels.conf` has one mandatory panel setting, one mandatory `triage-actors` setting, zero or more optional per-author panel rows, and -- 2.45.2 From 7690c15e1a29c0a27411a83a6fe75417cd1efcb3 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Tue, 25 Aug 2026 00:09:13 +0000 Subject: [PATCH 4/7] docs(labels): qualify fork-head sweep guarantees --- LABELS.md | 6 ++++-- docs/CONSUMERS.md | 28 ++++++++++++++++------------ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/LABELS.md b/LABELS.md index 61ffcfc..46a7f08 100644 --- a/LABELS.md +++ b/LABELS.md @@ -27,8 +27,10 @@ and the reconciler recomputes it from GitHub's own facts. `state:needs-human` means exactly one thing — a human could merge this now — so it requires zero blockers and head-current approvals; anything less and the reconciler takes it back. The author sets it at handoff (the one -hand-set state); the `labeled` event fires the sweep that validates the -write within seconds. +hand-set state). On a same-repository head, the `labeled` event fires the +sweep that validates the write within seconds; on a fork head whose +`pull_request_target` token is read-only, validation waits for the scheduled +sweep cadence (#241). ## PR blockers — what is in the way? (facts, as many as apply) diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 6573fd5..6594eed 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -482,17 +482,21 @@ repositories allow check data to be read regardless, but a private consumer 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 dispatch is a write, and without it every -event run goes red at the trigger. +repos included: the trigger job's dispatch is a write. Without it, issue and +same-repository PR event runs go red at the trigger. Fork-headed PR runs do +not enter that write path on this Forgejo; they remain green and depend on a +healthy scheduled sweep. **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. +to a #209-carrying tag without adding the sweep caller gets a loud red trigger +on every issue and same-repository PR event (workflow-not-found; likewise on a +sweep caller missing its `bootstrap` input, or a labels caller missing +`actions: write`). Fork-headed PR runs deliberately skip that trigger and stay +green, so their correctness is proven by the sweep caller's presence and its +latest scheduled run, not by the PR check alone. Never read a green +`fork_head` disposition as evidence that the scheduled sweep exists. Make the +adoption one atomic PR — pin bump, sweep caller file, and `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 @@ -543,9 +547,9 @@ carrying the split: `actions: read` today (crew does); the trigger job's dispatch 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. +Bump without the sweep caller and the trigger job goes red on every issue and +same-repository PR event; fork-headed PRs stay green but receive no labels +without the scheduled sweep. Never split these four edits across PRs. `pull_request_target` is intentional: same-repository PRs keep the base repository's write token without executing PR code. This Forgejo still gives -- 2.45.2 From 7da89a46aa4be2a7b07ee90396179038ac84052e Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Tue, 25 Aug 2026 00:24:05 +0000 Subject: [PATCH 5/7] docs(labels): qualify the instant trigger surface --- .github/workflows/labels.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 1625527..7a2f864 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -21,13 +21,13 @@ name: labels # 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 -# same-repository wake latency (#137) remains seconds-scale, while a displaced -# 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. +# trigger job below is its instant wake: it fires on every issue event and +# same-repository PR event this caller subscribes to, preserving that part of +# the surface that used to run reconcile directly. Same-repository wake latency +# (#137) remains seconds-scale, while a displaced 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 # writes both use GITHUB_TOKEN. GitHub does not create workflow runs from -- 2.45.2 From 07907456454642ab911c4c4277c1b57702e542c3 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Tue, 25 Aug 2026 03:56:57 +0000 Subject: [PATCH 6/7] fix: qualify fork label coverage --- .github/workflows/labels-sweep.yml | 8 +++-- .github/workflows/labels.yml | 23 +++++++++----- .github/workflows/self-labels-sweep.yml | 11 ++++--- .github/workflows/self-labels.yml | 8 +++-- changelog.d/241.md | 2 +- docs/CONSUMERS.md | 42 +++++++++++++++---------- test/labels-triggers.test.sh | 41 +++++++++++------------- 7 files changed, 77 insertions(+), 58 deletions(-) diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml index 7e2e3a1..0546978 100644 --- a/.github/workflows/labels-sweep.yml +++ b/.github/workflows/labels-sweep.yml @@ -5,8 +5,9 @@ name: labels-sweep # (workflow_dispatch plus the hourly cron, which relocated here with the # sweep). Issue events and same-repository PR events still yield a sweep within # seconds: labels.yml's trigger job dispatches this workflow's caller on those -# events. Fork-headed PR events carry a read-only token on this Forgejo and -# wait for the caller's scheduled cadence instead (#241). +# events. Fork-headed PR events carry a read-only token on this Forgejo, so +# state, blocker, and handoff reconciliation waits for the caller's scheduled +# cadence; the sweep does not apply path-derived scope labels (#241). # # Detached on purpose (#209): every sweep covers every open PR and all # sweeps serialize through ONE shared concurrency group, so GitHub's @@ -25,7 +26,8 @@ name: labels-sweep # 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. +# for every issue and same-repository PR wake, 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 diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 7a2f864..f206a85 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -5,11 +5,13 @@ name: labels # The caller uses pull_request_target, not pull_request, so same-repository PRs # keep the base repository's write token without running PR code. On this # Forgejo, unlike GitHub, fork-headed _target runs still receive a read-only -# token. Those runs therefore attempt no writes and leave labeling to the -# scheduled sweep; the explicit fork_head job below records that disposition -# 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). +# token. Those runs therefore attempt no writes. The scheduled sweep later +# reconciles state, blockers, and handoff, but it does not apply path-derived +# scope labels; consumers that require those labels on fork heads apply them +# manually. The explicit fork_head job below records that disposition 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 # meant every displacement in the sweep's shared concurrency queue recorded @@ -26,7 +28,8 @@ name: labels # the surface that used to run reconcile directly. Same-repository wake latency # (#137) remains seconds-scale, while a displaced 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 +# token, so state, blocker, and handoff reconciliation waits for the scheduled +# sweep; path-derived scope labels are not applied to fork heads. 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 @@ -115,7 +118,8 @@ jobs: # The sweep's instant wake (#209) keeps the whole non-PR event surface and # same-repository PRs. Fork-headed PRs are excluded because this Forgejo # gives their pull_request_target run a read-only token (#241); fork_head - # records the successful deferral to the scheduled sweep instead. + # records which reconciliation waits for the sweep and that path-derived + # scope labels are not applied there. # # LOUD on failure — never `|| true`: a red trigger is the # misconfiguration alarm. A consumer that bumps the pin without adding @@ -205,6 +209,9 @@ jobs: echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)" fork_head: + # This Forgejo keeps pull_request_target read-only for fork heads (#241), + # so name the deliberately unsupported scope write as well as the deferred + # state machine instead of letting a green no-op promise full labelling. if: >- github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository @@ -212,4 +219,4 @@ jobs: steps: - name: explain deferred fork labels run: >- - echo "labels: fork head has a read-only token; writes deferred to the scheduled sweep cadence" + echo "labels: fork head has a read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads" diff --git a/.github/workflows/self-labels-sweep.yml b/.github/workflows/self-labels-sweep.yml index cb04ebe..53842c9 100644 --- a/.github/workflows/self-labels-sweep.yml +++ b/.github/workflows/self-labels-sweep.yml @@ -11,11 +11,12 @@ on: # 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, plus every fork-headed PR transition on this Forgejo because - # 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 + # claim-reclaim, plus every state, blocker, and handoff transition for a + # fork-headed PR on this Forgejo because its pull_request_target token is + # read-only (#241). The sweep never applies path-derived scope labels. 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 # 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 diff --git a/.github/workflows/self-labels.yml b/.github/workflows/self-labels.yml index cd1c6b2..44d1fbf 100644 --- a/.github/workflows/self-labels.yml +++ b/.github/workflows/self-labels.yml @@ -11,7 +11,9 @@ name: labels # hourly cron and the manual/bootstrap workflow_dispatch. Issue events and # 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). +# so their successful labels run leaves state, blocker, and handoff +# reconciliation to the hourly sweep; path-derived scope labels are not +# applied to those heads (#241). on: # Narrowed (#199) to the actions that carry a queue-state change the hourly # cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/ @@ -30,7 +32,9 @@ on: pull_request_target: # These carry the head/draft/review facts the sweep derives state:* from. # Same-repository heads wake that sweep in seconds; fork heads cannot write - # with this Forgejo's read-only token and wait for its scheduled cadence. + # with this Forgejo's read-only token, so state, blocker, and handoff + # reconciliation waits for the scheduled cadence. The sweep does not apply + # path-derived scope labels to those heads (#241). # labeled/unlabeled are the same-repository handoff wake — the author's # optimistic state:needs-human write, confirmed or corrected here in # seconds (#11); synchronize re-derives on every push; review_requested/ diff --git a/changelog.d/241.md b/changelog.d/241.md index 8c52d7e..fb68d29 100644 --- a/changelog.d/241.md +++ b/changelog.d/241.md @@ -1,3 +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). +- Fork-headed label runs stay green without attempting forbidden writes, while same-repository heads keep instant scope and reconciliation wakes (#241). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 6594eed..e25621e 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -345,8 +345,10 @@ together at the same pin: 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). 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. + read-only, so those two jobs do not run. A successful `fork_head` job names + the disposition: the scheduled sweep later reconciles state, blockers, and + handoff, while path-derived `scope:*` labels are not applied to fork heads. + Apply those scope labels manually when an outside contribution needs them. - **`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 @@ -360,7 +362,8 @@ together at the same pin: Behind its own caller, a displaced sweep cancels on the Actions tab, attached to no PR. Same-repository PR checks show `scope` and the green `trigger`; fork-headed PRs show the green `fork_head` disposition - and wait for the scheduled sweep cadence. + and wait for scheduled state, blocker, and handoff reconciliation. The sweep + does not supply their path-derived scope labels. The consumer keeps its path mapping in `.github/labeler.yml` and its review panel plus scope taxonomy in `.github/labels.conf`. @@ -389,7 +392,9 @@ on: pull_request_target: # These carry the head/draft/review facts state:* derives from. Same-repo # heads take the instant write + sweep-dispatch path; this Forgejo gives - # fork heads a read-only token, so they wait for the scheduled sweep. + # fork heads a read-only token, so state, blocker, and handoff reconciliation + # waits for the scheduled sweep; path-derived scope labels require a manual + # write when wanted. # labeled/unlabeled are the same-repo handoff wake; synchronize re-derives # 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. @@ -432,9 +437,10 @@ on: # wake for 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, plus every fork-headed PR transition - # on this Forgejo. Issue events and same-repo PR - # events carry the rest in seconds, one trigger-job dispatch away. Hourly + # time-based stale / 48h claim-reclaim, plus every state, blocker, and handoff + # transition for a fork-headed PR on this Forgejo. The sweep never applies + # path-derived scope labels. Issue events and same-repo PR events carry the + # rest in seconds, one trigger-job dispatch away. Hourly # trades ≤1h of latency on the scheduled classes while cutting nominal # 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 @@ -443,9 +449,9 @@ on: 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 issue and same-repo PR 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. + # 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 input is refused, and the trigger job goes loudly red. workflow_dispatch: inputs: bootstrap: @@ -485,7 +491,8 @@ caller's `actions: write` is different — it is required everywhere, public repos included: the trigger job's dispatch is a write. Without it, issue and same-repository PR event runs go red at the trigger. Fork-headed PR runs do not enter that write path on this Forgejo; they remain green and depend on a -healthy scheduled sweep. +healthy scheduled sweep for state, blocker, and handoff reconciliation. That +sweep does not apply their path-derived scope labels. **The failure mode to know before bumping**: a consumer that bumps its pin to a #209-carrying tag without adding the sweep caller gets a loud red trigger @@ -548,14 +555,17 @@ carrying the split: write. The sweep caller keeps `actions: read`. Bump without the sweep caller and the trigger job goes red on every issue and -same-repository PR event; fork-headed PRs stay green but receive no labels -without the scheduled sweep. Never split these four edits across PRs. +same-repository PR event. Fork-headed PRs stay green, receive state, blocker, +and handoff reconciliation only from the scheduled sweep, and never receive +path-derived scope labels automatically; apply those manually when wanted. +Never split these four edits across PRs. `pull_request_target` is intentional: same-repository PRs keep the base repository's write token without executing PR code. This Forgejo still gives -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. +fork-headed `_target` runs a read-only token, so they attempt no writes. The +scheduled sweep later reconciles state, blockers, and handoff; it does not +apply path-derived scope labels to those heads. 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 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 diff --git a/test/labels-triggers.test.sh b/test/labels-triggers.test.sh index f76ed2d..1f5220a 100644 --- a/test/labels-triggers.test.sh +++ b/test/labels-triggers.test.sh @@ -153,35 +153,30 @@ check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \ # ---- 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 +# jobs exclude it, while one successful job explains exactly what the scheduled +# sweep does and does not supply. Compare the whole normalised expressions: a +# substring guard also accepts a future negation that inverts the gate. +job_if_expression() { # $1 = file, $2 = job + yq -r ".jobs.$2.if // \"\"" "$1" | + tr '\n' ' ' | + awk '{$1=$1; print}' } -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" +check "scope writes only for a same-repo PR head" 0 \ + "github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'review_requested' && github.event.action != 'review_request_removed'" \ + job_if_expression "$REUSABLE" scope +check "the sweep trigger preserves non-PR events and excludes fork heads" 0 \ + "github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository" \ + job_if_expression "$REUSABLE" trigger +check "a fork-headed PR selects the successful explanation job" 0 \ + "github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository" \ + job_if_expression "$REUSABLE" fork_head 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" \ +check "the fork path distinguishes swept state from unsupported scope writes" 0 \ + "read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads" \ fork_head_step summary -- 2.45.2 From 7fa202acb51fada58db72be5d37ced5270f278be Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Tue, 25 Aug 2026 04:37:41 +0000 Subject: [PATCH 7/7] docs: preserve fork-label rationale --- .github/workflows/labels.yml | 3 ++- docs/CONSUMERS.md | 8 ++++---- test/labels-triggers.test.sh | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index f206a85..007f1a8 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -119,7 +119,8 @@ jobs: # same-repository PRs. Fork-headed PRs are excluded because this Forgejo # gives their pull_request_target run a read-only token (#241); fork_head # records which reconciliation waits for the sweep and that path-derived - # scope labels are not applied there. + # scope labels are not applied there. Non-PR events include workflow_dispatch: + # excluding it would make a dispatched labels caller silently do nothing. # # LOUD on failure — never `|| true`: a red trigger is the # misconfiguration alarm. A consumer that bumps the pin without adding diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index e25621e..ce3d17e 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -434,14 +434,14 @@ 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 a review verdict landing (no - # pull_request_review trigger on the labels caller), blocker:ci-red + # wake for a review verdict landing (the labels caller has 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, plus every state, blocker, and handoff # transition for a fork-headed PR on this Forgejo. The sweep never applies # path-derived scope labels. Issue events and same-repo PR events carry the - # rest in seconds, one trigger-job dispatch away. Hourly - # trades ≤1h of latency on the scheduled classes while cutting nominal + # rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of + # latency on the scheduled classes while cutting nominal # 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; diff --git a/test/labels-triggers.test.sh b/test/labels-triggers.test.sh index 1f5220a..07490b4 100644 --- a/test/labels-triggers.test.sh +++ b/test/labels-triggers.test.sh @@ -154,8 +154,8 @@ check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \ # 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 exactly what the scheduled -# sweep does and does not supply. Compare the whole normalised expressions: a -# substring guard also accepts a future negation that inverts the gate. +# sweep does and does not supply. Require each full normalised expression to +# appear intact, so deleting or inverting one of its clauses fails the guard. job_if_expression() { # $1 = file, $2 = job yq -r ".jobs.$2.if // \"\"" "$1" | tr '\n' ' ' | -- 2.45.2