2026-07-28 18:21:48 +00:00
#!/usr/bin/env bash
set -u
# The labels TRIGGER SURFACE is a cost lever (#199): a full-board sweep is
# billed a 1-minute minimum every time a trigger fires, so how OFTEN it fires
# is what exhausted the fleet's shared Actions allotment. These assertions
# pin the reductions #199 made and the guard it must not trade away — none of
# them touch the reconciler's LOGIC, which its own fixtures cover.
ROOT = " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) /.. " && pwd ) "
# shellcheck source=test/harness.sh
source " $ROOT /test/harness.sh "
REUSABLE = " $ROOT /.github/workflows/labels.yml "
2026-08-01 16:20:00 +00:00
SWEEP = " $ROOT /.github/workflows/labels-sweep.yml "
2026-07-28 18:21:48 +00:00
SELF = " $ROOT /.github/workflows/self-labels.yml "
2026-08-01 16:20:00 +00:00
SELF_SWEEP = " $ROOT /.github/workflows/self-labels-sweep.yml "
STUB = " $ROOT /docs/CONSUMERS.md " # the published caller stubs, fenced yaml blocks
2026-07-28 18:21:48 +00:00
# The `cancel-in-progress:` value of a named top-level job, read from the first
# such line inside that job's block. Job keys sit at two-space indent.
job_cancel_in_progress( ) { # $1 = file, $2 = job name
awk -v job = " ^ $2 :\$ " '
$0 ~ job { f = 1; next }
f && /^ [ a-z] / { exit } # next job — stop before leaking into it
f && /cancel-in-progress:/ { sub( /.*cancel-in-progress:[ [ :space:] ] */, "" ) ; print; exit }
' " $1 "
}
# The `types:` list of a trigger key (issues:, pull_request_target:), read from
# the first `types:` line after the bare key. The key is bare (nothing after
# the colon) so it never collides with `issues: write` in the permissions block.
trigger_types( ) { # $1 = file, $2 = trigger key
awk -v key = " ^ $2 :\$ " '
$0 ~ key { f = 1; next }
f && /^ types:/ { sub( /^ types:[ [ :space:] ] */, "" ) ; print; exit }
f && /^ [ a-z] / { exit }
' " $1 "
}
# ---- the guard the cost fix must never trade away (#199 test plan must-fail) --
# cancel-in-progress: true on reconcile kills a sweep mid-board, the exact race
# the shared concurrency group exists to prevent. It WOULD cut run count — by
2026-08-01 16:20:00 +00:00
# trading correctness for minutes — so it stays false, forever. The job lives
# in labels-sweep.yml since #209; the guard moved with it.
2026-07-28 18:21:48 +00:00
check "reconcile serializes, never cancels mid-board" 0 "false" \
2026-08-01 16:20:00 +00:00
job_cancel_in_progress " $SWEEP " reconcile
2026-07-28 18:21:48 +00:00
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check "reconcile is never cancel-in-progress: true" 1 "" \
bash -c ' job_cancel_in_progress( ) {
awk -v job = "^ reconcile:\$" "\$0 ~ job{f=1;next} f&&/^ [a-z]/{exit} f&&/cancel-in-progress:/{sub(/.*cancel-in-progress:[[:space:]]*/,\"\");print;exit}" " $1 "
2026-08-01 16:20:00 +00:00
} ; [ " $( job_cancel_in_progress " $1 " ) " = true ] ' _ " $SWEEP "
2026-07-28 18:21:48 +00:00
# scope MAY cancel — it is per-PR and additive, so a superseded run is waste,
# not a lost sweep. This asserts the must-fail above is scoped to reconcile.
check "scope stays cancel-in-progress: true (per-PR, additive)" 0 "true" \
job_cancel_in_progress " $REUSABLE " scope
2026-08-01 16:20:00 +00:00
# ---- the sweep is detached from PR-triggered runs (#209) ---------------------
# While reconcile rode the PR-event run, every displacement in its shared
# queue recorded a CANCELLED check on some PR — fake red CI. The reusable
# labels.yml must never grow the job back; its trigger job wakes the sweep
# caller by dispatch instead, and that dispatch is the misconfiguration
# alarm: a pin bumped without the sweep caller must go loudly red at the
# trigger, so the dispatch line is never allowed to silence itself.
check "labels.yml carries no reconcile job" 1 "" \
grep -E '^ reconcile:' " $REUSABLE "
check "labels-sweep.yml carries the reconcile job" 0 " reconcile:" \
grep -E '^ reconcile:' " $SWEEP "
check "the sweep keeps the ONE shared concurrency group" 0 "group: labels-reconcile" \
grep -F 'group: labels-reconcile' " $SWEEP "
check "labels.yml carries the trigger job" 0 " trigger:" \
grep -E '^ trigger:' " $REUSABLE "
fix(labels): wake the sweep over REST, so board events reconcile in seconds
`.github/workflows/labels.yml` dispatched the sweep with `gh workflow run`,
the eighth runtime gh call site the 0.6.0 merge reintroduced and the only one
!204 did not port. On this forge the runner carries neither gh nor a GitHub
API, so the step refused and the entire event-driven reconcile path ended
there — every transition waiting up to an hour for the scheduled sweep.
The workflow-dispatch endpoint has the SAME shape on both forges:
POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
{"ref": "<branch>", "inputs": {...}} -> 204, empty body
so the step no longer decides a forge at all. The CEREMONY_FORGE_CLIENT=gh
declaration and both inline refusals are removed rather than ported, and
test/no-runtime-gh.test.sh now asserts their ABSENCE — an opt-out with no gh
behind it is a standing permission slip.
The ref is supplied explicitly and taken from the repository, never from
GITHUB_REF_NAME: on a pull_request_target run that is `<n>/merge`, which is
not a branch. A non-204 still fails the job, keeping the misconfiguration
alarm the trigger exists to be, and the diagnostic explains Forgejo's empty
500 rather than passing a bare status to a reader who will go looking for an
outage that is not there.
test/labels-dispatch.test.sh extracts the shipped step and executes it against
a recording stub, asserting the method, endpoint, ref and inputs actually
sent. Dropping the inputs or ignoring a non-204 both red the suite.
Refs #205
2026-08-05 16:58:05 +00:00
# #205 ported this dispatch from `gh workflow run` to REST. The assertion is
# the same one it always was — the sweep caller is woken BY NAME and never
# bootstrapped — but it now has to hold against a request rather than a CLI
# line. What the step actually SENDS is driven in test/labels-dispatch.test.sh;
# these two keep the wiring pinned here alongside the rest of the trigger.
2026-08-01 16:20:00 +00:00
# shellcheck disable=SC2016 # $SWEEP_WORKFLOW is the workflow's own env var, asserted literally
fix(labels): wake the sweep over REST, so board events reconcile in seconds
`.github/workflows/labels.yml` dispatched the sweep with `gh workflow run`,
the eighth runtime gh call site the 0.6.0 merge reintroduced and the only one
!204 did not port. On this forge the runner carries neither gh nor a GitHub
API, so the step refused and the entire event-driven reconcile path ended
there — every transition waiting up to an hour for the scheduled sweep.
The workflow-dispatch endpoint has the SAME shape on both forges:
POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
{"ref": "<branch>", "inputs": {...}} -> 204, empty body
so the step no longer decides a forge at all. The CEREMONY_FORGE_CLIENT=gh
declaration and both inline refusals are removed rather than ported, and
test/no-runtime-gh.test.sh now asserts their ABSENCE — an opt-out with no gh
behind it is a standing permission slip.
The ref is supplied explicitly and taken from the repository, never from
GITHUB_REF_NAME: on a pull_request_target run that is `<n>/merge`, which is
not a branch. A non-204 still fails the job, keeping the misconfiguration
alarm the trigger exists to be, and the diagnostic explains Forgejo's empty
500 rather than passing a bare status to a reader who will go looking for an
outage that is not there.
test/labels-dispatch.test.sh extracts the shipped step and executes it against
a recording stub, asserting the method, endpoint, ref and inputs actually
sent. Dropping the inputs or ignoring a non-204 both red the suite.
Refs #205
2026-08-05 16:58:05 +00:00
check "the trigger dispatches the sweep caller by name" 0 \
'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
grep -F '/dispatches' " $REUSABLE "
check "...never bootstrapping" 0 'bootstrap: "no"' \
grep -F 'bootstrap' " $REUSABLE "
2026-08-05 17:27:17 +00:00
# The never-silenced invariant moved to test/labels-dispatch.test.sh, where it
# is BEHAVIOURAL: a curl that dies at the transport must fail the extracted
# step, plus a code-aware no-`|| true` guard on the step itself. The check that
# lived here grepped the `gh workflow run` line #205 removed, so after the port
# it passed on every implementation including one that swallows a failed POST —
# a green assertion whose name claimed an invariant its implementation could
# not observe (@codex-reviewer-andresmgsl, #213 review).
2026-08-01 16:20:00 +00:00
check "the sweep caller filename input defaults to labels-sweep.yml" 0 \
"default: labels-sweep.yml" grep -F 'default: labels-sweep.yml' " $REUSABLE "
# the dogfood callers wear the split: the event caller names its deviant
# sweep filename, and the sweep caller declares the bootstrap input the
# trigger's -f flag requires (an undeclared input reds every dispatch)
check "self caller passes its dogfood sweep filename" 0 \
"sweep_workflow: self-labels-sweep.yml" \
grep -F 'sweep_workflow: self-labels-sweep.yml' " $SELF "
check "self sweep caller declares the bootstrap dispatch input" 0 \
"bootstrap:" grep -E '^ bootstrap:' " $SELF_SWEEP "
check "stub sweep caller declares the bootstrap dispatch input" 0 \
"bootstrap:" grep -E '^ bootstrap:' " $STUB "
# the labels caller's event runs must not carry the sweep's cron or manual
# dispatch — those relocated to the sweep caller with #209
check "self caller carries no cron" 1 "" grep -F 'cron:' " $SELF "
check "self caller carries no workflow_dispatch" 1 "" \
grep -E '^ workflow_dispatch:' " $SELF "
2026-07-28 18:21:48 +00:00
# ---- the cron is a backstop, relaxed to hourly (#199 candidate 1) -----------
# Scope the */15 assertion to the cron LINE — the prose comments cite */15 by
# name to explain the change, and must not re-red their own documentation.
2026-08-01 16:20:00 +00:00
# The cron rides the sweep caller since #209.
check "self sweep caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' " $SELF_SWEEP "
2026-07-28 18:21:48 +00:00
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
2026-08-01 16:20:00 +00:00
check "self sweep caller cron line no longer fires */15" 1 "" \
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ " $SELF_SWEEP "
2026-07-28 18:21:48 +00:00
check "stub cron is hourly" 0 '0 * * * *' grep -F 'cron:' " $STUB "
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
check "stub cron line no longer fires */15" 1 "" \
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ " $STUB "
2026-07-28 19:04:27 +00:00
# ---- issues: is narrowed to the queue-state-changing actions (#199) ----------
# Kept because each carries a queue-state change an event uniquely carries, so
# dropping it would trip #199's must-fail (a transition waiting on the schedule
# when an event could have carried it): opened → mint→needs-triage; closed →
# blocker-closes→ready self-heal; edited → a body rewrite of the `Blocked by #N`
# declaration the sweep parses; reopened → a closed issue re-entering the queue.
# (labels.test.sh owns the exact-list and caller<->stub parity assertions.)
for keep in opened closed edited reopened; do
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check " self caller issues surface keeps ' $keep ' " 0 "" \
bash -c ' trigger_types( ) {
awk -v key = "^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" " $1 "
} ; trigger_types " $1 " | grep -qw " $2 " ' _ " $SELF " " $keep "
done
# The churn actions must not reappear on the issues surface without a fresh why.
# labeled/unlabeled were the dominant issues-churn source; assigned/unassigned
# only feed validation and the 48h claim clock, caught within one cadence.
for churn in labeled unlabeled assigned unassigned; do
2026-07-28 18:21:48 +00:00
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check " self caller issues surface drops ' $churn ' " 1 "" \
bash -c ' trigger_types( ) {
awk -v key = "^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" " $1 "
} ; trigger_types " $1 " | grep -qw " $2 " ' _ " $SELF " " $churn "
done
# ---- the PR handoff wake is NOT collateral of the issues narrowing ----------
# The handoff (state:needs-human, confirmed by the caller's labeled event) rides
# pull_request_target, not issues. A future edit that strips it there re-reds.
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
trigger_types " $SELF " pull_request_target
2026-08-24 23:56:06 +00:00
# ---- 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
2026-08-25 03:56:57 +00:00
# jobs exclude it, while one successful job explains exactly what the scheduled
2026-08-25 04:37:41 +00:00
# sweep does and does not supply. Require each full normalised expression to
# appear intact, so deleting or inverting one of its clauses fails the guard.
2026-08-25 03:56:57 +00:00
job_if_expression( ) { # $1 = file, $2 = job
yq -r " .jobs. $2 .if // \"\" " " $1 " |
tr '\n' ' ' |
awk '{$1=$1; print}'
2026-08-24 23:56:06 +00:00
}
2026-08-25 03:56:57 +00:00
check "scope writes only for a same-repo PR head" 0 \
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'review_requested' && github.event.action != 'review_request_removed'" \
job_if_expression " $REUSABLE " scope
check "the sweep trigger preserves non-PR events and excludes fork heads" 0 \
"github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository" \
job_if_expression " $REUSABLE " trigger
check "a fork-headed PR selects the successful explanation job" 0 \
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository" \
job_if_expression " $REUSABLE " fork_head
2026-08-24 23:56:06 +00:00
fork_head_step( ) {
yq -r '.jobs.fork_head.steps[] | select(.name == "explain deferred fork labels") | .run' \
" $REUSABLE " | bash
}
2026-08-25 03:56:57 +00:00
check "the fork path distinguishes swept state from unsupported scope writes" 0 \
"read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads" \
2026-08-24 23:56:06 +00:00
fork_head_step
2026-07-28 18:21:48 +00:00
summary