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
|
||||
# caller; docs/CONSUMERS.md carries the complete caller stub
|
||||
# (workflow_dispatch plus the hourly cron, which relocated here with the
|
||||
# sweep). Board events still yield a sweep within seconds: labels.yml's
|
||||
# trigger job dispatches this workflow's caller on every event it used to
|
||||
# run reconcile on.
|
||||
# sweep). Issue events and same-repository PR events still yield a sweep within
|
||||
# seconds: labels.yml's trigger job dispatches this workflow's caller on those
|
||||
# events. Fork-headed PR events carry a read-only token on this Forgejo and
|
||||
# wait for the caller's scheduled cadence instead (#241).
|
||||
#
|
||||
# Detached on purpose (#209): every sweep covers every open PR and all
|
||||
# sweeps serialize through ONE shared concurrency group, so GitHub's
|
||||
|
|
|
|||
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
|
||||
# the caller; docs/CONSUMERS.md carries the complete caller stub.
|
||||
#
|
||||
# The caller uses pull_request_target, not pull_request: every PR in this
|
||||
# family arrives from a fork, where pull_request runs with a READ-ONLY token
|
||||
# and cannot label anything. _target is safe in this workflow because no PR
|
||||
# code is ever checked out or executed — scope reads changed paths and the
|
||||
# path mapping via the API and checks out only the ceremony implementation.
|
||||
# Keep it that way.
|
||||
# The caller uses pull_request_target, not pull_request, so same-repository PRs
|
||||
# keep the base repository's write token without running PR code. On this
|
||||
# Forgejo, unlike GitHub, fork-headed _target runs still receive a read-only
|
||||
# token. Those runs therefore attempt no writes and leave labeling to the
|
||||
# scheduled sweep; the explicit fork_head job below records that disposition
|
||||
# as a successful check. Both write paths execute only for same-repository
|
||||
# heads. Scope reads changed paths and the path mapping through the API and
|
||||
# checks out only the ceremony implementation. Keep it that way (#241).
|
||||
#
|
||||
# The reconcile sweep lived here until #209. Riding the PR-triggered run
|
||||
# meant every displacement in the sweep's shared concurrency queue recorded
|
||||
|
|
@ -21,8 +23,11 @@ name: labels
|
|||
# sweep now lives in labels-sweep.yml behind its own caller, and the
|
||||
# trigger job below is its wake: it fires on every event this caller
|
||||
# subscribes — the exact surface that used to run reconcile directly — so
|
||||
# the wake latency (#137) is unchanged, while a displaced sweep cancels on
|
||||
# the Actions tab, attached to no PR. PR checks show scope + trigger only.
|
||||
# same-repository wake latency (#137) remains seconds-scale, while a displaced
|
||||
# sweep cancels on the Actions tab, attached to no PR. Fork-headed runs cannot
|
||||
# dispatch with their read-only token, so their labels arrive on the scheduled
|
||||
# sweep cadence. PR checks show scope + trigger for same-repository heads, or
|
||||
# fork_head for fork heads.
|
||||
#
|
||||
# This cannot loop: the trigger's dispatch and the reconciler's label
|
||||
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
|
||||
|
|
@ -60,6 +65,7 @@ jobs:
|
|||
# scope run is clobbered.
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action != 'labeled' &&
|
||||
github.event.action != 'unlabeled' &&
|
||||
github.event.action != 'review_requested' &&
|
||||
|
|
@ -106,15 +112,10 @@ jobs:
|
|||
CONFIG_REF: ${{ github.sha }}
|
||||
|
||||
trigger:
|
||||
# The sweep's wake (#209). No `if:`: reconcile carried none, so the
|
||||
# trigger keeps the whole event surface the caller subscribes —
|
||||
# workflow_dispatch of the labels caller itself included. That cannot
|
||||
# double-fire bootstrap: this dispatch always carries bootstrap=no, so
|
||||
# a dispatched labels caller yields one plain sweep, and the taxonomy
|
||||
# bootstrap fires solely on a manual dispatch of the sweep caller
|
||||
# (whose input defaults to "yes"). Excluding workflow_dispatch here
|
||||
# would instead make a dispatched labels caller do nothing at all —
|
||||
# a silent no-op run is worse than a redundant sweep.
|
||||
# The sweep's instant wake (#209) keeps the whole non-PR event surface and
|
||||
# same-repository PRs. Fork-headed PRs are excluded because this Forgejo
|
||||
# gives their pull_request_target run a read-only token (#241); fork_head
|
||||
# records the successful deferral to the scheduled sweep instead.
|
||||
#
|
||||
# LOUD on failure — never `|| true`: a red trigger is the
|
||||
# misconfiguration alarm. A consumer that bumps the pin without adding
|
||||
|
|
@ -122,6 +123,9 @@ jobs:
|
|||
# `bootstrap` input (unexpected input), or without `actions: write`
|
||||
# on this caller (permission denied) fails HERE, visibly on the PR,
|
||||
# instead of silently never sweeping again.
|
||||
if: >-
|
||||
github.event_name != 'pull_request_target' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: dispatch the sweep
|
||||
|
|
@ -199,3 +203,13 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"
|
||||
|
||||
fork_head:
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: explain deferred fork labels
|
||||
run: >-
|
||||
echo "labels: fork head has a read-only token; writes deferred to the scheduled sweep cadence"
|
||||
|
|
|
|||
13
.github/workflows/self-labels-sweep.yml
vendored
13
.github/workflows/self-labels-sweep.yml
vendored
|
|
@ -7,12 +7,14 @@ name: labels-sweep
|
|||
on:
|
||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
||||
# when no other engine drives board state: the cron is then the sweep's ONLY
|
||||
# wake for four transition classes — a review verdict landing (there is no
|
||||
# wake for a review verdict landing (there is no
|
||||
# pull_request_review trigger on the labels caller), blocker:ci-red set or
|
||||
# cleared (no check_suite/check_run/workflow_run), a blocker:conflict when
|
||||
# ANOTHER PR merges under this one, and the time-based stale / 48h
|
||||
# claim-reclaim. The labels caller's events carry the rest in seconds, one
|
||||
# trigger-job dispatch away. Hourly trades ≤1h of latency on those four
|
||||
# claim-reclaim, plus every fork-headed PR transition on this Forgejo because
|
||||
# its pull_request_target token is read-only (#241). Issue events and
|
||||
# same-repository PR events carry the rest in seconds, one trigger-job
|
||||
# dispatch away. Hourly trades ≤1h of latency on the scheduled classes
|
||||
# while cutting nominal scheduled sweeps from four an hour to one at
|
||||
# GitHub's 1-minute billing floor. Do not delete the cron: it is their
|
||||
# discovery path. If another engine writes some of those transitions, only
|
||||
|
|
@ -22,8 +24,9 @@ on:
|
|||
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
||||
# bootstraps the taxonomy on a fresh repo — what dispatching the labels
|
||||
# caller did before #209. The reusable's trigger job wakes this workflow
|
||||
# with bootstrap=no on every board event — an event-woken sweep must not
|
||||
# re-upsert ~20 labels each time — so declaring this input is part of the
|
||||
# with bootstrap=no on every issue and same-repository PR event — an
|
||||
# event-woken sweep must not re-upsert ~20 labels each time — so declaring
|
||||
# this input is part of the
|
||||
# caller contract: a dispatch naming an undeclared input is refused, and
|
||||
# the trigger job goes loudly red.
|
||||
workflow_dispatch:
|
||||
|
|
|
|||
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
|
||||
# reconcile sweep no longer rides these runs — the reusable's trigger job
|
||||
# dispatches the sweep caller (self-labels-sweep.yml here), which owns the
|
||||
# hourly cron and the manual/bootstrap workflow_dispatch. A board event
|
||||
# below still yields a sweep within seconds, one dispatch hop later.
|
||||
# hourly cron and the manual/bootstrap workflow_dispatch. Issue events and
|
||||
# same-repository PR events below still yield a sweep within seconds, one
|
||||
# dispatch hop later. Fork-headed PRs carry a read-only token on this Forgejo,
|
||||
# so their successful labels run leaves writes to the hourly sweep (#241).
|
||||
on:
|
||||
# Narrowed (#199) to the actions that carry a queue-state change the hourly
|
||||
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
|
||||
|
|
@ -26,14 +28,14 @@ on:
|
|||
issues:
|
||||
types: [opened, closed, edited, reopened]
|
||||
pull_request_target:
|
||||
# Every PR arrives from a fork, so these carry the head/draft/review facts
|
||||
# the sweep derives state:* from. labeled/unlabeled are the handoff wake —
|
||||
# the author's optimistic state:needs-human write, confirmed or corrected
|
||||
# here in seconds (#11); synchronize re-derives on every push;
|
||||
# review_requested/review_request_removed wake the sweep that clears (or
|
||||
# restores) blocker:unrequested — without them the one event that makes
|
||||
# the label false could not clear it, and a quiet repo wore the red flag
|
||||
# until the advisory cron (#137).
|
||||
# These carry the head/draft/review facts the sweep derives state:* from.
|
||||
# Same-repository heads wake that sweep in seconds; fork heads cannot write
|
||||
# with this Forgejo's read-only token and wait for its scheduled cadence.
|
||||
# labeled/unlabeled are the same-repository handoff wake — the author's
|
||||
# optimistic state:needs-human write, confirmed or corrected here in
|
||||
# seconds (#11); synchronize re-derives on every push; review_requested/
|
||||
# review_request_removed clear (or restore) blocker:unrequested on that
|
||||
# same instant path (#137).
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ 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}}"
|
||||
|
|
@ -169,6 +175,37 @@ 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
|
||||
|
|
|
|||
|
|
@ -208,14 +208,14 @@ claim_reclaim_marker() { # $1 = last activity epoch
|
|||
refs_references() { # PR body on stdin -> local issue numbers named by Refs
|
||||
awk '
|
||||
{
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
' | 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:
|
||||
|
||||
- **`labels.yml`** — the event-facing half, called on PR and issue events.
|
||||
Two jobs: additive path-based `scope:*` labels, and a few-seconds
|
||||
`trigger` job that wakes the sweep by dispatching the consumer's sweep
|
||||
caller (a REST `POST` to the forge's own
|
||||
Same-repository PRs keep two write-capable jobs: additive path-based
|
||||
`scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by
|
||||
dispatching the consumer's sweep caller (a REST `POST` to the forge's own
|
||||
`${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`,
|
||||
plain `GITHUB_TOKEN` — `workflow_dispatch` is
|
||||
one of the two documented exemptions from the token's no-retrigger rule,
|
||||
so no PAT anywhere in the path and no loop: the sweep dispatches
|
||||
nothing).
|
||||
nothing). On this Forgejo a fork-headed `pull_request_target` token is
|
||||
read-only, so those two jobs do not run; a successful `fork_head` job names
|
||||
the deferral, and the scheduled sweep owns their labels.
|
||||
- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
|
||||
handoff, stale status, the issue work queue, and the `needs-ruling`
|
||||
invariants on both surfaces — the bare-flag check and the 7-day
|
||||
|
|
@ -356,8 +358,9 @@ together at the same pin:
|
|||
as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun`
|
||||
and its `--failed`/`--job` forms all decline a queue-displaced run).
|
||||
Behind its own caller, a displaced sweep cancels on the
|
||||
Actions tab, attached to no PR; PR checks show `scope` and the green
|
||||
`trigger` only.
|
||||
Actions tab, attached to no PR. Same-repository PR checks show `scope` and
|
||||
the green `trigger`; fork-headed PRs show the green `fork_head` disposition
|
||||
and wait for the scheduled sweep cadence.
|
||||
|
||||
The consumer keeps its path mapping in `.github/labeler.yml` and its
|
||||
review panel plus scope taxonomy in `.github/labels.conf`.
|
||||
|
|
@ -384,11 +387,12 @@ The complete event-facing caller is:
|
|||
name: labels
|
||||
on:
|
||||
pull_request_target:
|
||||
# Fork PRs; these carry the head/draft/review facts state:* derives from.
|
||||
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here);
|
||||
# synchronize re-derives on every push. review_requested/review_request_removed
|
||||
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears
|
||||
# blocker:unrequested when the panel is asked.
|
||||
# These carry the head/draft/review facts state:* derives from. Same-repo
|
||||
# heads take the instant write + sweep-dispatch path; this Forgejo gives
|
||||
# fork heads a read-only token, so they wait for the scheduled sweep.
|
||||
# labeled/unlabeled are the same-repo handoff wake; synchronize re-derives
|
||||
# on every push. review_requested/review_request_removed shipped in 0.3.0
|
||||
# (ceremony#137) and wake the same-repo sweep when the panel is asked.
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
|
||||
# consumer pinned to 0.1.0 omits this block.
|
||||
|
|
@ -425,20 +429,21 @@ name: labels-sweep
|
|||
on:
|
||||
# The consumer owns this cadence (#203). Hourly is the recommended default
|
||||
# when no other engine drives board state: the cron is then the sweep's only
|
||||
# wake for four transition classes — a review verdict landing (no
|
||||
# wake for a review verdict landing (no
|
||||
# pull_request_review trigger on the labels caller), blocker:ci-red
|
||||
# set/cleared, blocker:conflict when another PR merges under this one, and
|
||||
# time-based stale / 48h claim-reclaim. The labels caller's events carry the
|
||||
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
|
||||
# latency on those four while cutting nominal scheduled sweeps from four an
|
||||
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their
|
||||
# discovery path. If another engine writes some of those transitions, only
|
||||
# the classes with no other writer bound the cadence; relax it only as that
|
||||
# list shrinks.
|
||||
# time-based stale / 48h claim-reclaim, plus every fork-headed PR transition
|
||||
# on this Forgejo. Issue events and same-repo PR
|
||||
# events carry the rest in seconds, one trigger-job dispatch away. Hourly
|
||||
# trades ≤1h of latency on the scheduled classes while cutting nominal
|
||||
# sweeps from four an hour to one at GitHub's 1-minute floor. Do not delete
|
||||
# the cron: it is their discovery path. If another engine writes some of
|
||||
# those transitions, only the classes with no other writer bound the cadence;
|
||||
# relax it only as that list shrinks.
|
||||
schedule: [{cron: "0 * * * *"}]
|
||||
# A manual full-board sweep. A bare dispatch (input default "yes") also
|
||||
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
|
||||
# wakes this workflow with bootstrap=no on every board event, so the
|
||||
# wakes this workflow with bootstrap=no on every issue and same-repo PR event, so the
|
||||
# declared input is part of the contract: a dispatch naming an undeclared
|
||||
# input is refused, and the trigger job goes loudly red.
|
||||
workflow_dispatch:
|
||||
|
|
@ -542,16 +547,18 @@ Bump without the sweep caller and the trigger job goes red on every PR
|
|||
and issue event — the loud failure mode above — so never split these
|
||||
four edits across PRs.
|
||||
|
||||
`pull_request_target` is intentional: fork PRs need the base repository's
|
||||
token to write labels. The reusable workflows execute no PR code. They check
|
||||
out only the consumer's base branch and the pinned ceremony implementation.
|
||||
`pull_request_target` is intentional: same-repository PRs keep the base
|
||||
repository's write token without executing PR code. This Forgejo still gives
|
||||
fork-headed `_target` runs a read-only token, so they attempt no writes and
|
||||
leave labels to the scheduled sweep. The reusable workflows check out only the
|
||||
consumer's base branch and the pinned ceremony implementation.
|
||||
The #52 ruling invariants ride exactly these triggers — but the caller above
|
||||
is no longer the #18 shape, so adopting current triggers is a stub edit, not
|
||||
a bare pin bump. `review_requested` and `review_request_removed` on
|
||||
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that
|
||||
clears `blocker:unrequested` the moment the panel is asked, without which a
|
||||
quiet repo wears that flag until the backstop cron; a consumer picks them up
|
||||
by pinning `0.3.0` or later, never through mixed refs.
|
||||
`pull_request_target:` shipped in `0.3.0` (ceremony#137). It clears
|
||||
`blocker:unrequested` the moment the panel is asked on a same-repository head;
|
||||
fork heads wait for the sweep cadence on this Forgejo. A consumer picks the
|
||||
events up by pinning `0.3.0` or later, never through mixed refs.
|
||||
|
||||
`.github/labels.conf` has one mandatory panel setting, one mandatory
|
||||
`triage-actors` setting, zero or more optional per-author panel rows, and
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ forgejo_page_url() {
|
|||
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
|
||||
# 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
|
||||
# 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 endpoint="" jqexpr="" have_jq=false
|
||||
local paginate=false paginate_exhaustive=false method=GET 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" ;;
|
||||
|
|
@ -106,6 +121,14 @@ 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
|
||||
|
|
@ -116,7 +139,7 @@ forge_api() {
|
|||
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
|
||||
trap "rm -f '$hdr' '$body'" RETURN
|
||||
|
||||
if [ "$paginate" = false ]; then
|
||||
if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
|
||||
if ! curl -sS -D "$hdr" -o "$body" \
|
||||
-H "Authorization: token $token" -H 'Accept: application/json' \
|
||||
"$base/$endpoint"; then
|
||||
|
|
@ -142,21 +165,23 @@ forge_api() {
|
|||
fi
|
||||
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
|
||||
|
||||
# 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
|
||||
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
|
||||
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
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
pagejson="$(cat "$body")"
|
||||
|
|
@ -175,13 +200,17 @@ EOF
|
|||
[ "$n" -gt 0 ] || break
|
||||
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
|
||||
got=$((got + n))
|
||||
[ "$got" -lt "$total" ] || break
|
||||
if [ "$paginate_exhaustive" = true ]; then
|
||||
[ "$n" -eq 50 ] || break
|
||||
else
|
||||
[ "$got" -lt "$total" ] || break
|
||||
fi
|
||||
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 [ "$got" -ne "$total" ]; then
|
||||
if [ "$paginate_exhaustive" = false ] && [ "$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
|
||||
|
|
@ -561,7 +590,13 @@ 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
|
||||
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 '
|
||||
[.[]
|
||||
| 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" \
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -746,6 +746,7 @@ 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=""
|
||||
|
|
@ -753,14 +754,44 @@ timeline_stub() {
|
|||
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
|
||||
shift
|
||||
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
|
||||
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
|
||||
*timeline*) printf '%s' "$page_body" >"$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"},
|
||||
|
|
@ -777,6 +808,61 @@ 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
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -150,4 +150,38 @@ done
|
|||
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
|
||||
trigger_types "$SELF" pull_request_target
|
||||
|
||||
# ---- fork heads carry a read-only token on this Forgejo (#241) --------------
|
||||
# Same-repo heads keep the existing immediate scope + sweep-dispatch path. A
|
||||
# fork-headed pull_request_target run must attempt no write: both write-capable
|
||||
# jobs exclude it, while one successful job explains that the scheduled sweep
|
||||
# owns its labels. These read the parsed workflow fields rather than grepping
|
||||
# prose, so a comment cannot satisfy the contract.
|
||||
job_if_contains_all() { # $1 = file, $2 = job, remaining args = predicates
|
||||
local file="$1" job="$2" expression predicate
|
||||
shift 2
|
||||
expression="$(yq -r ".jobs.$job.if // \"\"" "$file")"
|
||||
for predicate in "$@"; do
|
||||
grep -qF "$predicate" <<<"$expression" || return 1
|
||||
done
|
||||
}
|
||||
check "scope writes only for a same-repo PR head" 0 "" \
|
||||
job_if_contains_all "$REUSABLE" scope \
|
||||
"github.event.pull_request.head.repo.full_name == github.repository"
|
||||
check "the sweep trigger preserves non-PR events and excludes fork heads" 0 "" \
|
||||
job_if_contains_all "$REUSABLE" trigger \
|
||||
"github.event_name != 'pull_request_target'" \
|
||||
"github.event.pull_request.head.repo.full_name == github.repository"
|
||||
check "a fork-headed PR selects the successful explanation job" 0 "" \
|
||||
job_if_contains_all "$REUSABLE" fork_head \
|
||||
"github.event_name == 'pull_request_target'" \
|
||||
"github.event.pull_request.head.repo.full_name != github.repository"
|
||||
|
||||
fork_head_step() {
|
||||
yq -r '.jobs.fork_head.steps[] | select(.name == "explain deferred fork labels") | .run' \
|
||||
"$REUSABLE" | bash
|
||||
}
|
||||
check "the fork path names its read-only token and sweep-cadence wake" 0 \
|
||||
"read-only token; writes deferred to the scheduled sweep cadence" \
|
||||
fork_head_step
|
||||
|
||||
summary
|
||||
|
|
|
|||
Loading…
Reference in a new issue