Merge pull request '.github/workflows/labels.yml — wake the sweep over REST, so a board event reconciles in seconds (#205)' (#213) from build/205-dispatch-rest into main

Reviewed-on: heavy-duty/ceremony#213
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
This commit is contained in:
andres 2026-08-05 18:08:32 +00:00
commit c5e987eb89
8 changed files with 355 additions and 45 deletions

View file

@ -87,7 +87,7 @@ jobs:
# equivalent of. # equivalent of.
# #
# bootstrap: every trigger-driven wake arrives as workflow_dispatch # bootstrap: every trigger-driven wake arrives as workflow_dispatch
# too (that is how `gh workflow run` wakes the caller), so the event # too (that is how the trigger's dispatch wakes the caller), so the event
# name alone no longer separates the operator's manual full-board # name alone no longer separates the operator's manual full-board
# bootstrap from an event-woken sweep — the caller's `bootstrap` # bootstrap from an event-woken sweep — the caller's `bootstrap`
# dispatch input does: the trigger passes "no", a bare manual # dispatch input does: the trigger passes "no", a bare manual

View file

@ -126,33 +126,76 @@ jobs:
steps: steps:
- name: dispatch the sweep - name: dispatch the sweep
env: env:
GH_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }} SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }}
# This step speaks gh and says so, the same declaration DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
# actions/refs-not-closing carries (#198 spec 4). A workflow has no
# shell to call forge_preflight from, so the refusal is inline
# below; #205 owns the REST port that removes both.
CEREMONY_FORGE_CLIENT: gh
run: | run: |
# Two questions, not one. @codex-reviewer-andresmgsl: a guard that # REST, not `gh` (#205). The workflow-dispatch endpoint has the SAME
# only asks `command -v gh` passes the moment a Forgejo runner image # shape on both forges —
# happens to ship gh — and then runs a GitHub dispatch against a # POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
# forge that cannot serve it, which is the client/forge mismatch # {"ref": "<branch>", "inputs": {...}} -> 204, empty body
# forge_preflight exists to prevent. So the FORGE is decided first, # — so this step no longer decides a forge at all. That is why the
# mirroring forge_detect positively (only github.com is accepted; # `CEREMONY_FORGE_CLIENT: gh` declaration and both inline refusals are
# anything else, known or not, is refused — "Never 'probably # gone rather than ported: there is nothing left to refuse. Measured
# github'"), and the binary is checked second. # on this instance (Forgejo 8.0.3+gitea-1.22.0) and published in its
# own swagger; run 459 was raised this way.
# #
# A warning, not a failure: this trigger is the misconfiguration # STILL LOUD on failure, per this job's contract: a consumer missing
# alarm for a CONSUMER's missing sweep caller, and reddening every # the sweep caller, its `bootstrap` input, or `actions: write` must
# sweep on a forge for a gap #205 already owns would drown that # fail HERE and visibly, not sweep silently never again.
# signal. #205 ports the dispatch to REST and removes all of this. # NEVER "probably github" (lib/forge.sh). Defaulting an unset
if [ "${GITHUB_SERVER_URL:-}" != "https://github.com" ]; then # GITHUB_API_URL to api.github.com would send this forge's dispatch
echo "::warning::labels: the sweep was NOT woken from this trigger — it dispatches with \`gh\` against GitHub, and this is not a GitHub forge (GITHUB_SERVER_URL=${GITHUB_SERVER_URL:-unset}). #205 ports it to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller — issue events included — is unavailable until then." # to GitHub and report success — the same unset-environment guess
exit 0 # #201 just refused for docs-sync. The API root is injected by the
# forge running us; if it is absent we do not know where we are, and
# a guess is worse than a red trigger
# (@codex-reviewer-andresmgsl, #205 review).
api="${GITHUB_API_URL:-}"
if [ -z "$api" ]; then
echo "::error::labels: the sweep was NOT woken — GITHUB_API_URL is unset, so the forge's API root is unknown. Refusing to guess a forge."
exit 1
fi fi
if ! command -v gh >/dev/null 2>&1; then
echo "::warning::labels: the sweep was NOT woken from this trigger — this runner does not carry \`gh\`. #205 ports the dispatch to REST. The hourly SCHEDULED sweep still runs; every event-driven wake through this caller is unavailable until then." # `gh workflow run` defaulted the ref to the repository's default
exit 0 # branch; REST has no default and 400s without one. Prefer the event
# payload, fall back to an API read: on a `pull_request_target` run
# GITHUB_REF_NAME is `<n>/merge`, which is not a branch and would
# dispatch nothing.
branch="${DEFAULT_BRANCH:-}"
if [ -z "$branch" ]; then
branch="$(curl -fsS -H "Authorization: Bearer $GITHUB_TOKEN" \
"$api/repos/$GITHUB_REPOSITORY" | jq -r '.default_branch // empty')"
fi fi
gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no if [ -z "$branch" ]; then
echo "::error::labels: the sweep was NOT woken — could not determine the default branch to dispatch $SWEEP_WORKFLOW on."
exit 1
fi
out="$(mktemp)"
err="$(mktemp)"
trap 'rm -f "$out" "$err"' EXIT
# A transport failure is named, not merely propagated. Letting `set
# -e` carry curl's own exit code out of the assignment DID fail the
# job — the invariant holds — but it failed with a bare status and no
# sentence, which is the opposite of this step owning its diagnostic.
if ! code="$(curl -sS -o "$out" -w '%{http_code}' -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg ref "$branch" '{ref: $ref, inputs: {bootstrap: "no"}}')" \
"$api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches" \
2>"$err")"; then
echo "::error::labels: the sweep was NOT woken — the request to $api never completed: $(tr -d '\n' <"$err")"
exit 1
fi
if [ "$code" != "204" ]; then
# Own the diagnostic rather than pass the status through. This
# Forgejo answers an unknown workflow name — and a bare ref that
# does not resolve — with `500` and an EMPTY body, so the raw
# status alone sends the reader looking for a server fault that is
# not there.
echo "::error::labels: the sweep was NOT woken — POST $api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches (ref=$branch) returned HTTP $code: $(tr -d '\n' <"$out")"
echo "::error::labels: check that $SWEEP_WORKFLOW exists on $branch, declares a \`bootstrap\` workflow_dispatch input, and that this caller grants \`actions: write\`. An empty 500 body from Forgejo means the workflow name or the ref did not resolve."
exit 1
fi
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"

