diff --git a/.github/workflows/labels-sweep.yml b/.github/workflows/labels-sweep.yml index 707b871..defca41 100644 --- a/.github/workflows/labels-sweep.yml +++ b/.github/workflows/labels-sweep.yml @@ -87,7 +87,7 @@ jobs: # equivalent of. # # 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 # bootstrap from an event-woken sweep — the caller's `bootstrap` # dispatch input does: the trigger passes "no", a bare manual diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 925ff2f..8cdf02c 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -126,33 +126,76 @@ jobs: steps: - name: dispatch the sweep env: - GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }} - # This step speaks gh and says so, the same declaration - # 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 + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | - # Two questions, not one. @codex-reviewer-andresmgsl: a guard that - # only asks `command -v gh` passes the moment a Forgejo runner image - # happens to ship gh — and then runs a GitHub dispatch against a - # forge that cannot serve it, which is the client/forge mismatch - # forge_preflight exists to prevent. So the FORGE is decided first, - # mirroring forge_detect positively (only github.com is accepted; - # anything else, known or not, is refused — "Never 'probably - # github'"), and the binary is checked second. + # REST, not `gh` (#205). The workflow-dispatch endpoint has the SAME + # shape on both forges — + # POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches + # {"ref": "", "inputs": {...}} -> 204, empty body + # — so this step no longer decides a forge at all. That is why the + # `CEREMONY_FORGE_CLIENT: gh` declaration and both inline refusals are + # gone rather than ported: there is nothing left to refuse. Measured + # 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 - # alarm for a CONSUMER's missing sweep caller, and reddening every - # sweep on a forge for a gap #205 already owns would drown that - # signal. #205 ports the dispatch to REST and removes all of this. - if [ "${GITHUB_SERVER_URL:-}" != "https://github.com" ]; then - 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." - exit 0 + # STILL LOUD on failure, per this job's contract: a consumer missing + # the sweep caller, its `bootstrap` input, or `actions: write` must + # fail HERE and visibly, not sweep silently never again. + # NEVER "probably github" (lib/forge.sh). Defaulting an unset + # GITHUB_API_URL to api.github.com would send this forge's dispatch + # to GitHub and report success — the same unset-environment guess + # #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 - 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." - exit 0 + + # `gh workflow run` defaulted the ref to the repository's default + # 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 `/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 - 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)" diff --git a/.github/workflows/self-labels.yml b/.github/workflows/self-labels.yml index 715f1ee..8b6b8c1 100644 --- a/.github/workflows/self-labels.yml +++ b/.github/workflows/self-labels.yml @@ -39,7 +39,7 @@ permissions: contents: read checks: read # mergeability/check-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 pull-requests: write jobs: diff --git a/changelog.d/205.md b/changelog.d/205.md new file mode 100644 index 0000000..50ed6f6 --- /dev/null +++ b/changelog.d/205.md @@ -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 `/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). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index 0454f32..e5a3d59 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -339,7 +339,9 @@ 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 (`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, so no PAT anywhere in the path and no loop: the sweep dispatches nothing). @@ -404,7 +406,7 @@ permissions: contents: read checks: read # mergeability/check-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 pull-requests: write 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 `state:*` axis on the board rather than a red workflow run. The labels 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 -it every event run goes red at the trigger. +repos included: the trigger job's dispatch is a write, and without it every +event run goes red at the trigger. **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 @@ -526,8 +528,8 @@ carrying the split: fires both callers into the one shared `labels-reconcile` group — so displacement goes **up**, and the fix reads as the bug getting worse. 4. **`actions: write` on the labels caller** — consumers carry - `actions: read` today (crew does); the trigger job's `gh workflow run` - is a write. The sweep caller keeps `actions: read`. + `actions: read` today (crew does); the trigger job's dispatch is a + write. The sweep caller keeps `actions: read`. 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 @@ -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 looks wrong now rather than after the next scheduled cadence: +On GitHub, with the `gh` CLI: + ```sh gh workflow run labels-sweep.yml -R / ``` +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///actions/workflows/labels-sweep.yml/dispatches" +``` + +`$API` is the forge's API root — `https://api.github.com` on GitHub, +`/api/v1` on Forgejo — and success is `204` with an empty body. + Ceremony dogfoods the callers under the filenames `self-labels.yml` and `self-labels-sweep.yml`, so the equivalent command in this repository substitutes that filename. Scheduled and trigger-driven runs only diff --git a/test/labels-dispatch.test.sh b/test/labels-dispatch.test.sh new file mode 100755 index 0000000..d7c3bf4 --- /dev/null +++ b/test/labels-dispatch.test.sh @@ -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 + cat >"$TMP/bin/curl" <>"$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 [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 `/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 diff --git a/test/labels-triggers.test.sh b/test/labels-triggers.test.sh index c547ae9..14847c5 100644 --- a/test/labels-triggers.test.sh +++ b/test/labels-triggers.test.sh @@ -70,13 +70,24 @@ check "the sweep keeps the ONE shared concurrency group" 0 "group: labels-reconc grep -F 'group: labels-reconcile' "$SWEEP" check "labels.yml carries the trigger job" 0 " trigger:" \ 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 -check "the trigger dispatches the sweep caller, never bootstrapping" 0 \ - 'gh workflow run "$SWEEP_WORKFLOW" -R "$GITHUB_REPOSITORY" -f bootstrap=no' \ - grep -F 'gh workflow run' "$REUSABLE" -# shellcheck disable=SC2016 # $1 expands in the nested bash, not here -check "the trigger dispatch is never silenced with || true" 1 "" \ - bash -c 'grep -F "gh workflow run" "$1" | grep -qF "|| true"' _ "$REUSABLE" +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" +# 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 \ "default: labels-sweep.yml" grep -F 'default: labels-sweep.yml' "$REUSABLE" # the dogfood callers wear the split: the event caller names its deviant diff --git a/test/no-runtime-gh.test.sh b/test/no-runtime-gh.test.sh index 7ef6b59..bb2d2ec 100755 --- a/test/no-runtime-gh.test.sh +++ b/test/no-runtime-gh.test.sh @@ -205,13 +205,20 @@ printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \ ' gh workflow run x' >"$TMP/declared-both.yml" check "...and a declaration guarding BOTH forge and binary is" 0 "" \ refuses_when_unavailable "$TMP/declared-both.yml" -# The shipped workflow is the real customer for that pair. -check "labels.yml declares the client it speaks" 0 "" \ +# labels.yml WAS the real customer for that pair. #205 ported its dispatch to +# 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" -check "...decides the forge before dispatching" 0 "" \ - refuses_wrong_forge "$ROOT/.github/workflows/labels.yml" -check "...and checks the binary too, rather than dying on command not found" 0 "" \ - refuses_missing_binary "$ROOT/.github/workflows/labels.yml" +# shellcheck disable=SC2016 # `$SWEEP_WORKFLOW` is the literal the YAML must +# carry: the endpoint has to be built from the caller's input, not hardcoded. +labels_yml_dispatches_by_rest() { + 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" # 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' \