Compare commits

..

No commits in common. "2539998754f98cbc26833460bc40498df32f6cd1" and "ca7ce6e9194d0b427c093cfc74f03eb2c84fe25f" have entirely different histories.

16 changed files with 89 additions and 385 deletions

View file

@ -3,10 +3,9 @@ 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). 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).
# sweep). Board events still yield a sweep within seconds: labels.yml's
# trigger job dispatches this workflow's caller on every event it used to
# run reconcile on.
#
# Detached on purpose (#209): every sweep covers every open PR and all
# sweeps serialize through ONE shared concurrency group, so GitHub's

View file

@ -2,14 +2,12 @@ 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, 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 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 reconcile sweep lived here until #209. Riding the PR-triggered run
# meant every displacement in the sweep's shared concurrency queue recorded
@ -23,11 +21,8 @@ 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
# 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.
# the wake latency (#137) is unchanged, while a displaced sweep cancels on
# the Actions tab, attached to no PR. PR checks show scope + trigger only.
#
# This cannot loop: the trigger's dispatch and the reconciler's label
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
@ -65,7 +60,6 @@ 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' &&
@ -112,10 +106,15 @@ jobs:
CONFIG_REF: ${{ github.sha }}
trigger:
# 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.
# The sweep's wake (#209). No `if:`: reconcile carried none, so the
# trigger keeps the whole event surface the caller subscribes —
# workflow_dispatch of the labels caller itself included. That cannot
# double-fire bootstrap: this dispatch always carries bootstrap=no, so
# a dispatched labels caller yields one plain sweep, and the taxonomy
# bootstrap fires solely on a manual dispatch of the sweep caller
# (whose input defaults to "yes"). Excluding workflow_dispatch here
# would instead make a dispatched labels caller do nothing at all —
# a silent no-op run is worse than a redundant sweep.
#
# LOUD on failure — never `|| true`: a red trigger is the
# misconfiguration alarm. A consumer that bumps the pin without adding
@ -123,9 +122,6 @@ 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
@ -203,13 +199,3 @@ 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"

View file

@ -7,14 +7,12 @@ 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 (there is no
# wake for four transition classes — a review verdict landing (there is no
# pull_request_review trigger on the labels caller), blocker:ci-red set or
# cleared (no check_suite/check_run/workflow_run), a blocker:conflict when
# ANOTHER PR merges under this one, and the time-based stale / 48h
# claim-reclaim, 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
# claim-reclaim. The labels caller's events carry the rest in seconds, one
# trigger-job dispatch away. Hourly trades ≤1h of latency on those four
# while cutting nominal scheduled sweeps from four an hour to one at
# GitHub's 1-minute billing floor. Do not delete the cron: it is their
# discovery path. If another engine writes some of those transitions, only
@ -24,9 +22,8 @@ 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 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
# with bootstrap=no on every board event — an event-woken sweep must not
# re-upsert ~20 labels each time — so declaring this input is part of the
# caller contract: a dispatch naming an undeclared input is refused, and
# the trigger job goes loudly red.
workflow_dispatch:

View file

@ -8,10 +8,8 @@ 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. 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).
# hourly cron and the manual/bootstrap workflow_dispatch. A board event
# below still yields a sweep within seconds, one dispatch hop later.
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/
@ -28,14 +26,14 @@ on:
issues:
types: [opened, closed, edited, reopened]
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.
# 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).
# 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).
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
permissions:
contents: read

View file

@ -33,12 +33,6 @@ set -euo pipefail
# no '## ' heading, and changelog_section extracts the body below HEAD's
# heading — so the date HEAD stamped into its heading never enters the
# 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}}"
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
@ -175,37 +169,6 @@ frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
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
# ceremony deletes exactly what it assembles (#112) — a fragment that
# 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
awk '
{
rest = tolower($0)
while (match(rest, /(^|[^[:alnum:]_-])refs[[:space:]:]+(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/)) {
token = substr(rest, RSTART, RLENGTH)
sub(/^.*refs[[:space:]:]+/, "", token)
print token
# Retain the token final byte so ^ cannot turn a concatenated
# alphanumeric suffix into a fresh keyword boundary (#234).
rest = substr(rest, RSTART + RLENGTH - 1)
line = $0
lower = tolower(line)
if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) {
line = substr(line, RSTART + RLENGTH)
if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
sub(/[.(;].*/, "", line)
print line
}
}
}
' | issue_references \

View file

@ -1,3 +0,0 @@
### 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).

View file

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

View file

@ -1,3 +0,0 @@
### 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).

View file

@ -1,3 +0,0 @@
### 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,16 +337,14 @@ 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.
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
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
`${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). 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.
nothing).
- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
handoff, stale status, the issue work queue, and the `needs-ruling`
invariants on both surfaces — the bare-flag check and the 7-day
@ -358,9 +356,8 @@ 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. 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.
Actions tab, attached to no PR; PR checks show `scope` and the green
`trigger` only.
The consumer keeps its path mapping in `.github/labeler.yml` and its
review panel plus scope taxonomy in `.github/labels.conf`.
@ -387,12 +384,11 @@ The complete event-facing caller is:
name: labels
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.
# 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.
# 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.
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.
@ -429,21 +425,20 @@ 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
# wake for four transition classes — a review verdict landing (no
# pull_request_review trigger on the labels caller), blocker:ci-red
# set/cleared, blocker:conflict when another PR merges under this one, and
# time-based stale / 48h claim-reclaim, 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.
# time-based stale / 48h claim-reclaim. The labels caller's events carry the
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
# latency on those four while cutting nominal scheduled sweeps from four an
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their
# discovery path. If another engine writes some of those transitions, only
# the classes with no other writer bound the cadence; relax it only as that
# list shrinks.
schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep. A bare dispatch (input default "yes") also
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
# wakes this workflow with bootstrap=no on every issue and same-repo PR event, so the
# wakes this workflow with bootstrap=no on every board event, so the
# declared input is part of the contract: a dispatch naming an undeclared
# input is refused, and the trigger job goes loudly red.
workflow_dispatch:
@ -547,18 +542,16 @@ 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: 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.
`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.
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). 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.
`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.
`.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, zero or more optional per-author panel rows, and

