Merge pull request 'fix: keep fork-headed label runs green' (#256) from build/241-fork-labels into main

Reviewed-on: heavy-duty/ceremony#256
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>
This commit is contained in:
andres 2026-08-25 06:38:18 +00:00
commit 6dc8bf6558
8 changed files with 171 additions and 81 deletions

View file

@ -3,9 +3,11 @@ 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, 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 # 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
@ -24,7 +26,8 @@ name: labels-sweep
# taxonomy (its `bootstrap` input defaults to "yes"), exactly what # taxonomy (its `bootstrap` input defaults to "yes"), exactly what
# dispatching the labels caller did before the split. The trigger job's # dispatching the labels caller did before the split. The trigger job's
# dispatches carry bootstrap=no — ~20 label upserts per sweep is too chatty # 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 # This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
# create workflow runs from GITHUB_TOKEN-raised events (the trigger's # create workflow runs from GITHUB_TOKEN-raised events (the trigger's

View file

@ -2,12 +2,16 @@ 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. The scheduled sweep later
# path mapping via the API and checks out only the ceremony implementation. # reconciles state, blockers, and handoff, but it does not apply path-derived
# Keep it that way. # 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 # 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
@ -19,10 +23,14 @@ name: labels
# one `review_requested` event per panelist per request, so every review # one `review_requested` event per panelist per request, so every review
# round displaces runs and the rate scales with panel size. The # round displaces runs and the rate scales with panel size. The
# 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 instant wake: it fires on every issue event and
# subscribes — the exact surface that used to run reconcile directly — so # same-repository PR event this caller subscribes to, preserving that part of
# the wake latency (#137) is unchanged, while a displaced sweep cancels on # the surface that used to run reconcile directly. Same-repository wake latency
# the Actions tab, attached to no PR. PR checks show scope + trigger only. # (#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 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 # 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 +68,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 +115,12 @@ 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 which reconciliation waits for the sweep and that path-derived
# a dispatched labels caller yields one plain sweep, and the taxonomy # scope labels are not applied there. Non-PR events include workflow_dispatch:
# bootstrap fires solely on a manual dispatch of the sweep caller # excluding it would make a dispatched labels caller silently do nothing.
# (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 +128,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 +208,16 @@ 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:
# 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
runs-on: ubuntu-latest
steps:
- name: explain deferred fork labels
run: >-
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"

View file

@ -7,13 +7,16 @@ 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 state, blocker, and handoff transition for a
# trigger-job dispatch away. Hourly trades ≤1h of latency on those four # fork-headed PR on this Forgejo because its pull_request_target token is
# while cutting nominal scheduled sweeps from four an hour to one at # 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 # 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
# the classes with no other writer bound the cadence; relax it only as that # the classes with no other writer bound the cadence; relax it only as that
@ -22,8 +25,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,12 @@ 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 state, blocker, and handoff
# reconciliation to the hourly sweep; path-derived scope labels are not
# applied to those heads (#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 +30,16 @@ 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, so state, blocker, and handoff
# here in seconds (#11); synchronize re-derives on every push; # reconciliation waits for the scheduled cadence. The sweep does not apply
# review_requested/review_request_removed wake the sweep that clears (or # path-derived scope labels to those heads (#241).
# restores) blocker:unrequested — without them the one event that makes # labeled/unlabeled are the same-repository handoff wake — the author's
# the label false could not clear it, and a quiet repo wore the red flag # optimistic state:needs-human write, confirmed or corrected here in
# until the advisory cron (#137). # 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] 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

@ -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 — `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 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 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 hand-set state). On a same-repository head, the `labeled` event fires the
write within seconds. 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) ## PR blockers — what is in the way? (facts, as many as apply)

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

@ -0,0 +1,3 @@
### Fixed
- Fork-headed label runs stay green without attempting forbidden writes, while same-repository heads keep instant scope and reconciliation wakes (#241).

View file

@ -337,14 +337,18 @@ 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 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, - **`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 +360,10 @@ 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 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 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 +390,14 @@ 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 state, blocker, and handoff reconciliation
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears # waits for the scheduled sweep; path-derived scope labels require a manual
# blocker:unrequested when the panel is asked. # 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.
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,22 +434,24 @@ 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 (the labels caller has no
# pull_request_review trigger on the labels caller), blocker:ci-red # pull_request_review trigger), 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 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 # rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
# latency on those four while cutting nominal scheduled sweeps from four an # latency on the scheduled classes while cutting nominal
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their # sweeps from four an hour to one at GitHub's 1-minute floor. Do not delete
# discovery path. If another engine writes some of those transitions, only # the cron: it is their discovery path. If another engine writes some of
# the classes with no other writer bound the cadence; relax it only as that # those transitions, only the classes with no other writer bound the cadence;
# list shrinks. # 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
# declared input is part of the contract: a dispatch naming an undeclared # event, so the declared input is part of the contract: a dispatch naming an
# input is refused, and the trigger job goes loudly red. # undeclared input is refused, and the trigger job goes loudly red.
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bootstrap: bootstrap:
@ -477,17 +488,22 @@ 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 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 `state:*` axis on the board rather than a red workflow run. The labels
caller's `actions: write` is different — it is required everywhere, public 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 repos included: the trigger job's dispatch is a write. Without it, issue and
event run goes red at the trigger. 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 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 **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 to a #209-carrying tag without adding the sweep caller gets a loud red trigger
silence nowhere — the trigger job goes **red on every PR and issue event** on every issue and same-repository PR event (workflow-not-found; likewise on a
(workflow-not-found; likewise on a sweep caller missing its `bootstrap` sweep caller missing its `bootstrap` input, or a labels caller missing
input, or a labels caller missing `actions: write`), and event-woken sweeps `actions: write`). Fork-headed PR runs deliberately skip that trigger and stay
stop until the caller lands. That loudness is deliberate: never read green, so their correctness is proven by the sweep caller's presence and its
silence, or a green `scope` alone, as health. Make the adoption one atomic latest scheduled run, not by the PR check alone. Never read a green
PR — pin bump, sweep caller file, `actions: write` line together. `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 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 first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt
@ -538,20 +554,25 @@ carrying the split:
`actions: read` today (crew does); the trigger job's dispatch is a `actions: read` today (crew does); the trigger job's dispatch is a
write. The sweep caller keeps `actions: read`. write. The sweep caller keeps `actions: read`.
Bump without the sweep caller and the trigger job goes red on every PR Bump without the sweep caller and the trigger job goes red on every issue and
and issue event — the loud failure mode above — so never split these same-repository PR event. Fork-headed PRs stay green, receive state, blocker,
four edits across PRs. 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: 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
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. 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

@ -150,4 +150,33 @@ 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 exactly what the scheduled
# 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' ' ' |
awk '{$1=$1; print}'
}
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 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 summary