View file

@ -39,7 +39,7 @@ permissions:
contents: read contents: read
checks: read # mergeability/check-rollup read for PR state checks: read # mergeability/check-rollup read for PR state
statuses: read # commit-status rollup read for PR state statuses: read # commit-status rollup read for PR state
actions: write # the trigger job's `gh workflow run` dispatch of the sweep caller (#209) actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
issues: write issues: write
pull-requests: write pull-requests: write
jobs: jobs:

44
changelog.d/205.md Normal file
View file

@ -0,0 +1,44 @@
### Fixed
- `.github/workflows/labels.yml` wakes the sweep over REST instead of
`gh workflow run`, so a board event reconciles within seconds on any forge
rather than waiting up to an hour for the scheduled sweep (#205).
- The workflow-dispatch endpoint has the same shape on both forges, so that
step no longer decides one: the `CEREMONY_FORGE_CLIENT=gh` declaration and
both inline refusals are gone rather than ported (#205).
- The dispatch supplies its `ref` explicitly, because REST has no default
branch where `gh workflow run` had one, and refuses without it (#205).
- It takes that ref from the repository, never from `GITHUB_REF_NAME` — on a
`pull_request_target` run that is `<n>/merge`, which is not a branch (#205).
- A failed dispatch names the endpoint, the ref and the status, and says that
an empty `500` body from Forgejo means the workflow name or the ref did not
resolve — a bare status sends the reader after a server fault that is not
there (#205).
### Added
- `test/labels-dispatch.test.sh` extracts the shipped step and executes it
against a recording stub, asserting the method, endpoint, ref and
`inputs.bootstrap` actually sent (#205).
- That test also drives the failure path: any non-204 still fails the job, so
the misconfiguration alarm the trigger exists to be cannot decay into a
warning (#205).
- An unset `GITHUB_API_URL` refuses before any request instead of defaulting
to `api.github.com`. Guessing sent this forge's dispatch to GitHub and
reported success — the "Never 'probably github'" rule, and the same
unset-environment refusal #201 established for docs-sync (#205).
- A dispatch that never reaches the forge names the failure. Letting `set -e`
carry curl's exit code out did fail the job, but with a bare status and no
sentence (#205).
- `docs/CONSUMERS.md` and both caller comments describe the REST dispatch, and
the manual bootstrap command carries a forge-neutral form beside the `gh`
one — a cross-forge runbook that directs this forge to a missing binary is
wrong even where the surrounding prose is right (#205).

View file

@ -339,7 +339,9 @@ 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 Two jobs: additive path-based `scope:*` labels, and a few-seconds
`trigger` job that wakes the sweep by dispatching the consumer's sweep `trigger` job that wakes the sweep by dispatching the consumer's sweep
caller (`gh workflow run`, plain `GITHUB_TOKEN``workflow_dispatch` is 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, 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).
@ -404,7 +406,7 @@ permissions:
contents: read contents: read
checks: read # mergeability/check-rollup read for PR state checks: read # mergeability/check-rollup read for PR state
statuses: read # commit-status rollup read for PR state statuses: read # commit-status rollup read for PR state
actions: write # the trigger job's `gh workflow run` dispatch of the sweep caller (#209) actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
issues: write issues: write
pull-requests: write pull-requests: write
jobs: jobs:
@ -468,8 +470,8 @@ repositories allow check data to be read regardless, but a private consumer
needs the explicit reads above; without them the failure appears as an empty needs the explicit reads above; without them the failure appears as an empty
`state:*` axis on the board rather than a red workflow run. The labels `state:*` axis on the board rather than a red workflow run. The labels
caller's `actions: write` is different — it is required everywhere, public caller's `actions: write` is different — it is required everywhere, public
repos included: the trigger job's `gh workflow run` is a write, and without repos included: the trigger job's dispatch is a write, and without it every
it every event run goes red at the trigger. event run goes red at the trigger.
**The failure mode to know before bumping**: a consumer that bumps its pin **The failure mode to know before bumping**: a consumer that bumps its pin
to a #209-carrying tag without adding the sweep caller keeps green-looking to a #209-carrying tag without adding the sweep caller keeps green-looking
@ -526,8 +528,8 @@ carrying the split:
fires both callers into the one shared `labels-reconcile` group — so fires both callers into the one shared `labels-reconcile` group — so
displacement goes **up**, and the fix reads as the bug getting worse. displacement goes **up**, and the fix reads as the bug getting worse.
4. **`actions: write` on the labels caller** — consumers carry 4. **`actions: write` on the labels caller** — consumers carry
`actions: read` today (crew does); the trigger job's `gh workflow run` `actions: read` today (crew does); the trigger job's dispatch is a
is a write. The sweep caller keeps `actions: read`. write. The sweep caller keeps `actions: read`.
Bump without the sweep caller and the trigger job goes red on every PR Bump without the sweep caller and the trigger job goes red on every 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
@ -596,10 +598,25 @@ to bootstrap labels on a fresh repository. A bare dispatch is also the
operator's general manual full-board sweep — the answer when the board operator's general manual full-board sweep — the answer when the board
looks wrong now rather than after the next scheduled cadence: looks wrong now rather than after the next scheduled cadence:
On GitHub, with the `gh` CLI:
```sh ```sh
gh workflow run labels-sweep.yml -R <owner>/<repo> gh workflow run labels-sweep.yml -R <owner>/<repo>
``` ```
On any forge — including Forgejo, whose runners carry no `gh` — the same
dispatch over REST, which is what the trigger job itself sends (#205):
```sh
curl -sS -X POST \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"ref":"main","inputs":{"bootstrap":"yes"}}' \
"$API/repos/<owner>/<repo>/actions/workflows/labels-sweep.yml/dispatches"
```
`$API` is the forge's API root — `https://api.github.com` on GitHub,
`<instance>/api/v1` on Forgejo — and success is `204` with an empty body.
Ceremony dogfoods the callers under the filenames `self-labels.yml` and Ceremony dogfoods the callers under the filenames `self-labels.yml` and
`self-labels-sweep.yml`, so the equivalent command in this repository `self-labels-sweep.yml`, so the equivalent command in this repository
substitutes that filename. Scheduled and trigger-driven runs only substitutes that filename. Scheduled and trigger-driven runs only

188
test/labels-dispatch.test.sh Executable file
View file

@ -0,0 +1,188 @@
#!/usr/bin/env bash
# The sweep dispatch in .github/workflows/labels.yml (#205).
#
# This EXTRACTS the shipped step's `run:` script and EXECUTES it against a
# recording stub, rather than grepping the YAML for strings. A grep here would
# pass on a step that assembles a perfect request and never sends it — the
# shape of defect this repo keeps finding in its own tests. So every case
# asserts on what the step actually sent, or on what it actually did when the
# forge refused.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
WORKFLOW="$ROOT/.github/workflows/labels.yml"
TMP="$(mktemp -d)"
trap 'rm -rf "${TMP:?}"' EXIT
# --- the step under test, taken from the shipped workflow --------------------
STEP="$TMP/step.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.trigger.steps[] | select(.name == "dispatch the sweep") | .run' "$WORKFLOW"
} >"$STEP"
chmod +x "$STEP"
step_extracted() { [ "$(wc -l <"$STEP")" -ge 10 ]; }
check "the step's script was extracted from the shipped workflow" 0 "" step_extracted
# --- stubs -------------------------------------------------------------------
# `curl` records every invocation and answers with the code the case wants. It
# parses only what the step actually passes, so a step that stopped sending
# `-d`, or changed the method, fails here rather than recording nothing.
make_curl() { # make_curl <http-code> <body>
cat >"$TMP/bin/curl" <<STUB
#!/usr/bin/env bash
method=GET; data=; url=; out=; write=
while [ \$# -gt 0 ]; do
case "\$1" in
-X) method="\$2"; shift 2 ;;
-d) data="\$2"; shift 2 ;;
-o) out="\$2"; shift 2 ;;
-w) write="\$2"; shift 2 ;;
-H) printf '%s\n' "HEADER \$2" >>"$TMP/calls"; shift 2 ;;
-fsS|-sS|-s) shift ;;
*) url="\$1"; shift ;;
esac
done
printf '%s\n' "METHOD \$method" "URL \$url" "DATA \$data" >>"$TMP/calls"
# the default-branch read is a plain GET whose stdout the step pipes to jq
if [ "\$method" = GET ]; then printf '{"default_branch":"trunk"}'; exit 0; fi
[ -n "\$out" ] && printf '%s' '$2' >"\$out"
[ -n "\$write" ] && printf '%s' '$1'
exit 0
STUB
chmod +x "$TMP/bin/curl"
}
run_step() { # run_step <http-code> <body> [env assignments...]
local code="$1" body="$2"
shift 2
rm -rf "${TMP:?}/bin"
mkdir -p "$TMP/bin"
: >"$TMP/calls"
make_curl "$code" "$body"
env PATH="$TMP/bin:$PATH" \
GITHUB_TOKEN=tok \
GITHUB_API_URL=https://forge.example/api/v1 \
GITHUB_REPOSITORY=owner/repo \
SWEEP_WORKFLOW=self-labels-sweep.yml \
"$@" \
"$STEP"
}
sent() { grep -h "^$1 " "$TMP/calls" | tail -1 | cut -d' ' -f2-; }
sent_field() { jq -r "$1" <<<"$(sent DATA)"; }
# --- the success path --------------------------------------------------------
check "a 204 dispatch succeeds, naming what it woke and where" 0 \
"sweep dispatched — self-labels-sweep.yml on main" \
run_step 204 "" DEFAULT_BRANCH=main
posted() { [ "$(sent METHOD)" = POST ]; }
check "...by POST, not GET" 0 "" posted
right_endpoint() {
[ "$(sent URL)" = \
"https://forge.example/api/v1/repos/owner/repo/actions/workflows/self-labels-sweep.yml/dispatches" ]
}
check "...to the dispatches endpoint of the workflow it was told to wake" 0 "" right_endpoint
ref_is_main() { [ "$(sent_field .ref)" = main ]; }
check "...carrying a ref, because REST has no default and refuses without one" 0 "" ref_is_main
bootstrap_is_string_no() { [ "$(sent_field '.inputs.bootstrap')" = no ]; }
check "...and bootstrap=no as a STRING input, not a bare flag" 0 "" bootstrap_is_string_no
bearer_sent() { grep -qF 'HEADER Authorization: Bearer tok' "$TMP/calls"; }
check "...under the bearer header both forges accept" 0 "" bearer_sent
# --- the ref it must NOT inherit ---------------------------------------------
# On pull_request_target GITHUB_REF_NAME is `<n>/merge`. A step that reaches
# for it dispatches at something that is not a branch — and the forge answers
# that with the opaque 500, so it would look like an outage.
check "a pull_request_target run still dispatches at the branch" 0 "" \
run_step 204 "" DEFAULT_BRANCH=main GITHUB_REF_NAME=203/merge
ref_is_not_a_merge_ref() { case "$(sent_field .ref)" in *merge*) return 1 ;; *) return 0 ;; esac; }
check "...never at its merge ref" 0 "" ref_is_not_a_merge_ref
# --- the fallback ------------------------------------------------------------
check "an absent default branch is read from the forge, not guessed" 0 "" \
run_step 204 "" DEFAULT_BRANCH=
ref_is_trunk() { [ "$(sent_field .ref)" = trunk ]; }
check "...and the dispatch uses what the read returned" 0 "" ref_is_trunk
# --- failure is loud, and the diagnostic is owned ----------------------------
# `gh workflow run` failing WAS the misconfiguration alarm. The port keeps that
# contract: a consumer missing the caller, its input, or `actions: write` must
# fail here rather than sweep silently never again.
check "an empty 500 fails the step — the alarm still rings" 1 \
"/self-labels-sweep.yml/dispatches (ref=main)" \
run_step 500 "" DEFAULT_BRANCH=main
check "...explaining Forgejo's EMPTY 500 rather than passing it through" 1 \
"empty 500 body from Forgejo means the workflow name or the ref did not resolve" \
run_step 500 "" DEFAULT_BRANCH=main
check "...and naming the consumer causes the alarm exists for" 1 "actions: write" \
run_step 500 "" DEFAULT_BRANCH=main
check "any non-204 fails, not only the statuses the API documents" 1 "forbidden" \
run_step 403 '{"message":"forbidden"}' DEFAULT_BRANCH=main
# --- the API root is not guessed ---------------------------------------------
# Defaulting an unset GITHUB_API_URL to api.github.com sent this forge's
# dispatch to GitHub and reported success (@codex-reviewer-andresmgsl). The
# teeth are the call count: refusing AFTER a request is not refusing.
run_step_no_api() {
rm -rf "${TMP:?}/bin"; mkdir -p "$TMP/bin"; : >"$TMP/calls"
make_curl 204 ""
# -u, because plain `env` PRESERVES the parent environment: on the runner
# every step arrives with GITHUB_API_URL set — the premise of the fix under
# test — so without the unset this case inherits it, the refusal path never
# executes, and the case passes only in a dev shell that lacks the variable
# (@kimi-reviewer-andresmgsl, run 468).
env -u GITHUB_API_URL \
PATH="$TMP/bin:$PATH" GITHUB_TOKEN=tok GITHUB_REPOSITORY=owner/repo \
SWEEP_WORKFLOW=self-labels-sweep.yml DEFAULT_BRANCH=main "$STEP"
}
check "an unset GITHUB_API_URL refuses rather than guessing GitHub" 1 \
"GITHUB_API_URL is unset" run_step_no_api
no_calls_made() { [ ! -s "$TMP/calls" ]; }
check "...having made zero requests: refusing after a POST is not refusing" 0 "" no_calls_made
# --- a transport failure is not silence --------------------------------------
# The old `|| true` invariant was asserted by grepping the gh line this port
# removed, so it passed on any REST implementation including one that swallows
# a failed POST (@codex-reviewer-andresmgsl). Driven instead: curl itself exits
# non-zero, which `-w` cannot report because nothing is written.
make_failing_curl() {
printf '%s\n' '#!/usr/bin/env bash' 'echo "curl: (7) failed to connect" >&2' 'exit 7' \
>"$TMP/bin/curl"
chmod +x "$TMP/bin/curl"
}
run_step_curl_dies() {
rm -rf "${TMP:?}/bin"; mkdir -p "$TMP/bin"; : >"$TMP/calls"
make_failing_curl
env PATH="$TMP/bin:$PATH" GITHUB_TOKEN=tok \
GITHUB_API_URL=https://forge.example/api/v1 GITHUB_REPOSITORY=owner/repo \
SWEEP_WORKFLOW=self-labels-sweep.yml DEFAULT_BRANCH=main "$STEP"
}
check "a POST that never reaches the forge fails the step, and says so" 1 \
"never completed" run_step_curl_dies
# A code-aware guard alongside the behavioural one: no swallowing operator on
# the dispatch itself.
never_silenced() { sed 's/#.*//' "$STEP" | grep -qE '\|\|[[:space:]]*true'; }
check "...and the step carries no || true" 1 "" never_silenced
# --- what the port removed ---------------------------------------------------
# Strip comments first: the step's prose NAMES `gh workflow run` and
# CEREMONY_FORGE_CLIENT to explain what it replaced, so a raw grep asserts on
# the explanation instead of the code.
step_code() { sed 's/#.*//' "$STEP"; }
invokes_gh() { step_code | grep -qE '(^|[^[:alnum:]_])gh[[:space:]]'; }
decides_forge() { step_code | grep -qE 'GITHUB_SERVER_URL|CEREMONY_FORGE_CLIENT'; }
check "the step no longer INVOKES gh, its comments about it aside" 1 "" invokes_gh
check "...and no longer decides a forge, because REST needs no branch" 1 "" decides_forge
summary

View file

@ -70,13 +70,24 @@ check "the sweep keeps the ONE shared concurrency group" 0 "group: labels-reconc
grep -F 'group: labels-reconcile' "$SWEEP" grep -F 'group: labels-reconcile' "$SWEEP"
check "labels.yml carries the trigger job" 0 " trigger:" \ check "labels.yml carries the trigger job" 0 " trigger:" \
grep -E '^ trigger:' "$REUSABLE" grep -E '^ trigger:' "$REUSABLE"
# #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.
# shellcheck disable=SC2016 # $SWEEP_WORKFLOW is the workflow's own env var, asserted literally # shellcheck disable=SC2016 # $SWEEP_WORKFLOW is the workflow's own env var, asserted literally
check "the trigger dispatches the sweep caller, never bootstrapping" 0 \ check "the trigger dispatches the sweep caller by name" 0 \
'gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no' \ 'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
grep -F 'gh workflow run' "$REUSABLE" grep -F '/dispatches' "$REUSABLE"
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here check "...never bootstrapping" 0 'bootstrap: "no"' \
check "the trigger dispatch is never silenced with || true" 1 "" \ grep -F 'bootstrap' "$REUSABLE"
bash -c 'grep -F "gh workflow run" "$1" | grep -qF "|| true"' _ "$REUSABLE" # 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).
check "the sweep caller filename input defaults to labels-sweep.yml" 0 \ check "the sweep caller filename input defaults to labels-sweep.yml" 0 \
"default: labels-sweep.yml" grep -F 'default: labels-sweep.yml' "$REUSABLE" "default: labels-sweep.yml" grep -F 'default: labels-sweep.yml' "$REUSABLE"
# the dogfood callers wear the split: the event caller names its deviant # the dogfood callers wear the split: the event caller names its deviant

View file

@ -205,13 +205,20 @@ printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
' gh workflow run x' >"$TMP/declared-both.yml" ' gh workflow run x' >"$TMP/declared-both.yml"
check "...and a declaration guarding BOTH forge and binary is" 0 "" \ check "...and a declaration guarding BOTH forge and binary is" 0 "" \
refuses_when_unavailable "$TMP/declared-both.yml" refuses_when_unavailable "$TMP/declared-both.yml"
# The shipped workflow is the real customer for that pair. # labels.yml WAS the real customer for that pair. #205 ported its dispatch to
check "labels.yml declares the client it speaks" 0 "" \ # REST, so it no longer speaks gh and must no longer declare a client — the
# exemption is spent, not inherited. Asserting its ABSENCE is what stops the
# declaration coming back as cover for a re-added `gh` call: an opt-out with no
# gh behind it is a standing permission slip.
check "labels.yml no longer declares a client, because it speaks none (#205)" 1 "" \
declares_gh_client "$ROOT/.github/workflows/labels.yml" declares_gh_client "$ROOT/.github/workflows/labels.yml"
check "...decides the forge before dispatching" 0 "" \ # shellcheck disable=SC2016 # `$SWEEP_WORKFLOW` is the literal the YAML must
refuses_wrong_forge "$ROOT/.github/workflows/labels.yml" # carry: the endpoint has to be built from the caller's input, not hardcoded.
check "...and checks the binary too, rather than dying on command not found" 0 "" \ labels_yml_dispatches_by_rest() {
refuses_missing_binary "$ROOT/.github/workflows/labels.yml" grep -qF 'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
"$ROOT/.github/workflows/labels.yml"
}
check "...and dispatches the sweep over REST instead" 0 "" labels_yml_dispatches_by_rest
check "...and an undeclared one does not" 1 "" declares_gh_client "$TMP/bad.sh" check "...and an undeclared one does not" 1 "" declares_gh_client "$TMP/bad.sh"
# A mention of the variable in prose is not a declaration. # A mention of the variable in prose is not a declaration.
printf '%s\n' '#!/usr/bin/env bash' '# CEREMONY_FORGE_CLIENT=gh would opt out' \ printf '%s\n' '#!/usr/bin/env bash' '# CEREMONY_FORGE_CLIENT=gh would opt out' \