View file

@ -84,7 +84,7 @@ forgejo_page_url() {
esac
}
# forge_api [--paginate | --paginate-exhaustive] <endpoint> [--jq <expr>]
# forge_api [--paginate] <endpoint> [--jq <expr>]
#
# --paginate walks page= until a short page, then PROVES the walk was
# complete by comparing what it collected against the server's declared
@ -94,26 +94,11 @@ forgejo_page_url() {
# make the completeness check compare null to a number — the guard itself
# degrading silently, which is the failure class re-entering through the
# 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() {
local paginate=false paginate_exhaustive=false method=GET endpoint="" jqexpr="" have_jq=false
local paginate=false endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do
case "$1" in
--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 ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
@ -121,14 +106,6 @@ forge_api() {
shift
done
[ -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
base="$(forgejo_api_base)" || return 1
@ -139,7 +116,7 @@ forge_api() {
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
trap "rm -f '$hdr' '$body'" RETURN
if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
if [ "$paginate" = false ]; then
if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$endpoint"; then
@ -165,23 +142,21 @@ forge_api() {
fi
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
# 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
# cannot have been atomic, so it is refused rather than reconciled.
local page_total
page_total="$(forgejo_total_count "$hdr")" || return 1
if [ -z "$total" ]; then
total="$page_total"
elif [ "$page_total" != "$total" ]; then
cat >&2 <<EOF
# 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
# 9 stopped at 4 believing itself whole. A moving total means the read
# cannot have been atomic, so it is refused rather than reconciled.
local page_total
page_total="$(forgejo_total_count "$hdr")" || return 1
if [ -z "$total" ]; then
total="$page_total"
elif [ "$page_total" != "$total" ]; then
cat >&2 <<EOF
forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188).
The collection moved under the walk, so no page set can be proven whole.
Refusing rather than reconciling a board that is already out of date.
EOF
return 1
fi
return 1
fi
pagejson="$(cat "$body")"
@ -200,17 +175,13 @@ EOF
[ "$n" -gt 0 ] || break
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
got=$((got + n))
if [ "$paginate_exhaustive" = true ]; then
[ "$n" -eq 50 ] || break
else
[ "$got" -lt "$total" ] || break
fi
[ "$got" -lt "$total" ] || break
page=$((page + 1))
done
# The assert. A short read here is the silent-truncation bug arriving by
# another route, so it is fatal rather than a warning.
if [ "$paginate_exhaustive" = false ] && [ "$got" -ne "$total" ]; then
if [ "$got" -ne "$total" ]; then
cat >&2 <<EOF
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
@ -590,13 +561,7 @@ forge_request_reviewer() {
# empty timeline — the two states the ruling ladder must tell apart (#4853).
forge_timeline() {
local n="${1:?forge_timeline: number required}" raw
# 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
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
jq '
[.[]
| select(.type == "label")

View file

@ -217,49 +217,6 @@ check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
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.
seed_flat halfdone
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"

View file

@ -746,7 +746,6 @@ check "no api base refuses" 1 "cannot reach the forge" \
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
# emitting .user instead of .actor each reds its own case (#4853).
timeline_stub() {
: >"$timeline_calls"
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() {
local hdr="" out="" url=""
@ -754,44 +753,14 @@ timeline_stub() {
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
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"
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
case "$url" in
*timeline*) printf '%s' "$page_body" >"$out" ;;
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
*) printf '[]' >"$out" ;;
esac
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='[
{"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"},
@ -808,61 +777,6 @@ check "forge_timeline drops non-label events" 0 "" \
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
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
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
# shellcheck disable=SC2317

View file

@ -101,28 +101,6 @@ 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)'
check "Refs parser returns only references owned by a valid Refs marker" 0 "" \
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'
check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \
open_pr_issues <<<"$open_records"

View file

@ -150,38 +150,4 @@ 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