diff --git a/.github/workflows/build_and_publish_container.yml b/.github/workflows/build_and_publish_container.yml index 04541f0..a98bab4 100644 --- a/.github/workflows/build_and_publish_container.yml +++ b/.github/workflows/build_and_publish_container.yml @@ -68,7 +68,22 @@ on: value: ${{ jobs.gate.outputs.should-release }} jobs: + # Refuse trigger patterns known to expose this workflow to supply-chain + # attack classes (pull_request_target, workflow_run via pull_request_target). + # The guard MUST stay first under `jobs:`; every other job lists `guard` + # in its `needs:` so a refused invocation skips the entire workflow — + # critical for the container build type, whose docker push happens + # mid-composite (no verify step could roll back a pushed image). + # Full trigger contract: docs/SPEC.md#trigger-model. + guard: + runs-on: ubuntu-latest + permissions: {} # reads env only — no workspace, no API, no secrets + steps: + # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) + - uses: TomHennen/wrangle/actions/preflight_guard@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 + gate: + needs: [guard] runs-on: ubuntu-latest permissions: {} # release_gate reads only env; no workspace, no API outputs: @@ -76,12 +91,13 @@ jobs: steps: # No checkout: release_gate resolves via ${{ github.action_path }}. # TODO: replace with $/actions/release_gate when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/release_gate@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + - uses: TomHennen/wrangle/actions/release_gate@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 id: gate with: events: ${{ inputs.release-events }} build: + needs: [guard] permissions: contents: read packages: write @@ -94,7 +110,7 @@ jobs: # TODO: replace with $/build/actions/container when GitHub ships $/ syntax (#136) - name: "build and publish" id: build - uses: TomHennen/wrangle/build/actions/container@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + uses: TomHennen/wrangle/build/actions/container@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 with: path: ${{ inputs.path }} imagename: ${{ inputs.imagename }} @@ -105,7 +121,7 @@ jobs: # Gated on release-events (default: non-pull-request) so adopters can # tighten when provenance is produced without forking the workflow. if: ${{ needs.gate.outputs.should-release == 'true' }} - needs: [gate, build] + needs: [guard, gate, build] permissions: actions: read # for detecting the Github Actions environment. id-token: write # for creating OIDC tokens for signing. @@ -138,7 +154,7 @@ jobs: # Tests assert the lockstep so a single-side bump fails CI loudly. verify: if: ${{ needs.gate.outputs.should-release == 'true' && inputs.verify-image }} - needs: [gate, build, provenance] + needs: [guard, gate, build, provenance] runs-on: ubuntu-latest # No registry credentials needed: cosign verify-attestation against # ghcr.io public images works anonymously, and the SLSA attestation diff --git a/.github/workflows/build_and_publish_npm.yml b/.github/workflows/build_and_publish_npm.yml index 82da613..5f196bb 100644 --- a/.github/workflows/build_and_publish_npm.yml +++ b/.github/workflows/build_and_publish_npm.yml @@ -115,7 +115,20 @@ on: value: ${{ jobs.gate.outputs.should-release }} jobs: + # Refuse trigger patterns known to expose this workflow to supply-chain + # attack classes (pull_request_target, workflow_run via pull_request_target). + # The guard MUST stay first under `jobs:`; every other job lists `guard` + # in its `needs:` so a refused invocation skips the entire workflow. + # Full trigger contract: docs/SPEC.md#trigger-model. + guard: + runs-on: ubuntu-latest + permissions: {} # reads env only — no workspace, no API, no secrets + steps: + # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) + - uses: TomHennen/wrangle/actions/preflight_guard@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 + gate: + needs: [guard] runs-on: ubuntu-latest permissions: {} # release_gate reads only env; no workspace, no API outputs: @@ -123,12 +136,13 @@ jobs: steps: # No checkout: release_gate resolves via ${{ github.action_path }}. # TODO: replace with $/actions/release_gate when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/release_gate@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + - uses: TomHennen/wrangle/actions/release_gate@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 id: gate with: events: ${{ inputs.release-events }} build: + needs: [guard] runs-on: ubuntu-latest permissions: contents: read # minimal — no OIDC, no publish @@ -146,7 +160,7 @@ jobs: ref: ${{ inputs.ref || '' }} # TODO: replace with $/build/actions/npm when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/build/actions/npm@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + - uses: TomHennen/wrangle/build/actions/npm@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 id: build with: path: ${{ inputs.path }} @@ -188,7 +202,7 @@ jobs: # write is mandatory even when upload-assets is false. provenance: if: ${{ needs.gate.outputs.should-release == 'true' }} - needs: [gate, build] + needs: [guard, gate, build] permissions: actions: read # detect the GitHub Actions environment id-token: write # OIDC for Sigstore signing @@ -210,7 +224,7 @@ jobs: # flows pass `verify-provenance: false` and wire their own. verify: if: ${{ needs.gate.outputs.should-release == 'true' && inputs.verify-provenance }} - needs: [gate, build, provenance] + needs: [guard, gate, build, provenance] runs-on: ubuntu-latest permissions: {} # default token; download-artifact reads same-run artifacts steps: diff --git a/.github/workflows/build_and_publish_python.yml b/.github/workflows/build_and_publish_python.yml index a0d53f9..c87d657 100644 --- a/.github/workflows/build_and_publish_python.yml +++ b/.github/workflows/build_and_publish_python.yml @@ -94,11 +94,24 @@ on: value: ${{ jobs.gate.outputs.should-release }} jobs: + # Refuse trigger patterns known to expose this workflow to supply-chain + # attack classes (pull_request_target, workflow_run via pull_request_target). + # The guard MUST stay first under `jobs:`; every other job lists `guard` + # in its `needs:` so a refused invocation skips the entire workflow. + # Full trigger contract: docs/SPEC.md#trigger-model. + guard: + runs-on: ubuntu-latest + permissions: {} # reads env only — no workspace, no API, no secrets + steps: + # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) + - uses: TomHennen/wrangle/actions/preflight_guard@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 + # Decide whether release-time actions (provenance, publish) should run # for this event. Cheap separate job so the result is available to # both the provenance job below and the caller's publish job via # outputs.should-release. gate: + needs: [guard] runs-on: ubuntu-latest permissions: {} # release_gate reads only env; no workspace, no API outputs: @@ -106,12 +119,13 @@ jobs: steps: # No checkout: release_gate resolves via ${{ github.action_path }}. # TODO: replace with $/actions/release_gate when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/actions/release_gate@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + - uses: TomHennen/wrangle/actions/release_gate@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 id: gate with: events: ${{ inputs.release-events }} build: + needs: [guard] runs-on: ubuntu-latest permissions: contents: read # minimal — no OIDC, no publish @@ -128,7 +142,7 @@ jobs: ref: ${{ inputs.ref || '' }} # TODO: replace with $/build/actions/python when GitHub ships $/ syntax (#136) - - uses: TomHennen/wrangle/build/actions/python@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + - uses: TomHennen/wrangle/build/actions/python@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 id: build with: path: ${{ inputs.path }} @@ -173,7 +187,7 @@ jobs: # only uploads provenance as a workflow artifact (90-day retention). provenance: if: ${{ needs.gate.outputs.should-release == 'true' }} - needs: [gate, build] + needs: [guard, gate, build] permissions: actions: read # detect the GitHub Actions environment id-token: write # OIDC for Sigstore signing @@ -202,7 +216,7 @@ jobs: # `verify-provenance: false` and wire their own. verify: if: ${{ needs.gate.outputs.should-release == 'true' && inputs.verify-provenance }} - needs: [gate, build, provenance] + needs: [guard, gate, build, provenance] runs-on: ubuntu-latest permissions: {} # default token; download-artifact reads same-run artifacts steps: diff --git a/.github/workflows/build_shell.yml b/.github/workflows/build_shell.yml index f514682..615cadb 100644 --- a/.github/workflows/build_shell.yml +++ b/.github/workflows/build_shell.yml @@ -19,7 +19,20 @@ on: default: "" jobs: + # Refuse trigger patterns known to expose this workflow to supply-chain + # attack classes (pull_request_target, workflow_run via pull_request_target). + # The guard MUST stay first under `jobs:`; every other job lists `guard` + # in its `needs:` so a refused invocation skips the entire workflow. + # Full trigger contract: docs/SPEC.md#trigger-model. + guard: + runs-on: ubuntu-latest + permissions: {} # reads env only — no workspace, no API, no secrets + steps: + # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) + - uses: TomHennen/wrangle/actions/preflight_guard@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 + shell-build: + needs: [guard] runs-on: ubuntu-latest permissions: contents: read @@ -30,7 +43,7 @@ jobs: - name: Run shell build # TODO: replace with $/build/actions/shell when GitHub ships $/ syntax (#136) - uses: TomHennen/wrangle/build/actions/shell@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + uses: TomHennen/wrangle/build/actions/shell@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 with: scan-path: ${{ inputs.scan-path }} bats-path: ${{ inputs.bats-path }} diff --git a/.github/workflows/check_source_change.yml b/.github/workflows/check_source_change.yml index 929f63f..8828e60 100644 --- a/.github/workflows/check_source_change.yml +++ b/.github/workflows/check_source_change.yml @@ -10,7 +10,20 @@ on: default: "osv zizmor scorecard:info" jobs: + # Refuse trigger patterns known to expose this workflow to supply-chain + # attack classes (pull_request_target, workflow_run via pull_request_target). + # The guard MUST stay first under `jobs:`; every other job lists `guard` + # in its `needs:` so a refused invocation skips the entire workflow. + # Full trigger contract: docs/SPEC.md#trigger-model. + guard: + runs-on: ubuntu-latest + permissions: {} # reads env only — no workspace, no API, no secrets + steps: + # TODO: replace with $/actions/preflight_guard when GitHub ships $/ syntax (#136) + - uses: TomHennen/wrangle/actions/preflight_guard@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 + check-change: + needs: [guard] permissions: actions: read contents: read @@ -25,6 +38,6 @@ jobs: # action-pattern tools (zizmor, scorecard) via uses: internally. # TODO: replace with $/actions/scan when GitHub ships $/ syntax (#136) - name: Source scan - uses: TomHennen/wrangle/actions/scan@dad131c0f4faf6348952ad21b037750cb568834a # claude/v0.2-pnpm-support 2026-05-14 + uses: TomHennen/wrangle/actions/scan@8f40e053e1423fb59008501f83126d7ff25fcae4 # claude/202-refuse-pull-request-target 2026-05-14 with: tools: ${{ inputs.tools }} diff --git a/actions/preflight_guard/action.yml b/actions/preflight_guard/action.yml new file mode 100644 index 0000000..567f79d --- /dev/null +++ b/actions/preflight_guard/action.yml @@ -0,0 +1,27 @@ +name: Wrangle Preflight Guard +description: > + Refuse trigger patterns that would expose wrangle's reusable workflows + to known supply-chain attack classes. Used at the head of every wrangle + reusable workflow so a refused invocation skips the entire run + (`needs: [guard]` propagation). + + Currently refuses: + - pull_request_target invocations + - workflow_run invocations triggered by pull_request_target + These are the "pwn request" vector that compromised TanStack/router via + Mini Shai-Hulud in May 2026. No legitimate wrangle adoption uses these + triggers for build/publish workflows. + + No inputs. Wrangle owns the refuse-list; adopters add `needs: [guard]` + to their downstream jobs and the rest is automatic. + + See docs/SPEC.md#trigger-model for the full trigger contract. + +runs: + using: composite + steps: + - shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + OUTER_EVENT: ${{ github.event.workflow_run.event }} + run: "${{ github.action_path }}/preflight_guard.sh" diff --git a/actions/preflight_guard/preflight_guard.sh b/actions/preflight_guard/preflight_guard.sh new file mode 100755 index 0000000..f561a0c --- /dev/null +++ b/actions/preflight_guard/preflight_guard.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# preflight_guard.sh — refuse trigger patterns known to expose wrangle's +# reusable workflows to supply-chain attack classes. Fails the workflow +# fast; downstream jobs with `needs: [guard]` skip via standard +# `needs:` propagation. +# +# Required env (set by the composite action wrapper): +# EVENT_NAME — github.event_name +# OUTER_EVENT — github.event.workflow_run.event (empty unless event is +# workflow_run) +# +# Refusals are listed below. New refusal categories belong here, with a +# matching entry in docs/SPEC.md#trigger-model. + +set -euo pipefail +set -f # env vars come from GitHub event context — disable globbing defensively + +fail() { + printf '::error::wrangle refuses %s.\n' "$1" >&2 + printf "::error::This trigger pattern combined with a checkout of PR head SHA is the 'pwn request' vector that compromised TanStack/router via Mini Shai-Hulud in May 2026.\n" >&2 + printf '::error::Move build/publish to a push-triggered workflow (push to main or tags). See docs/SPEC.md#trigger-model.\n' >&2 + exit 1 +} + +if [[ "$EVENT_NAME" == "pull_request_target" ]]; then + fail "pull_request_target invocations" +fi + +if [[ "$EVENT_NAME" == "workflow_run" && "$OUTER_EVENT" == "pull_request_target" ]]; then + fail "workflow_run invocations triggered by pull_request_target" +fi + +printf 'Event "%s" allowed.\n' "$EVENT_NAME" diff --git a/actions/preflight_guard/test.bats b/actions/preflight_guard/test.bats new file mode 100644 index 0000000..ef62839 --- /dev/null +++ b/actions/preflight_guard/test.bats @@ -0,0 +1,88 @@ +#!/usr/bin/env bats + +# Tests for the preflight_guard composite action. Two flavors: +# +# - Behavioral: run preflight_guard.sh directly with EVENT_NAME / +# OUTER_EVENT set, assert exit code + emitted message. These cover +# the actual refusal logic. +# - Structural: grep-based fingerprints on action.yml / the script. +# These survive script-internal refactors and break loudly if a +# drive-by edit swaps the guard for a no-op step or strips the +# env-passthrough pattern. + +setup() { + ACTION_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")" && pwd)" + ACTION="$ACTION_DIR/action.yml" + SCRIPT="$ACTION_DIR/preflight_guard.sh" +} + +# --- behavioral --- + +@test "behavior: refuses pull_request_target" { + EVENT_NAME=pull_request_target OUTER_EVENT="" run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" == *"pull_request_target invocations"* ]] +} + +@test "behavior: refuses workflow_run triggered by pull_request_target" { + EVENT_NAME=workflow_run OUTER_EVENT=pull_request_target run "$SCRIPT" + [[ "$status" -eq 1 ]] + [[ "$output" == *"workflow_run invocations triggered by pull_request_target"* ]] +} + +@test "behavior: allows push" { + EVENT_NAME=push OUTER_EVENT="" run "$SCRIPT" + [[ "$status" -eq 0 ]] + [[ "$output" == *'Event "push" allowed.'* ]] +} + +@test "behavior: allows workflow_dispatch" { + EVENT_NAME=workflow_dispatch OUTER_EVENT="" run "$SCRIPT" + [[ "$status" -eq 0 ]] +} + +@test "behavior: allows workflow_call" { + EVENT_NAME=workflow_call OUTER_EVENT="" run "$SCRIPT" + [[ "$status" -eq 0 ]] +} + +@test "behavior: allows workflow_run triggered by push (not pull_request_target)" { + EVENT_NAME=workflow_run OUTER_EVENT=push run "$SCRIPT" + [[ "$status" -eq 0 ]] +} + +# --- structural --- + +@test "structure: action.yml exists" { + [[ -f "$ACTION" ]] +} + +@test "structure: script exists and is executable" { + [[ -x "$SCRIPT" ]] +} + +@test "structure: action.yml delegates to the script" { + run grep 'preflight_guard.sh' "$ACTION" + [[ "$status" -eq 0 ]] +} + +@test "structure: action.yml passes env vars (no expression interpolation into shell body)" { + # EVENT_NAME and OUTER_EVENT must be passed via env:, not interpolated + # into the script body. Matches wrangle's injection-safety convention. + run grep -F 'EVENT_NAME: ${{ github.event_name }}' "$ACTION" + [[ "$status" -eq 0 ]] + run grep -F 'OUTER_EVENT: ${{ github.event.workflow_run.event }}' "$ACTION" + [[ "$status" -eq 0 ]] +} + +@test "structure: error message references the pwn-request vector" { + # Fingerprint that survives editorial polish but breaks if the guard + # is silently swapped for a no-op step. + run grep -F "'pwn request' vector" "$SCRIPT" + [[ "$status" -eq 0 ]] +} + +@test "structure: error message points adopters at docs/SPEC.md#trigger-model" { + run grep -F 'docs/SPEC.md#trigger-model' "$SCRIPT" + [[ "$status" -eq 0 ]] +} diff --git a/docs/SPEC.md b/docs/SPEC.md index 3235ed9..3d63896 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -759,6 +759,44 @@ Wrangle runs security tools on behalf of adopting repositories. This makes it a 2. **Compromised wrangle itself** — an attacker gains commit access to the wrangle repo 3. **Malicious adapter inputs** — attacker-controlled data flows into shell commands 4. **Tool misbehavior** — a tool writes outside its output directory, exfiltrates data, or produces malicious SARIF +5. **Adopter trigger misconfiguration** — an adopter wires wrangle's reusable workflows under a GitHub Actions trigger that runs attacker-influenced code in the base repo's privileged context. See "Trigger Model" below. + +### Trigger Model + +Every wrangle reusable workflow runs a `guard` job at the head of `jobs:` (the [`actions/preflight_guard`](../actions/preflight_guard/action.yml) composite action). Refusal fails the workflow; every other job declares `needs: [guard]` so a refused invocation skips the entire run — no OIDC tokens minted, no privileged actions executed, no docker push, no provenance generation. + +**Triggers wrangle's reusable workflows are designed for:** + +- `push` to `main`, release branches, or tags. +- `push` to integration test branches (e.g., `integration/**` used by `test/integration/dispatch.sh`). +- `workflow_dispatch` (manual). +- `workflow_call` (when one wrangle reusable workflow wraps another internally). + +**Triggers `preflight_guard` refuses:** + +- `pull_request_target` — runs in the **base** repo's privileged context with the base repo's secrets, while a checkout of `${{ github.event.pull_request.head.sha }}` brings in PR-author code. This is the "pwn request" vector: attacker code executes with secrets it shouldn't have. The TanStack/router Mini Shai-Hulud compromise (May 2026) is the most-cited recent exploitation. +- `workflow_run` triggered by `pull_request_target` — indirect form of the same vector. The outer event (`github.event.workflow_run.event`) is checked, not just `github.event_name`. + +**Triggers `preflight_guard` does NOT (currently) refuse but adopters should still be careful about:** + +- `pull_request` from a fork that the workflow then `checkout`s with `ref: ${{ github.event.pull_request.head.sha }}` — this is the same untrusted-checkout pattern but without base-repo privileges, so the blast radius is smaller. `actions/scan`'s `zizmor` runs in wrangle's source-scan path catches this finding for adopters. +- `workflow_dispatch` chains where an upstream workflow was itself `pull_request_target`-triggered — GitHub flattens the event chain to `workflow_dispatch` and the guard sees only that. Out of scope; the `workflow_run`-via-`pull_request_target` check covers the most common indirect vector. + +**Guard vs. gate — two preflight check shapes:** + +Wrangle's reusable workflows have two kinds of checks that sit at workflow start. Different mechanisms, different jobs to gate downstream on: + +| | `actions/preflight_guard` | `actions/release_gate` | +|---|---|---| +| **What it does** | Refuses the workflow run if the trigger is unsafe | Decides whether release-time actions should run this event/ref | +| **Mechanism** | Fails (`exit 1`) on a refused trigger | Outputs `should-release: true/false` | +| **Downstream uses it as** | `needs: [guard]` — fail propagation | `if: needs.gate.outputs.should-release == 'true'` — signal branching | +| **What happens on a "no"** | Whole workflow fails; everything skips | Workflow succeeds; non-release jobs (build, test) still run; release-time jobs (provenance, publish) skip | +| **Why this shape** | A green ✅ with everything skipped would hide the misconfiguration — fail-loud is the security-relevant property | Legit non-release events (PR builds) should still run build/test, just not provenance/publish | + +The `_guard` / `_gate` suffix is the name's contract: `_guard` = abort on fail, `_gate` = signal and let downstream branch. + +**Adding refusal categories:** add the check to `actions/preflight_guard/preflight_guard.sh`, add a matching row to the "refuses" list above, and add a structural assertion to `actions/preflight_guard/test.bats`. ### Integrity Verification diff --git a/test/test_refuse_pull_request_target.bats b/test/test_refuse_pull_request_target.bats new file mode 100644 index 0000000..39d19c1 --- /dev/null +++ b/test/test_refuse_pull_request_target.bats @@ -0,0 +1,110 @@ +#!/usr/bin/env bats + +# Structural tests asserting that every reusable workflow in +# .github/workflows/ wires the preflight guard correctly. The guard's +# own refusal-logic tests live next to the action source in +# actions/preflight_guard/test.bats — this file only checks the +# workflow-level wiring: +# +# 1. The first job under `jobs:` is `guard:`. +# 2. The `guard:` job has `permissions: {}` (least privilege). +# 3. The `guard:` job invokes `actions/preflight_guard` via `uses:`. +# 4. Every other job lists `guard` in its `needs:` so a refused +# invocation skips the entire workflow. +# +# Grep-based; doesn't parse YAML. Tests break loudly if anyone refactors +# the guard wiring out or accidentally adds a new job without the +# `needs: [guard]` dependency. + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + WORKFLOWS_DIR="$REPO_ROOT/.github/workflows" + REUSABLE_WORKFLOWS=( + "build_and_publish_npm.yml" + "build_and_publish_python.yml" + "build_and_publish_container.yml" + "build_shell.yml" + "check_source_change.yml" + ) +} + +@test "wiring: first job in each reusable workflow is guard" { + # Defense against accidentally reordering the guard below a job that + # could side-effect before the refusal fires. + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + first_job=$(awk ' + /^jobs:$/ { in_jobs=1; next } + in_jobs && /^ [a-zA-Z][a-zA-Z0-9_-]*:$/ { + name=$1; sub(":", "", name); print name; exit + } + ' "$WORKFLOWS_DIR/$wf") + [[ "$first_job" == "guard" ]] || { + printf "First job in %s is '%s' not 'guard'\n" "$wf" "$first_job" >&2 + return 1 + } + done +} + +@test "wiring: guard job invokes actions/preflight_guard via uses:" { + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + guard_block=$(awk ' + /^ guard:$/ { in_block=1; print; next } + in_block && /^ [a-z]/ { in_block=0 } + in_block { print } + ' "$WORKFLOWS_DIR/$wf") + echo "$guard_block" | grep -qE 'uses:[[:space:]]*TomHennen/wrangle/actions/preflight_guard@' || { + printf 'guard job in %s does not use TomHennen/wrangle/actions/preflight_guard\n' "$wf" >&2 + printf '%s\n' "$guard_block" >&2 + return 1 + } + done +} + +@test "wiring: guard job has permissions: {}" { + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + guard_block=$(awk ' + /^ guard:$/ { in_block=1; print; next } + in_block && /^ [a-z]/ { in_block=0 } + in_block { print } + ' "$WORKFLOWS_DIR/$wf") + echo "$guard_block" | grep -qE 'permissions: \{\}' || { + printf 'guard job in %s missing permissions: {}\n' "$wf" >&2 + printf '%s\n' "$guard_block" >&2 + return 1 + } + done +} + +@test "wiring: every non-guard job lists guard in its needs" { + # Failing the guard must skip every downstream job. The cheapest + # invariant to grep is "every non-guard job's needs: includes guard". + # Transitive gating via gate/build would also work, but the explicit + # form is easier to audit at a glance and breaks loudly if a later + # job is added without the guard dependency. + for wf in "${REUSABLE_WORKFLOWS[@]}"; do + jobs=$(awk ' + /^jobs:$/ { in_jobs=1; next } + in_jobs && /^[a-zA-Z]/ { in_jobs=0 } + in_jobs && /^ [a-zA-Z][a-zA-Z0-9_-]*:$/ { + name=$1; sub(":", "", name); print name + } + ' "$WORKFLOWS_DIR/$wf") + [[ -n "$jobs" ]] || { + printf 'No jobs found in %s — awk extraction broken?\n' "$wf" >&2 + return 1 + } + for job in $jobs; do + [[ "$job" == "guard" ]] && continue + block=$(awk -v j="$job" ' + $0 == " " j ":" { in_job=1; print; next } + in_job && /^ [a-zA-Z][a-zA-Z0-9_-]*:$/ { in_job=0 } + in_job { print } + ' "$WORKFLOWS_DIR/$wf") + echo "$block" | grep -qE '^[[:space:]]*needs:.*\[.*\bguard\b.*\]' || { + printf "Job '%s' in %s does not list 'guard' in its needs:\n" "$job" "$wf" >&2 + printf '%s\n' "$block" >&2 + return 1 + } + done + done +}