forked from heavy-duty/ceremony
Compare commits
19 commits
ca7ce6e919
...
2539998754
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2539998754 | ||
|
|
e639e67f09 | ||
|
|
ffbc1afc3d | ||
|
|
311ef304fc | ||
| e55e99663e | |||
|
|
5823f3d7b7 | ||
|
|
0f3d3b36eb | ||
|
|
a2b9b30930 | ||
| a1bac15a8b | |||
| 46458ba8cd | |||
|
|
1164640a08 | ||
|
|
40ebcea462 | ||
|
|
8c0f5d53d7 | ||
|
|
4fb01e8b9f | ||
|
|
d712f0636f | ||
|
|
5232027361 | ||
|
|
6b2b467b7c | ||
|
|
b105939d95 | ||
|
|
b2048f63bd |
16 changed files with 385 additions and 89 deletions
7
.github/workflows/labels-sweep.yml
vendored
7
.github/workflows/labels-sweep.yml
vendored
|
|
@ -3,9 +3,10 @@ name: labels-sweep
|
||||||
# jobs that rode labels.yml until #209. Triggers and permissions live in the
|
# jobs that rode labels.yml until #209. Triggers and permissions live in the
|
||||||
# caller; docs/CONSUMERS.md carries the complete caller stub
|
# caller; docs/CONSUMERS.md carries the complete caller stub
|
||||||
# (workflow_dispatch plus the hourly cron, which relocated here with the
|
# (workflow_dispatch plus the hourly cron, which relocated here with the
|
||||||
# sweep). Board events still yield a sweep within seconds: labels.yml's
|
# sweep). Issue events and same-repository PR events still yield a sweep within
|
||||||
# trigger job dispatches this workflow's caller on every event it used to
|
# seconds: labels.yml's trigger job dispatches this workflow's caller on those
|
||||||
# run reconcile on.
|
# events. Fork-headed PR events carry a read-only token on this Forgejo and
|
||||||
|
# wait for the caller's scheduled cadence instead (#241).
|
||||||
#
|
#
|
||||||
# Detached on purpose (#209): every sweep covers every open PR and all
|
# Detached on purpose (#209): every sweep covers every open PR and all
|
||||||
# sweeps serialize through ONE shared concurrency group, so GitHub's
|
# sweeps serialize through ONE shared concurrency group, so GitHub's
|
||||||
|
|
|
||||||
48
.github/workflows/labels.yml
vendored
48
.github/workflows/labels.yml
vendored
|
|
@ -2,12 +2,14 @@ name: labels
|
||||||
# Reusable half of the labels automation. Triggers and permissions live in
|
# Reusable half of the labels automation. Triggers and permissions live in
|
||||||
# the caller; docs/CONSUMERS.md carries the complete caller stub.
|
# the caller; docs/CONSUMERS.md carries the complete caller stub.
|
||||||
#
|
#
|
||||||
# The caller uses pull_request_target, not pull_request: every PR in this
|
# The caller uses pull_request_target, not pull_request, so same-repository PRs
|
||||||
# family arrives from a fork, where pull_request runs with a READ-ONLY token
|
# keep the base repository's write token without running PR code. On this
|
||||||
# and cannot label anything. _target is safe in this workflow because no PR
|
# Forgejo, unlike GitHub, fork-headed _target runs still receive a read-only
|
||||||
# code is ever checked out or executed — scope reads changed paths and the
|
# token. Those runs therefore attempt no writes and leave labeling to the
|
||||||
# path mapping via the API and checks out only the ceremony implementation.
|
# scheduled sweep; the explicit fork_head job below records that disposition
|
||||||
# Keep it that way.
|
# as a successful check. Both write paths execute only for same-repository
|
||||||
|
# heads. Scope reads changed paths and the path mapping through the API and
|
||||||
|
# checks out only the ceremony implementation. Keep it that way (#241).
|
||||||
#
|
#
|
||||||
# The reconcile sweep lived here until #209. Riding the PR-triggered run
|
# The reconcile sweep lived here until #209. Riding the PR-triggered run
|
||||||
# meant every displacement in the sweep's shared concurrency queue recorded
|
# meant every displacement in the sweep's shared concurrency queue recorded
|
||||||
|
|
@ -21,8 +23,11 @@ name: labels
|
||||||
# sweep now lives in labels-sweep.yml behind its own caller, and the
|
# sweep now lives in labels-sweep.yml behind its own caller, and the
|
||||||
# trigger job below is its wake: it fires on every event this caller
|
# trigger job below is its wake: it fires on every event this caller
|
||||||
# subscribes — the exact surface that used to run reconcile directly — so
|
# subscribes — the exact surface that used to run reconcile directly — so
|
||||||
# the wake latency (#137) is unchanged, while a displaced sweep cancels on
|
# same-repository wake latency (#137) remains seconds-scale, while a displaced
|
||||||
# the Actions tab, attached to no PR. PR checks show scope + trigger only.
|
# sweep cancels on the Actions tab, attached to no PR. Fork-headed runs cannot
|
||||||
|
# dispatch with their read-only token, so their labels arrive on the scheduled
|
||||||
|
# sweep cadence. PR checks show scope + trigger for same-repository heads, or
|
||||||
|
# fork_head for fork heads.
|
||||||
#
|
#
|
||||||
# This cannot loop: the trigger's dispatch and the reconciler's label
|
# This cannot loop: the trigger's dispatch and the reconciler's label
|
||||||
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
|
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
|
||||||
|
|
@ -60,6 +65,7 @@ jobs:
|
||||||
# scope run is clobbered.
|
# scope run is clobbered.
|
||||||
if: >-
|
if: >-
|
||||||
github.event_name == 'pull_request_target' &&
|
github.event_name == 'pull_request_target' &&
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||||
github.event.action != 'labeled' &&
|
github.event.action != 'labeled' &&
|
||||||
github.event.action != 'unlabeled' &&
|
github.event.action != 'unlabeled' &&
|
||||||
github.event.action != 'review_requested' &&
|
github.event.action != 'review_requested' &&
|
||||||
|
|
@ -106,15 +112,10 @@ jobs:
|
||||||
CONFIG_REF: ${{ github.sha }}
|
CONFIG_REF: ${{ github.sha }}
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
# The sweep's wake (#209). No `if:`: reconcile carried none, so the
|
# The sweep's instant wake (#209) keeps the whole non-PR event surface and
|
||||||
# trigger keeps the whole event surface the caller subscribes —
|
# same-repository PRs. Fork-headed PRs are excluded because this Forgejo
|
||||||
# workflow_dispatch of the labels caller itself included. That cannot
|
# gives their pull_request_target run a read-only token (#241); fork_head
|
||||||
# double-fire bootstrap: this dispatch always carries bootstrap=no, so
|
# records the successful deferral to the scheduled sweep instead.
|
||||||
# a dispatched labels caller yields one plain sweep, and the taxonomy
|
|
||||||
# bootstrap fires solely on a manual dispatch of the sweep caller
|
|
||||||
# (whose input defaults to "yes"). Excluding workflow_dispatch here
|
|
||||||
# would instead make a dispatched labels caller do nothing at all —
|
|
||||||
# a silent no-op run is worse than a redundant sweep.
|
|
||||||
#
|
#
|
||||||
# LOUD on failure — never `|| true`: a red trigger is the
|
# LOUD on failure — never `|| true`: a red trigger is the
|
||||||
# misconfiguration alarm. A consumer that bumps the pin without adding
|
# misconfiguration alarm. A consumer that bumps the pin without adding
|
||||||
|
|
@ -122,6 +123,9 @@ jobs:
|
||||||
# `bootstrap` input (unexpected input), or without `actions: write`
|
# `bootstrap` input (unexpected input), or without `actions: write`
|
||||||
# on this caller (permission denied) fails HERE, visibly on the PR,
|
# on this caller (permission denied) fails HERE, visibly on the PR,
|
||||||
# instead of silently never sweeping again.
|
# instead of silently never sweeping again.
|
||||||
|
if: >-
|
||||||
|
github.event_name != 'pull_request_target' ||
|
||||||
|
github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: dispatch the sweep
|
- name: dispatch the sweep
|
||||||
|
|
@ -199,3 +203,13 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"
|
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"
|
||||||
|
|
||||||
|
fork_head:
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'pull_request_target' &&
|
||||||
|
github.event.pull_request.head.repo.full_name != github.repository
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: explain deferred fork labels
|
||||||
|
run: >-
|
||||||
|
echo "labels: fork head has a read-only token; writes deferred to the scheduled sweep cadence"
|
||||||
|
|
|
||||||
13
.github/workflows/self-labels-sweep.yml
vendored
13
.github/workflows/self-labels-sweep.yml
vendored
|
|
@ -7,12 +7,14 @@ name: labels-sweep
|
||||||
on:
|
on:
|
||||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
# The consumer owns this cadence (#203). Hourly is the recommended default
|
||||||
# when no other engine drives board state: the cron is then the sweep's ONLY
|
# when no other engine drives board state: the cron is then the sweep's ONLY
|
||||||
# wake for four transition classes — a review verdict landing (there is no
|
# wake for a review verdict landing (there is no
|
||||||
# pull_request_review trigger on the labels caller), blocker:ci-red set or
|
# pull_request_review trigger on the labels caller), blocker:ci-red set or
|
||||||
# cleared (no check_suite/check_run/workflow_run), a blocker:conflict when
|
# cleared (no check_suite/check_run/workflow_run), a blocker:conflict when
|
||||||
# ANOTHER PR merges under this one, and the time-based stale / 48h
|
# ANOTHER PR merges under this one, and the time-based stale / 48h
|
||||||
# claim-reclaim. The labels caller's events carry the rest in seconds, one
|
# claim-reclaim, plus every fork-headed PR transition on this Forgejo because
|
||||||
# trigger-job dispatch away. Hourly trades ≤1h of latency on those four
|
# its pull_request_target token is read-only (#241). Issue events and
|
||||||
|
# same-repository PR events carry the rest in seconds, one trigger-job
|
||||||
|
# dispatch away. Hourly trades ≤1h of latency on the scheduled classes
|
||||||
# while cutting nominal scheduled sweeps from four an hour to one at
|
# while cutting nominal scheduled sweeps from four an hour to one at
|
||||||
# GitHub's 1-minute billing floor. Do not delete the cron: it is their
|
# GitHub's 1-minute billing floor. Do not delete the cron: it is their
|
||||||
# discovery path. If another engine writes some of those transitions, only
|
# discovery path. If another engine writes some of those transitions, only
|
||||||
|
|
@ -22,8 +24,9 @@ on:
|
||||||
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
||||||
# bootstraps the taxonomy on a fresh repo — what dispatching the labels
|
# bootstraps the taxonomy on a fresh repo — what dispatching the labels
|
||||||
# caller did before #209. The reusable's trigger job wakes this workflow
|
# caller did before #209. The reusable's trigger job wakes this workflow
|
||||||
# with bootstrap=no on every board event — an event-woken sweep must not
|
# with bootstrap=no on every issue and same-repository PR event — an
|
||||||
# re-upsert ~20 labels each time — so declaring this input is part of the
|
# event-woken sweep must not re-upsert ~20 labels each time — so declaring
|
||||||
|
# this input is part of the
|
||||||
# caller contract: a dispatch naming an undeclared input is refused, and
|
# caller contract: a dispatch naming an undeclared input is refused, and
|
||||||
# the trigger job goes loudly red.
|
# the trigger job goes loudly red.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
|
||||||
22
.github/workflows/self-labels.yml
vendored
22
.github/workflows/self-labels.yml
vendored
|
|
@ -8,8 +8,10 @@ name: labels
|
||||||
# Since #209 this caller carries the PR/issue event surface only. The
|
# Since #209 this caller carries the PR/issue event surface only. The
|
||||||
# reconcile sweep no longer rides these runs — the reusable's trigger job
|
# reconcile sweep no longer rides these runs — the reusable's trigger job
|
||||||
# dispatches the sweep caller (self-labels-sweep.yml here), which owns the
|
# dispatches the sweep caller (self-labels-sweep.yml here), which owns the
|
||||||
# hourly cron and the manual/bootstrap workflow_dispatch. A board event
|
# hourly cron and the manual/bootstrap workflow_dispatch. Issue events and
|
||||||
# below still yields a sweep within seconds, one dispatch hop later.
|
# same-repository PR events below still yield a sweep within seconds, one
|
||||||
|
# dispatch hop later. Fork-headed PRs carry a read-only token on this Forgejo,
|
||||||
|
# so their successful labels run leaves writes to the hourly sweep (#241).
|
||||||
on:
|
on:
|
||||||
# Narrowed (#199) to the actions that carry a queue-state change the hourly
|
# Narrowed (#199) to the actions that carry a queue-state change the hourly
|
||||||
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
|
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
|
||||||
|
|
@ -26,14 +28,14 @@ on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened, closed, edited, reopened]
|
types: [opened, closed, edited, reopened]
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
# Every PR arrives from a fork, so these carry the head/draft/review facts
|
# These carry the head/draft/review facts the sweep derives state:* from.
|
||||||
# the sweep derives state:* from. labeled/unlabeled are the handoff wake —
|
# Same-repository heads wake that sweep in seconds; fork heads cannot write
|
||||||
# the author's optimistic state:needs-human write, confirmed or corrected
|
# with this Forgejo's read-only token and wait for its scheduled cadence.
|
||||||
# here in seconds (#11); synchronize re-derives on every push;
|
# labeled/unlabeled are the same-repository handoff wake — the author's
|
||||||
# review_requested/review_request_removed wake the sweep that clears (or
|
# optimistic state:needs-human write, confirmed or corrected here in
|
||||||
# restores) blocker:unrequested — without them the one event that makes
|
# seconds (#11); synchronize re-derives on every push; review_requested/
|
||||||
# the label false could not clear it, and a quiet repo wore the red flag
|
# review_request_removed clear (or restore) blocker:unrequested on that
|
||||||
# until the advisory cron (#137).
|
# same instant path (#137).
|
||||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ set -euo pipefail
|
||||||
# no '## ' heading, and changelog_section extracts the body below HEAD's
|
# no '## ' heading, and changelog_section extracts the body below HEAD's
|
||||||
# heading — so the date HEAD stamped into its heading never enters the
|
# heading — so the date HEAD stamped into its heading never enters the
|
||||||
# comparison, and a date difference can never masquerade as a prose one.
|
# comparison, and a date difference can never masquerade as a prose one.
|
||||||
|
#
|
||||||
|
# This guard narrows, but cannot close, the target-movement window: it sees a
|
||||||
|
# fragment present when CI reads the target ref, but one can still land after
|
||||||
|
# the final run and before merge. Requiring release PRs to be up to date with
|
||||||
|
# their target branch before merge is the repository setting that closes that
|
||||||
|
# residual window (#253).
|
||||||
|
|
||||||
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
|
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
|
||||||
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
|
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
|
||||||
|
|
@ -169,6 +175,37 @@ frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
|
||||||
|
|
||||||
failures=0
|
failures=0
|
||||||
|
|
||||||
|
# Refusal: the target branch gained a fragment after this release PR's merge
|
||||||
|
# base, so the ceremony could not have consumed it. Merging this tree would
|
||||||
|
# strand that fragment for the next release and misattribute when it shipped.
|
||||||
|
stranded=""
|
||||||
|
while IFS= read -r -d '' entry; do
|
||||||
|
meta="${entry%%$'\t'*}"
|
||||||
|
path="${entry#*$'\t'}"
|
||||||
|
otype="$(printf '%s\n' "$meta" | awk '{ print $2 }')"
|
||||||
|
name="${path##*/}"
|
||||||
|
case "$otype:$name" in
|
||||||
|
blob:README.md) ;;
|
||||||
|
blob:*.md)
|
||||||
|
if ! printf '%s' "$base_frags" | grep -Fxq "$path"; then
|
||||||
|
stranded="${stranded} ${path}"$'\n'
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(git ls-tree -z "$base_ref" -- "$dir/")
|
||||||
|
if [ -n "$stranded" ]; then
|
||||||
|
{
|
||||||
|
echo "changelog-assembled: fragment(s) on target '$base_ref' were not consumed by this release PR:"
|
||||||
|
echo
|
||||||
|
printf '%s' "$stranded"
|
||||||
|
echo
|
||||||
|
echo " Merging now would strand these entries for the next release and"
|
||||||
|
echo " misattribute when they shipped."
|
||||||
|
echo " Fix: rebase onto the target head and re-run bin/changelog-assemble '$ver'."
|
||||||
|
} >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
# Refusal: a fragment the ceremony consumed is still present on HEAD. The
|
# Refusal: a fragment the ceremony consumed is still present on HEAD. The
|
||||||
# ceremony deletes exactly what it assembles (#112) — a fragment that
|
# ceremony deletes exactly what it assembles (#112) — a fragment that
|
||||||
# survives its own release sits in the directory and is assembled AGAIN
|
# survives its own release sits in the directory and is assembled AGAIN
|
||||||
|
|
|
||||||
|
|
@ -208,14 +208,14 @@ claim_reclaim_marker() { # $1 = last activity epoch
|
||||||
refs_references() { # PR body on stdin -> local issue numbers named by Refs
|
refs_references() { # PR body on stdin -> local issue numbers named by Refs
|
||||||
awk '
|
awk '
|
||||||
{
|
{
|
||||||
line = $0
|
rest = tolower($0)
|
||||||
lower = tolower(line)
|
while (match(rest, /(^|[^[:alnum:]_-])refs[[:space:]:]+(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/)) {
|
||||||
if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) {
|
token = substr(rest, RSTART, RLENGTH)
|
||||||
line = substr(line, RSTART + RLENGTH)
|
sub(/^.*refs[[:space:]:]+/, "", token)
|
||||||
if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
|
print token
|
||||||
sub(/[.(;].*/, "", line)
|
# Retain the token final byte so ^ cannot turn a concatenated
|
||||||
print line
|
# alphanumeric suffix into a fresh keyword boundary (#234).
|
||||||
}
|
rest = substr(rest, RSTART + RLENGTH - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
' | issue_references \
|
' | issue_references \
|
||||||
|
|
|
||||||
3
changelog.d/234.md
Normal file
3
changelog.d/234.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Refs-based issue-flow transitions now bind each declaration to its immediately following reference token, so later issue prose cannot release or preserve unrelated claims (#234).
|
||||||
3
changelog.d/240.md
Normal file
3
changelog.d/240.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Read Forgejo timelines to exhaustion so busy issues retain their newest label events despite dishonest total-count headers (#240).
|
||||||
3
changelog.d/241.md
Normal file
3
changelog.d/241.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fork-headed label runs now stay green without attempting writes their read-only token cannot make, while same-repository heads keep instant labeling (#241).
|
||||||
3
changelog.d/253.md
Normal file
3
changelog.d/253.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Release checks now refuse a target-head fragment that the candidate did not consume, preventing late merges from misattributing shipped changes (#253).
|
||||||
|
|
@ -337,14 +337,16 @@ The labels automation is two reusable workflows since #209, adopted
|
||||||
together at the same pin:
|
together at the same pin:
|
||||||
|
|
||||||
- **`labels.yml`** — the event-facing half, called on PR and issue events.
|
- **`labels.yml`** — the event-facing half, called on PR and issue events.
|
||||||
Two jobs: additive path-based `scope:*` labels, and a few-seconds
|
Same-repository PRs keep two write-capable jobs: additive path-based
|
||||||
`trigger` job that wakes the sweep by dispatching the consumer's sweep
|
`scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by
|
||||||
caller (a REST `POST` to the forge's own
|
dispatching the consumer's sweep caller (a REST `POST` to the forge's own
|
||||||
`${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`,
|
`${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`,
|
||||||
plain `GITHUB_TOKEN` — `workflow_dispatch` is
|
plain `GITHUB_TOKEN` — `workflow_dispatch` is
|
||||||
one of the two documented exemptions from the token's no-retrigger rule,
|
one of the two documented exemptions from the token's no-retrigger rule,
|
||||||
so no PAT anywhere in the path and no loop: the sweep dispatches
|
so no PAT anywhere in the path and no loop: the sweep dispatches
|
||||||
nothing).
|
nothing). On this Forgejo a fork-headed `pull_request_target` token is
|
||||||
|
read-only, so those two jobs do not run; a successful `fork_head` job names
|
||||||
|
the deferral, and the scheduled sweep owns their labels.
|
||||||
- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
|
- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
|
||||||
handoff, stale status, the issue work queue, and the `needs-ruling`
|
handoff, stale status, the issue work queue, and the `needs-ruling`
|
||||||
invariants on both surfaces — the bare-flag check and the 7-day
|
invariants on both surfaces — the bare-flag check and the 7-day
|
||||||
|
|
@ -356,8 +358,9 @@ together at the same pin:
|
||||||
as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun`
|
as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun`
|
||||||
and its `--failed`/`--job` forms all decline a queue-displaced run).
|
and its `--failed`/`--job` forms all decline a queue-displaced run).
|
||||||
Behind its own caller, a displaced sweep cancels on the
|
Behind its own caller, a displaced sweep cancels on the
|
||||||
Actions tab, attached to no PR; PR checks show `scope` and the green
|
Actions tab, attached to no PR. Same-repository PR checks show `scope` and
|
||||||
`trigger` only.
|
the green `trigger`; fork-headed PRs show the green `fork_head` disposition
|
||||||
|
and wait for the scheduled sweep cadence.
|
||||||
|
|
||||||
The consumer keeps its path mapping in `.github/labeler.yml` and its
|
The consumer keeps its path mapping in `.github/labeler.yml` and its
|
||||||
review panel plus scope taxonomy in `.github/labels.conf`.
|
review panel plus scope taxonomy in `.github/labels.conf`.
|
||||||
|
|
@ -384,11 +387,12 @@ The complete event-facing caller is:
|
||||||
name: labels
|
name: labels
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
# Fork PRs; these carry the head/draft/review facts state:* derives from.
|
# These carry the head/draft/review facts state:* derives from. Same-repo
|
||||||
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here);
|
# heads take the instant write + sweep-dispatch path; this Forgejo gives
|
||||||
# synchronize re-derives on every push. review_requested/review_request_removed
|
# fork heads a read-only token, so they wait for the scheduled sweep.
|
||||||
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears
|
# labeled/unlabeled are the same-repo handoff wake; synchronize re-derives
|
||||||
# blocker:unrequested when the panel is asked.
|
# on every push. review_requested/review_request_removed shipped in 0.3.0
|
||||||
|
# (ceremony#137) and wake the same-repo sweep when the panel is asked.
|
||||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||||
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
|
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
|
||||||
# consumer pinned to 0.1.0 omits this block.
|
# consumer pinned to 0.1.0 omits this block.
|
||||||
|
|
@ -425,20 +429,21 @@ name: labels-sweep
|
||||||
on:
|
on:
|
||||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
# The consumer owns this cadence (#203). Hourly is the recommended default
|
||||||
# when no other engine drives board state: the cron is then the sweep's only
|
# when no other engine drives board state: the cron is then the sweep's only
|
||||||
# wake for four transition classes — a review verdict landing (no
|
# wake for a review verdict landing (no
|
||||||
# pull_request_review trigger on the labels caller), blocker:ci-red
|
# pull_request_review trigger on the labels caller), blocker:ci-red
|
||||||
# set/cleared, blocker:conflict when another PR merges under this one, and
|
# set/cleared, blocker:conflict when another PR merges under this one, and
|
||||||
# time-based stale / 48h claim-reclaim. The labels caller's events carry the
|
# time-based stale / 48h claim-reclaim, plus every fork-headed PR transition
|
||||||
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
|
# on this Forgejo. Issue events and same-repo PR
|
||||||
# latency on those four while cutting nominal scheduled sweeps from four an
|
# events carry the rest in seconds, one trigger-job dispatch away. Hourly
|
||||||
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their
|
# trades ≤1h of latency on the scheduled classes while cutting nominal
|
||||||
# discovery path. If another engine writes some of those transitions, only
|
# sweeps from four an hour to one at GitHub's 1-minute floor. Do not delete
|
||||||
# the classes with no other writer bound the cadence; relax it only as that
|
# the cron: it is their discovery path. If another engine writes some of
|
||||||
# list shrinks.
|
# those transitions, only the classes with no other writer bound the cadence;
|
||||||
|
# relax it only as that list shrinks.
|
||||||
schedule: [{cron: "0 * * * *"}]
|
schedule: [{cron: "0 * * * *"}]
|
||||||
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
||||||
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
|
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
|
||||||
# wakes this workflow with bootstrap=no on every board event, so the
|
# wakes this workflow with bootstrap=no on every issue and same-repo PR event, so the
|
||||||
# declared input is part of the contract: a dispatch naming an undeclared
|
# declared input is part of the contract: a dispatch naming an undeclared
|
||||||
# input is refused, and the trigger job goes loudly red.
|
# input is refused, and the trigger job goes loudly red.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
@ -542,16 +547,18 @@ Bump without the sweep caller and the trigger job goes red on every PR
|
||||||
and issue event — the loud failure mode above — so never split these
|
and issue event — the loud failure mode above — so never split these
|
||||||
four edits across PRs.
|
four edits across PRs.
|
||||||
|
|
||||||
`pull_request_target` is intentional: fork PRs need the base repository's
|
`pull_request_target` is intentional: same-repository PRs keep the base
|
||||||
token to write labels. The reusable workflows execute no PR code. They check
|
repository's write token without executing PR code. This Forgejo still gives
|
||||||
out only the consumer's base branch and the pinned ceremony implementation.
|
fork-headed `_target` runs a read-only token, so they attempt no writes and
|
||||||
|
leave labels to the scheduled sweep. The reusable workflows check out only the
|
||||||
|
consumer's base branch and the pinned ceremony implementation.
|
||||||
The #52 ruling invariants ride exactly these triggers — but the caller above
|
The #52 ruling invariants ride exactly these triggers — but the caller above
|
||||||
is no longer the #18 shape, so adopting current triggers is a stub edit, not
|
is no longer the #18 shape, so adopting current triggers is a stub edit, not
|
||||||
a bare pin bump. `review_requested` and `review_request_removed` on
|
a bare pin bump. `review_requested` and `review_request_removed` on
|
||||||
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that
|
`pull_request_target:` shipped in `0.3.0` (ceremony#137). It clears
|
||||||
clears `blocker:unrequested` the moment the panel is asked, without which a
|
`blocker:unrequested` the moment the panel is asked on a same-repository head;
|
||||||
quiet repo wears that flag until the backstop cron; a consumer picks them up
|
fork heads wait for the sweep cadence on this Forgejo. A consumer picks the
|
||||||
by pinning `0.3.0` or later, never through mixed refs.
|
events up by pinning `0.3.0` or later, never through mixed refs.
|
||||||
|
|
||||||
`.github/labels.conf` has one mandatory panel setting, one mandatory
|
`.github/labels.conf` has one mandatory panel setting, one mandatory
|
||||||
`triage-actors` setting, zero or more optional per-author panel rows, and
|
`triage-actors` setting, zero or more optional per-author panel rows, and
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ forgejo_page_url() {
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# forge_api [--paginate] <endpoint> [--jq <expr>]
|
# forge_api [--paginate | --paginate-exhaustive] <endpoint> [--jq <expr>]
|
||||||
#
|
#
|
||||||
# --paginate walks page= until a short page, then PROVES the walk was
|
# --paginate walks page= until a short page, then PROVES the walk was
|
||||||
# complete by comparing what it collected against the server's declared
|
# complete by comparing what it collected against the server's declared
|
||||||
|
|
@ -94,11 +94,26 @@ forgejo_page_url() {
|
||||||
# make the completeness check compare null to a number — the guard itself
|
# make the completeness check compare null to a number — the guard itself
|
||||||
# degrading silently, which is the failure class re-entering through the
|
# degrading silently, which is the failure class re-entering through the
|
||||||
# door built to stop it.
|
# door built to stop it.
|
||||||
|
#
|
||||||
|
# --paginate-exhaustive is the narrow alternative for an endpoint whose
|
||||||
|
# x-total-count is known not to describe the collection. It proves completion
|
||||||
|
# by reading through the first short page and never consults that header.
|
||||||
forge_api() {
|
forge_api() {
|
||||||
local paginate=false endpoint="" jqexpr="" have_jq=false
|
local paginate=false paginate_exhaustive=false method=GET endpoint="" jqexpr="" have_jq=false
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--paginate) paginate=true ;;
|
--paginate) paginate=true ;;
|
||||||
|
--paginate-exhaustive) paginate_exhaustive=true ;;
|
||||||
|
-X | --method)
|
||||||
|
[ "$#" -ge 2 ] || { echo "forge_api: $1 requires a value" >&2; return 1; }
|
||||||
|
method="$2"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-X?*) method="${1#-X}" ;;
|
||||||
|
--method=*)
|
||||||
|
method="${1#*=}"
|
||||||
|
[ -n "$method" ] || { echo "forge_api: --method requires a value" >&2; return 1; }
|
||||||
|
;;
|
||||||
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
||||||
-*) ;;
|
-*) ;;
|
||||||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
||||||
|
|
@ -106,6 +121,14 @@ forge_api() {
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
||||||
|
if [ "$paginate" = true ] && [ "$paginate_exhaustive" = true ]; then
|
||||||
|
echo "forge_api: --paginate and --paginate-exhaustive are mutually exclusive" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if { [ "$paginate" = true ] || [ "$paginate_exhaustive" = true ]; } && [ "$method" != GET ]; then
|
||||||
|
echo "forge_api: pagination is available only for GET requests" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
local base token
|
local base token
|
||||||
base="$(forgejo_api_base)" || return 1
|
base="$(forgejo_api_base)" || return 1
|
||||||
|
|
@ -116,7 +139,7 @@ forge_api() {
|
||||||
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
|
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
|
||||||
trap "rm -f '$hdr' '$body'" RETURN
|
trap "rm -f '$hdr' '$body'" RETURN
|
||||||
|
|
||||||
if [ "$paginate" = false ]; then
|
if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
|
||||||
if ! curl -sS -D "$hdr" -o "$body" \
|
if ! curl -sS -D "$hdr" -o "$body" \
|
||||||
-H "Authorization: token $token" -H 'Accept: application/json' \
|
-H "Authorization: token $token" -H 'Accept: application/json' \
|
||||||
"$base/$endpoint"; then
|
"$base/$endpoint"; then
|
||||||
|
|
@ -142,6 +165,7 @@ forge_api() {
|
||||||
fi
|
fi
|
||||||
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
|
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
|
||||||
|
|
||||||
|
if [ "$paginate_exhaustive" = false ]; then
|
||||||
# Re-read on EVERY page, not once (#4712). A board that changes size
|
# Re-read on EVERY page, not once (#4712). A board that changes size
|
||||||
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
|
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
|
||||||
# 9 stopped at 4 believing itself whole. A moving total means the read
|
# 9 stopped at 4 believing itself whole. A moving total means the read
|
||||||
|
|
@ -158,6 +182,7 @@ forge_api: the declared total for '$endpoint' changed between pages — $total t
|
||||||
EOF
|
EOF
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
pagejson="$(cat "$body")"
|
pagejson="$(cat "$body")"
|
||||||
# A 200 whose body is not a collection counted as zero items (#4712),
|
# A 200 whose body is not a collection counted as zero items (#4712),
|
||||||
|
|
@ -175,13 +200,17 @@ EOF
|
||||||
[ "$n" -gt 0 ] || break
|
[ "$n" -gt 0 ] || break
|
||||||
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
|
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
|
||||||
got=$((got + n))
|
got=$((got + n))
|
||||||
|
if [ "$paginate_exhaustive" = true ]; then
|
||||||
|
[ "$n" -eq 50 ] || break
|
||||||
|
else
|
||||||
[ "$got" -lt "$total" ] || break
|
[ "$got" -lt "$total" ] || break
|
||||||
|
fi
|
||||||
page=$((page + 1))
|
page=$((page + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
# The assert. A short read here is the silent-truncation bug arriving by
|
# The assert. A short read here is the silent-truncation bug arriving by
|
||||||
# another route, so it is fatal rather than a warning.
|
# another route, so it is fatal rather than a warning.
|
||||||
if [ "$got" -ne "$total" ]; then
|
if [ "$paginate_exhaustive" = false ] && [ "$got" -ne "$total" ]; then
|
||||||
cat >&2 <<EOF
|
cat >&2 <<EOF
|
||||||
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
|
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
|
||||||
Refusing rather than reconciling a partial board: a sweep over part of the
|
Refusing rather than reconciling a partial board: a sweep over part of the
|
||||||
|
|
@ -561,7 +590,13 @@ forge_request_reviewer() {
|
||||||
# empty timeline — the two states the ruling ladder must tell apart (#4853).
|
# empty timeline — the two states the ruling ladder must tell apart (#4853).
|
||||||
forge_timeline() {
|
forge_timeline() {
|
||||||
local n="${1:?forge_timeline: number required}" raw
|
local n="${1:?forge_timeline: number required}" raw
|
||||||
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
|
# Measured on this instance: limit=10 reports x-total-count=10 and limit=50
|
||||||
|
# reports 50, while crew!96 held 151 events and strict pagination returned
|
||||||
|
# only its first 50. No other measured endpoint echoes its page size this
|
||||||
|
# way. Timelines are append-only, so exhaustion can include concurrent new
|
||||||
|
# events but cannot create a deletion hole; that is why only this call site
|
||||||
|
# may bypass the header-bound completeness proof (#240).
|
||||||
|
raw="$(forge_api --paginate-exhaustive "repos/$REPO/issues/$n/timeline")" || return 1
|
||||||
jq '
|
jq '
|
||||||
[.[]
|
[.[]
|
||||||
| select(.type == "label")
|
| select(.type == "label")
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,49 @@ check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
|
||||||
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
|
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
|
||||||
run survivor base
|
run survivor base
|
||||||
|
|
||||||
|
# A release PR can be faithful to its merge base while the target branch moves
|
||||||
|
# ahead and gains a fragment during review. That target-only fragment was not
|
||||||
|
# available to the ceremony, so merging the PR would strand it for the next
|
||||||
|
# release. The guard must read the target ref as well as their merge base.
|
||||||
|
seed_flat target-stranded
|
||||||
|
ceremony target-stranded 0.2.0 2026-07-24
|
||||||
|
commit_head target-stranded
|
||||||
|
git -C "$TMP/target-stranded" switch -q base
|
||||||
|
printf -- '- Landed while the release was under review (#30).\n' \
|
||||||
|
>"$TMP/target-stranded/changelog.d/30.md"
|
||||||
|
git -C "$TMP/target-stranded" add -A
|
||||||
|
git -C "$TMP/target-stranded" commit -qm target-fragment
|
||||||
|
git -C "$TMP/target-stranded" switch -q main
|
||||||
|
check "a target-head fragment the release did not consume fails" 1 \
|
||||||
|
"changelog.d/30.md" run target-stranded base
|
||||||
|
check "the target-stranding refusal names the rebase remedy" 1 \
|
||||||
|
"rebase onto the target head" run target-stranded base
|
||||||
|
check "the target-stranding refusal names the assembler re-run" 1 \
|
||||||
|
"changelog-assemble '0.2.0'" run target-stranded base
|
||||||
|
|
||||||
|
# Removing the target-only fragment makes the same diverged fixture green:
|
||||||
|
# target drift itself is not the failure, only a stranded fragment is.
|
||||||
|
git -C "$TMP/target-stranded" switch -q base
|
||||||
|
rm "$TMP/target-stranded/changelog.d/30.md"
|
||||||
|
git -C "$TMP/target-stranded" add -A
|
||||||
|
git -C "$TMP/target-stranded" commit -qm target-fragment-removed
|
||||||
|
git -C "$TMP/target-stranded" switch -q main
|
||||||
|
check "the same target fixture is green once no fragment is stranded" 0 \
|
||||||
|
"byte-for-byte" run target-stranded base
|
||||||
|
|
||||||
|
# Spell out the common harmless case independently: the target branch moved,
|
||||||
|
# but the advancing commit added no fragment.
|
||||||
|
seed_flat target-unrelated
|
||||||
|
ceremony target-unrelated 0.2.0 2026-07-24
|
||||||
|
commit_head target-unrelated
|
||||||
|
git -C "$TMP/target-unrelated" switch -q base
|
||||||
|
printf 'unrelated target change\n' >"$TMP/target-unrelated/code.txt"
|
||||||
|
git -C "$TMP/target-unrelated" add -A
|
||||||
|
git -C "$TMP/target-unrelated" commit -qm target-unrelated
|
||||||
|
git -C "$TMP/target-unrelated" switch -q main
|
||||||
|
check "a target head advanced without a fragment stays green" 0 \
|
||||||
|
"byte-for-byte" run target-unrelated base
|
||||||
|
|
||||||
# Fragments consumed, section never stamped: the prose went nowhere.
|
# Fragments consumed, section never stamped: the prose went nowhere.
|
||||||
seed_flat halfdone
|
seed_flat halfdone
|
||||||
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"
|
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"
|
||||||
|
|
|
||||||
|
|
@ -746,6 +746,7 @@ check "no api base refuses" 1 "cannot reach the forge" \
|
||||||
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
|
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
|
||||||
# emitting .user instead of .actor each reds its own case (#4853).
|
# emitting .user instead of .actor each reds its own case (#4853).
|
||||||
timeline_stub() {
|
timeline_stub() {
|
||||||
|
: >"$timeline_calls"
|
||||||
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
|
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
|
||||||
curl() {
|
curl() {
|
||||||
local hdr="" out="" url=""
|
local hdr="" out="" url=""
|
||||||
|
|
@ -753,14 +754,44 @@ timeline_stub() {
|
||||||
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
|
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
|
printf '%s\n' "$url" >>"$timeline_calls"
|
||||||
|
local page=1 page_body="$FAKE_TIMELINE" page_total="${FAKE_TL_N:-2}"
|
||||||
|
case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac
|
||||||
|
if [ "${#FAKE_TL_PAGES[@]}" -gt 0 ]; then
|
||||||
|
if [ "$page" -le "${#FAKE_TL_PAGES[@]}" ]; then
|
||||||
|
page_body="${FAKE_TL_PAGES[$((page - 1))]}"
|
||||||
|
else
|
||||||
|
page_body='[]'
|
||||||
|
fi
|
||||||
|
page_total="$(jq 'length' <<<"$page_body")"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
printf 'HTTP/1.1 200 OK\r\n'
|
||||||
|
[ "${FAKE_TL_HEADERS:-yes}" = no ] || printf 'X-Total-Count: %s\r\n' "$page_total"
|
||||||
|
printf '\r\n'
|
||||||
|
} >"$hdr"
|
||||||
case "$url" in
|
case "$url" in
|
||||||
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
|
*timeline*) printf '%s' "$page_body" >"$out" ;;
|
||||||
*) printf '[]' >"$out" ;;
|
*) printf '[]' >"$out" ;;
|
||||||
esac
|
esac
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
timeline_page() {
|
||||||
|
jq -nc --argjson first "$1" --argjson count "$2" '
|
||||||
|
[range($first; $first + $count)
|
||||||
|
| {
|
||||||
|
type: "label",
|
||||||
|
body: "1",
|
||||||
|
user: {login: "setter"},
|
||||||
|
label: {name: "needs-ruling"},
|
||||||
|
created_at: ("event-" + tostring)
|
||||||
|
}]
|
||||||
|
'
|
||||||
|
}
|
||||||
|
timeline_calls="$TMP/timeline_calls"
|
||||||
|
FAKE_TL_PAGES=()
|
||||||
|
FAKE_TL_HEADERS=yes
|
||||||
FAKE_TIMELINE='[
|
FAKE_TIMELINE='[
|
||||||
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
|
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
|
||||||
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
|
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
|
||||||
|
|
@ -777,6 +808,61 @@ check "forge_timeline drops non-label events" 0 "" \
|
||||||
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
|
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
|
||||||
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
|
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
|
||||||
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
|
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
|
||||||
|
|
||||||
|
# Forgejo's timeline endpoint lies consistently: x-total-count echoes the
|
||||||
|
# current page size. With 151 events its pages declare 50, 50, 50 and 1, so
|
||||||
|
# strict pagination stops successfully after page 1 and drops the newest 101
|
||||||
|
# events. Exhaustion is safe only here because timelines are append-only.
|
||||||
|
FAKE_TL_PAGES=(
|
||||||
|
"$(timeline_page 1 50)"
|
||||||
|
"$(timeline_page 51 50)"
|
||||||
|
"$(timeline_page 101 50)"
|
||||||
|
"$(timeline_page 151 1)"
|
||||||
|
)
|
||||||
|
timeline_stub
|
||||||
|
tl="$(REPO=o/r forge_timeline 188)"
|
||||||
|
check "forge_timeline exhausts all pages despite per-page total headers" 0 "" \
|
||||||
|
test "$(jq 'length' <<<"$tl")" = 151
|
||||||
|
check "forge_timeline retains the newest event beyond page one" 0 "" \
|
||||||
|
jq -e 'any(.[]; .created_at == "event-151")' <<<"$tl" >/dev/null
|
||||||
|
check "a short final page terminates without an extra empty-page read" 0 "" \
|
||||||
|
test "$(wc -l <"$timeline_calls")" = 4
|
||||||
|
|
||||||
|
# A collection exactly divisible by the page size needs one final empty read;
|
||||||
|
# stopping after the second full page cannot prove exhaustion.
|
||||||
|
FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 50)")
|
||||||
|
timeline_stub
|
||||||
|
exhaustive_count="$(REPO=o/r forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length')"
|
||||||
|
check "exhaustive pagination terminates after an empty page" 0 "" \
|
||||||
|
test "$exhaustive_count" = 100
|
||||||
|
check "an exactly-full exhaustive gather reads the empty third page" 0 "" \
|
||||||
|
test "$(wc -l <"$timeline_calls")" = 3
|
||||||
|
|
||||||
|
# The exhaustive path's completeness proof is the short page itself; it must
|
||||||
|
# never consult the endpoint's missing or dishonest total header.
|
||||||
|
FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 1)")
|
||||||
|
FAKE_TL_HEADERS=no
|
||||||
|
timeline_stub
|
||||||
|
check "exhaustive pagination needs no x-total-count header" 0 "" \
|
||||||
|
eq 51 forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length'
|
||||||
|
check "the same missing-header fixture is still refused by strict pagination" 1 \
|
||||||
|
"did not send x-total-count" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues/188/timeline' --jq 'length'
|
||||||
|
FAKE_TL_HEADERS=yes
|
||||||
|
|
||||||
|
check "strict and exhaustive pagination are mutually exclusive" 1 "mutually exclusive" \
|
||||||
|
forge_api --paginate --paginate-exhaustive 'repos/o/r/issues/188/timeline'
|
||||||
|
check "exhaustive pagination refuses a non-GET method" 1 "GET" \
|
||||||
|
forge_api --paginate-exhaustive -X POST 'repos/o/r/issues/188/timeline'
|
||||||
|
check "exhaustive pagination refuses compact -XPOST too" 1 "GET" \
|
||||||
|
forge_api --paginate-exhaustive -XPOST 'repos/o/r/issues/188/timeline'
|
||||||
|
check "exhaustive pagination refuses --method=POST too" 1 "GET" \
|
||||||
|
forge_api --paginate-exhaustive --method=POST 'repos/o/r/issues/188/timeline'
|
||||||
|
check "the exhaustive flag has exactly one production call site" 0 "" \
|
||||||
|
test "$(grep -c 'paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 5
|
||||||
|
check "only forge_timeline invokes exhaustive pagination" 0 "" \
|
||||||
|
test "$(grep -c 'forge_api --paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 1
|
||||||
|
|
||||||
# Unreadable: curl fails. Status must surface through forge_timeline itself
|
# Unreadable: curl fails. Status must surface through forge_timeline itself
|
||||||
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
|
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
|
||||||
# shellcheck disable=SC2317
|
# shellcheck disable=SC2317
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,28 @@ check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt
|
||||||
refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)'
|
refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)'
|
||||||
check "Refs parser returns only references owned by a valid Refs marker" 0 "" \
|
check "Refs parser returns only references owned by a valid Refs marker" 0 "" \
|
||||||
test "$(refs_references <<<"$refs_body")" = $'8\n12\n175'
|
test "$(refs_references <<<"$refs_body")" = $'8\n12\n175'
|
||||||
|
# A Refs declaration binds one token, never the prose that follows it; every
|
||||||
|
# later occurrence starts a fresh declaration (#234).
|
||||||
|
# shellcheck disable=SC2016 # backticks are the fixture's literal Markdown
|
||||||
|
crew_round_line='- **Claude verified the safe `Refs #52`, TDD provenance, and inherited #65 CI failure.**'
|
||||||
|
check "Refs parser ignores issue prose after the bound token" 0 "" \
|
||||||
|
test "$(refs_references <<<"$crew_round_line")" = 52
|
||||||
|
check "Refs comma-list narrowing is deliberate" 0 "" \
|
||||||
|
test "$(refs_references <<<'Refs #8, #9')" = 8
|
||||||
|
check "a cross-repo Refs token stays non-local" 0 "" \
|
||||||
|
test -z "$(refs_references <<<'Refs heavy-duty/rig#4')"
|
||||||
|
check "prose after a cross-repo Refs token stays non-local" 0 "" \
|
||||||
|
test -z "$(refs_references <<<'Refs heavy-duty/rig#4, #12')"
|
||||||
|
check "parenthesized prose after a local Refs token is ignored" 0 "" \
|
||||||
|
test "$(refs_references <<<'Refs #175 (split from #150)')" = 175
|
||||||
|
check "every Refs occurrence on one line contributes its bound token" 0 "" \
|
||||||
|
test "$(refs_references <<<'Refs #8. Refs #9.')" = $'8\n9'
|
||||||
|
check "a concatenated Refs spelling is not a second keyword occurrence" 0 "" \
|
||||||
|
test "$(refs_references <<<'Refs #8Refs #9')" = 8
|
||||||
|
check "lowercase refs in unrelated prose declares nothing" 0 "" \
|
||||||
|
test -z "$(refs_references <<<'fix refs parsing from #200')"
|
||||||
|
check "Refs without a following token declares nothing" 0 "" \
|
||||||
|
test -z "$(refs_references <<<'Closes #40; refs: none')"
|
||||||
open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5'
|
open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5'
|
||||||
check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \
|
check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \
|
||||||
open_pr_issues <<<"$open_records"
|
open_pr_issues <<<"$open_records"
|
||||||
|
|
|
||||||
|
|
@ -150,4 +150,38 @@ done
|
||||||
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
|
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
|
||||||
trigger_types "$SELF" pull_request_target
|
trigger_types "$SELF" pull_request_target
|
||||||
|
|
||||||
|
# ---- fork heads carry a read-only token on this Forgejo (#241) --------------
|
||||||
|
# Same-repo heads keep the existing immediate scope + sweep-dispatch path. A
|
||||||
|
# fork-headed pull_request_target run must attempt no write: both write-capable
|
||||||
|
# jobs exclude it, while one successful job explains that the scheduled sweep
|
||||||
|
# owns its labels. These read the parsed workflow fields rather than grepping
|
||||||
|
# prose, so a comment cannot satisfy the contract.
|
||||||
|
job_if_contains_all() { # $1 = file, $2 = job, remaining args = predicates
|
||||||
|
local file="$1" job="$2" expression predicate
|
||||||
|
shift 2
|
||||||
|
expression="$(yq -r ".jobs.$job.if // \"\"" "$file")"
|
||||||
|
for predicate in "$@"; do
|
||||||
|
grep -qF "$predicate" <<<"$expression" || return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
check "scope writes only for a same-repo PR head" 0 "" \
|
||||||
|
job_if_contains_all "$REUSABLE" scope \
|
||||||
|
"github.event.pull_request.head.repo.full_name == github.repository"
|
||||||
|
check "the sweep trigger preserves non-PR events and excludes fork heads" 0 "" \
|
||||||
|
job_if_contains_all "$REUSABLE" trigger \
|
||||||
|
"github.event_name != 'pull_request_target'" \
|
||||||
|
"github.event.pull_request.head.repo.full_name == github.repository"
|
||||||
|
check "a fork-headed PR selects the successful explanation job" 0 "" \
|
||||||
|
job_if_contains_all "$REUSABLE" fork_head \
|
||||||
|
"github.event_name == 'pull_request_target'" \
|
||||||
|
"github.event.pull_request.head.repo.full_name != github.repository"
|
||||||
|
|
||||||
|
fork_head_step() {
|
||||||
|
yq -r '.jobs.fork_head.steps[] | select(.name == "explain deferred fork labels") | .run' \
|
||||||
|
"$REUSABLE" | bash
|
||||||
|
}
|
||||||
|
check "the fork path names its read-only token and sweep-cadence wake" 0 \
|
||||||
|
"read-only token; writes deferred to the scheduled sweep cadence" \
|
||||||
|
fork_head_step
|
||||||
|
|
||||||
summary
|
summary
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue