diff --git a/.github/workflows/dependabot-lock-regen.yml b/.github/workflows/dependabot-lock-regen.yml index a0edeed85..d99097aea 100644 --- a/.github/workflows/dependabot-lock-regen.yml +++ b/.github/workflows/dependabot-lock-regen.yml @@ -73,7 +73,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | sudo apt-get install -y -qq gawk >/dev/null - gh extension install github/gh-actions-lock --pin v0.1.6 + gh extension install github/gh-actions-lock --pin v0.2.0 - name: Regenerate Dependabot PR lockfiles env: diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index e4c8f0b61..51a58ea3e 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -1491,8 +1491,13 @@ jobs: # refs made GitHub's native resolver accept them again. if [ -f .github/workflows/actions.lock ]; then # Pinned: an unpinned install let a new verifier release change every - # caller's verdict overnight. Bump deliberately, with the gate tests. - gh extension install github/gh-actions-lock --pin v0.1.6 + # caller's verdict overnight. Bump deliberately, with the gate tests: + # scripts/tests/gh-actions-lock-contract-test.sh runs the real + # binary and requires every pin in .github/workflows/ to agree. + # v0.2.0, not v0.1.6: v0.1.6 refuses to read a lockfile v0.0.3, + # which every current `gh actions-lock` writes, so a lock refreshed + # on an upgraded laptop failed this gate (proof-burrower#112). + gh extension install github/gh-actions-lock --pin v0.2.0 fi # The gate delegates lockfile verification to the authoritative diff --git a/.github/workflows/scorecard-reusable.yml b/.github/workflows/scorecard-reusable.yml index 351842cac..4e4fd33a6 100644 --- a/.github/workflows/scorecard-reusable.yml +++ b/.github/workflows/scorecard-reusable.yml @@ -75,7 +75,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - gh extension install github/gh-actions-lock --pin v0.1.6 + gh extension install github/gh-actions-lock --pin v0.2.0 # Exit 3: the reconciled SARIF was written, but some workflows failed # native lock verification and keep their findings. Exit 2: usage or # SARIF-structure error, nothing written. The code is passed on so @@ -219,7 +219,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - gh extension install github/gh-actions-lock --pin v0.1.6 + gh extension install github/gh-actions-lock --pin v0.2.0 # Exit 3: the reconciled SARIF was written, but some workflows failed # native lock verification and keep their findings. Exit 2: usage or # SARIF-structure error, nothing written. The code is passed on so diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index ea4288cdc..b986b2d2a 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -55,6 +55,17 @@ jobs: - name: Install ijson-jcs run: cargo install --git https://github.com/hyperpolymath/ijson-jcs --rev 0fdeea716fd558e9807362acf6f8202d56d8d88f --locked ijson-jcs + # scripts/tests/gh-actions-lock-contract-test.sh runs the real binary; it + # exits 2 without it. The version must match every `--pin` in this tree + # (the test fails when they disagree); the hash is the release asset's. + - name: Install gh-actions-lock + run: | + set -euo pipefail + curl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 -o "$RUNNER_TEMP/gh-actions-lock" https://github.com/github/gh-actions-lock/releases/download/v0.2.0/linux-amd64 + echo "79ec84c191667fd935a277e6fd3662cf5d3669cccc9e99998731d8f306f373b6 $RUNNER_TEMP/gh-actions-lock" | sha256sum --check + chmod u+x "$RUNNER_TEMP/gh-actions-lock" + echo "GH_ACTIONS_LOCK_BIN=$RUNNER_TEMP/gh-actions-lock" >> "$GITHUB_ENV" + - name: Run tests/*.sh and scripts/tests/*.sh run: bash scripts/run-shell-test-suite.sh diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 441e1550f..293588b68 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -9,7 +9,7 @@ # # Why this exists rather than `gh actions-lock --verify-local` alone: # `gh actions-lock` v0.1.6 cannot see a job-level `uses:` (a reusable-workflow -# call). Measured on this repo, 2026-09-22: +# call); v0.2.0 still cannot (re-measured 2026-10-14). Measured on this repo, 2026-09-22: # * mirror.yml's lock entry was stale by a whole SHA and died at startup with # jobs=0, yet --verify-local reported "All 30 workflows have complete # lockfile coverage" and fix mode did not repair it; diff --git a/scripts/complete-job-refs.sh b/scripts/complete-job-refs.sh index 7cedc3360..8f2ab89a5 100755 --- a/scripts/complete-job-refs.sh +++ b/scripts/complete-job-refs.sh @@ -4,7 +4,8 @@ # # complete-job-refs.sh — add job-level reusable-workflow refs, and the refs a # workflow uses through its local composite actions, to actions.lock. -# `gh actions-lock` v0.1.6 is blind to job-level `uses:` (upstream #129), so the +# `gh actions-lock` v0.1.6 is blind to job-level `uses:` (upstream #129; v0.2.0 +# still is, re-measured 2026-10-14), so the # entries it cannot write are written here. Idempotent; run from a repo root. # # Lock repair order (each step idempotent, run from a repo root): diff --git a/scripts/tests/gh-actions-lock-contract-test.sh b/scripts/tests/gh-actions-lock-contract-test.sh new file mode 100644 index 000000000..e21218a2c --- /dev/null +++ b/scripts/tests/gh-actions-lock-contract-test.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# The contract the estate's lock tooling needs from the REAL gh-actions-lock +# binary, checked against the one version every workflow here pins. The other +# lock tests stub `gh`; this one is what makes a pin bump deliberate. +# +# 1. Every gh-actions-lock pin in .github/workflows/ names the same version, +# and the binary under test is that version. +# 2. It reads lockfile v0.0.2 and v0.0.3. v0.1.6 refused v0.0.3, which every +# current `gh actions-lock` writes, so one lock refreshed on an upgraded +# laptop failed every reader pinned to v0.1.6 (proof-burrower#112). +# 3. A lockfile newer than the binary is refused loudly, never read as valid. +# 4. `--verify-local --json=valid,findings` prints one object with a boolean +# `valid` and a `findings` array, and names the categories that +# scripts/update-actions-lock.sh and reconcile-scorecard-actions-lock.rb +# key on: sha-as-ref, ref-changed, stale (dangling) and stale (a +# reusable-workflow dependency the tool cannot see). +# 5. scripts/update-actions-lock.sh --verify-local, driving the real binary, +# accepts the three v0.2.0 false positives (reusable-workflow `stale`, +# `not-pinned` for a workflow with nothing to pin, `local-action` for a +# ./ action present in the checkout) and rejects a real desync, an +# unlisted workflow with a remote action, and a missing ./ action. +# +# Offline: --verify-local makes no network call. Needs the binary in +# $GH_ACTIONS_LOCK_BIN (self-test.yml downloads the pinned release and checks +# its SHA-256) and mikefarah yq v4 on PATH. Exit 2 when it cannot run. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT + +pass=0; fail=0 +ok() { pass=$((pass+1)); echo " ok $1"; } +bad() { fail=$((fail+1)); echo " FAIL $1"; } + +BIN="${GH_ACTIONS_LOCK_BIN:-}" +if [ -z "$BIN" ] || [ ! -x "$BIN" ]; then + echo "gh-actions-lock-contract-test: set GH_ACTIONS_LOCK_BIN to the pinned gh-actions-lock binary" >&2 + exit 2 +fi +yq --version 2>/dev/null | grep -q 'mikefarah/yq' || { + echo "gh-actions-lock-contract-test: mikefarah yq v4 is required" >&2; exit 2; } + +CHECKOUT=3d3c42e5aac5ba805825da76410c181273ba90b1 +UPLOAD_OLD=043fb46d1a93c77aae656e7c1c64a875d1fc6a0a +STANDARDS=8f2ee50841e216cd8c192eeb68953118190f105c + +echo "1. one pinned version" +mapfile -t pins < <(grep -rhoE 'gh-actions-lock --pin v[0-9]+\.[0-9]+\.[0-9]+|gh-actions-lock/releases/download/v[0-9]+\.[0-9]+\.[0-9]+/' \ + "$ROOT/.github/workflows" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | sort -u) +if [ "${#pins[@]}" -eq 1 ]; then ok "every workflow pins ${pins[0]}" +else bad "workflows pin ${#pins[@]} versions: ${pins[*]:-none}"; fi +PIN="${pins[0]:-none}" +version="$("$BIN" --version 2>&1)" +if [[ "$version" == *" $PIN" ]]; then ok "the binary under test is $PIN" +else bad "the binary under test reports '$version', not $PIN"; fi + +# A repository with one workflow and its lock. picks the workflow: +# clean — one locked tag ref, nothing else +# reusable — clean plus a SHA ref and a reusable-workflow call +# desync — reusable, but the tag was bumped without relocking +fixture() { # fixture + local d=$1 v=$2 variant=$3 upload=v7.0.1 + rm -rf "$d"; mkdir -p "$d/.github/workflows"; git -C "$d" init -q + [ "$variant" = desync ] && upload=v7.0.2 + { + printf '# SPDX-License-Identifier: MPL-2.0\non: push\npermissions: read-all\njobs:\n' + printf ' a:\n runs-on: ubuntu-latest\n steps:\n' + printf ' - uses: actions/upload-artifact@%s\n' "$upload" + if [ "$variant" != clean ]; then + printf ' - uses: actions/checkout@%s\n' "$CHECKOUT" + printf ' r:\n uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@%s\n' "$STANDARDS" + fi + } > "$d/.github/workflows/ci.yml" + { + printf "# This file is machine-generated by \`gh actions-lock\`.\n" + printf "# Do not edit by hand; run \`gh actions-lock\` to update.\n" + printf "# Docs: https://gh.io/actions-lockfile\nversion: '%s'\nworkflows:\n" "$v" + printf " '.github/workflows/ci.yml':\n" + printf " - 'actions/upload-artifact@v7.0.1'\n" + if [ "$variant" != clean ]; then + printf " - 'actions/checkout@%s'\n" "$CHECKOUT" + printf " - 'hyperpolymath/standards@%s'\n" "$STANDARDS" + fi + printf "dependencies:\n" + if [ "$variant" != clean ]; then + printf " 'actions/checkout@%s':\n ref: '%s'\n commit: 'sha1-%s'\n owner_id: 44036562\n repo_id: 197814629\n" \ + "$CHECKOUT" "$CHECKOUT" "$CHECKOUT" + fi + printf " 'actions/upload-artifact@v7.0.1':\n ref: 'v7.0.1'\n commit: 'sha1-%s'\n owner_id: 44036562\n repo_id: 239134591\n" \ + "$UPLOAD_OLD" + if [ "$variant" != clean ]; then + printf " 'hyperpolymath/standards@%s':\n ref: '%s'\n commit: 'sha1-%s'\n owner_id: 6759885\n repo_id: 1\n" \ + "$STANDARDS" "$STANDARDS" "$STANDARDS" + fi + } > "$d/.github/workflows/actions.lock" +} + +# Run --verify-local in ; JSON to $WORK/out.json, stderr to $WORK/err. +verify() { (cd "$1" && "$BIN" --verify-local --json=valid,findings >"$WORK/out.json" 2>"$WORK/err"); } + +echo "2. reads every current lockfile version" +for v in v0.0.2 v0.0.3; do + fixture "$WORK/r" "$v" clean + before="$(sha256sum "$WORK/r/.github/workflows/actions.lock")" + verify "$WORK/r"; rc=$? + if [ "$rc" -eq 0 ] && jq -e '.valid == true and .findings == []' "$WORK/out.json" >/dev/null 2>&1; then + ok "a complete lockfile $v verifies valid" + else bad "a complete lockfile $v did not verify (exit $rc): $(head -c 300 "$WORK/err")"; fi + if [ "$before" = "$(sha256sum "$WORK/r/.github/workflows/actions.lock")" ]; then + ok "--verify-local left the $v lockfile byte-identical" + else bad "--verify-local rewrote the $v lockfile"; fi +done + +echo "3. a lockfile newer than the binary is refused" +fixture "$WORK/r" v9.9.9 clean +verify "$WORK/r"; rc=$? +if [ "$rc" -ne 0 ] && grep -q 'newer than this binary supports' "$WORK/err"; then + ok "lockfile v9.9.9 is refused (exit $rc) and says why" +else bad "lockfile v9.9.9 was not refused loudly (exit $rc): $(head -c 300 "$WORK/err")"; fi + +echo "4. JSON shape and finding categories" +fixture "$WORK/r" v0.0.3 desync +verify "$WORK/r" +if jq -e -s 'length == 1 and (.[0] | type == "object" and (.valid | type == "boolean") + and (.findings | type == "array") + and all(.findings[]; (.category | type == "string") and (.workflow | type == "string") + and (.dependency | type == "string")))' "$WORK/out.json" >/dev/null 2>&1; then + ok "one object: boolean valid, findings with category/workflow/dependency" +else bad "unexpected JSON shape: $(head -c 300 "$WORK/out.json")"; fi +got="$(jq -r '.findings[] | "\(.category) \(.dependency)"' "$WORK/out.json" 2>/dev/null | sort)" +want="$(printf '%s\n' \ + "ref-changed actions/upload-artifact@v7.0.2" \ + "sha-as-ref actions/checkout@$CHECKOUT" \ + "stale actions/upload-artifact@v7.0.1" \ + "stale hyperpolymath/standards@$STANDARDS" | sort)" +if [ "$got" = "$want" ]; then ok "sha-as-ref, ref-changed, stale and reusable stale, by category" +else bad "categories differ"; diff <(echo "$want") <(echo "$got") | sed 's/^/ /'; fi +if jq -e '.valid == false' "$WORK/out.json" >/dev/null 2>&1; then ok "a desync is valid:false" +else bad "a desync was not valid:false"; fi + +echo "5. update-actions-lock.sh --verify-local on the real binary" +mkdir -p "$WORK/bin" +printf '#!/usr/bin/env bash\n[ "$1" = actions-lock ] && shift\nexec "%s" "$@"\n' "$BIN" > "$WORK/bin/gh" +chmod +x "$WORK/bin/gh" +fixture "$WORK/r" v0.0.3 reusable +if (cd "$WORK/r" && GH_BIN="$WORK/bin/gh" bash "$ROOT/scripts/update-actions-lock.sh" --verify-local) >"$WORK/log" 2>&1 && + grep -q "Accepted reusable-workflow lock coverage: .github/workflows/ci.yml -> hyperpolymath/standards@$STANDARDS" "$WORK/log"; then + ok "reusable-workflow coverage and an advisory sha-as-ref are accepted" +else bad "the reusable fixture was rejected"; sed 's/^/ /' "$WORK/log" | tail -8; fi +fixture "$WORK/r" v0.0.3 desync +if (cd "$WORK/r" && GH_BIN="$WORK/bin/gh" bash "$ROOT/scripts/update-actions-lock.sh" --verify-local) >"$WORK/log" 2>&1; then + bad "a bumped-but-not-relocked tag was accepted"; sed 's/^/ /' "$WORK/log" | tail -8 +else ok "a bumped-but-not-relocked tag is rejected"; fi + +# A v0.1.6-written lock has no entry for a workflow with nothing to pin. v0.2.0 +# reports it `not-pinned` with no dependency; GitHub runs it regardless. +fixture "$WORK/r" v0.0.2 clean +printf 'on: push\npermissions: {}\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n' \ + > "$WORK/r/.github/workflows/runonly.yml" +printf 'on: workflow_call\npermissions: {}\njobs:\n a:\n uses: ./.github/workflows/runonly.yml\n' \ + > "$WORK/r/.github/workflows/localcall.yml" +verify "$WORK/r" +if jq -e '[.findings[] | select(.category != "not-pinned" or .dependency != null)] == []' \ + "$WORK/out.json" >/dev/null 2>&1; then + ok "an unlisted run-only or ./-only workflow yields at most not-pinned with no dependency" +else bad "unexpected findings for unlisted workflows: $(head -c 300 "$WORK/out.json")"; fi +if (cd "$WORK/r" && GH_BIN="$WORK/bin/gh" bash "$ROOT/scripts/update-actions-lock.sh" --verify-local) >"$WORK/log" 2>&1; then + ok "update-actions-lock.sh accepts workflows with nothing to pin" +else bad "workflows with nothing to pin were rejected"; sed 's/^/ /' "$WORK/log" | tail -8; fi +# A same-repository composite action. v0.2.0 calls `./` unsupported; the estate +# runs it, and fails the `$/` rewrite instead (check-lock-sync.sh). +printf 'on: push\npermissions: {}\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/upload-artifact@v7.0.1\n - uses: ./.github/actions/thing\n' \ + > "$WORK/r/.github/workflows/localaction.yml" +# v0.2.0 flags `./` only in a workflow the lock lists, so list this one. +awk -v q="'" '/^dependencies:/ { + print " " q ".github/workflows/localaction.yml" q ":" + print " - " q "actions/upload-artifact@v7.0.1" q + } { print }' "$WORK/r/.github/workflows/actions.lock" > "$WORK/lock.tmp" && + mv "$WORK/lock.tmp" "$WORK/r/.github/workflows/actions.lock" +verify "$WORK/r" +if jq -e '[.findings[] | select(.category == "local-action" and .dependency == null)] | length == 1' \ + "$WORK/out.json" >/dev/null 2>&1; then + ok "a listed workflow with a ./ action yields local-action with no dependency" +else bad "a listed workflow with a ./ action did not yield local-action"; fi +if (cd "$WORK/r" && GH_BIN="$WORK/bin/gh" bash "$ROOT/scripts/update-actions-lock.sh" --verify-local) >"$WORK/log" 2>&1; then + bad "a ./ action that does not exist was accepted"; sed 's/^/ /' "$WORK/log" | tail -8 +else ok "a ./ action that does not exist is rejected"; fi +mkdir -p "$WORK/r/.github/actions/thing" +printf 'name: thing\nruns:\n using: composite\n steps:\n - run: echo ok\n shell: bash\n' \ + > "$WORK/r/.github/actions/thing/action.yml" +if (cd "$WORK/r" && GH_BIN="$WORK/bin/gh" bash "$ROOT/scripts/update-actions-lock.sh" --verify-local) >"$WORK/log" 2>&1; then + ok "a ./ action present in the checkout is accepted" +else bad "a ./ action present in the checkout was rejected"; sed 's/^/ /' "$WORK/log" | tail -8; fi +if grep -q 'uses: ./.github/actions/thing' "$WORK/r/.github/workflows/localaction.yml"; then + ok "--verify-local left the ./ reference unrewritten" +else bad "--verify-local rewrote the ./ reference"; fi +printf 'on: push\npermissions: {}\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/upload-artifact@v7.0.1\n' \ + > "$WORK/r/.github/workflows/remote.yml" +if (cd "$WORK/r" && GH_BIN="$WORK/bin/gh" bash "$ROOT/scripts/update-actions-lock.sh" --verify-local) >"$WORK/log" 2>&1; then + bad "an unlisted workflow with a remote action was accepted"; sed 's/^/ /' "$WORK/log" | tail -8 +else ok "an unlisted workflow with a remote action is rejected"; fi + +echo +echo "Total: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/scripts/tests/update-actions-lock-test.sh b/scripts/tests/update-actions-lock-test.sh index ce72afaff..b917d16e2 100755 --- a/scripts/tests/update-actions-lock-test.sh +++ b/scripts/tests/update-actions-lock-test.sh @@ -41,6 +41,22 @@ if [ "${2:-}" = "--verify-local" ]; then printf '%s\n' '{"valid":false,"findings":[{"workflow":".github/workflows/reusable.yml","category":"missing","dependency":"hyperpolymath/standards@abc123"}]}' exit 1 ;; + not-pinned-nothing) + printf '%s\n' '{"valid":false,"findings":[{"workflow":".github/workflows/runonly.yml","category":"not-pinned","dependency":null},{"workflow":".github/workflows/localcall.yml","category":"not-pinned","dependency":null}]}' + exit 1 + ;; + not-pinned-remote) + printf '%s\n' '{"valid":false,"findings":[{"workflow":".github/workflows/ci.yml","category":"not-pinned","dependency":null}]}' + exit 1 + ;; + not-pinned-with-dependency) + printf '%s\n' '{"valid":false,"findings":[{"workflow":".github/workflows/runonly.yml","category":"not-pinned","dependency":"actions/checkout@v7.0.1"}]}' + exit 1 + ;; + local-action) + printf '%s\n' '{"valid":false,"findings":[{"workflow":".github/workflows/localaction.yml","category":"local-action","dependency":null}]}' + exit 1 + ;; malformed-success) printf '%s\n' 'not valid JSON' exit 0 @@ -147,6 +163,71 @@ if FAKE_VERIFY_FINDING=reusable-non-stale GH_BIN="$WORK/bin/fake-gh" \ fi echo "PASS: non-stale reusable-workflow finding remains blocking" +# gh-actions-lock v0.2.0 reports `not-pinned` ("workflow has no lockfile entry", +# dependency null) for a workflow a v0.1.6-written lock omits. That is a false +# positive only when the workflow has nothing to pin: `run:` steps, or ./ calls. +cat > .github/workflows/runonly.yml <<'EOF' +# SPDX-License-Identifier: MPL-2.0 +on: push +permissions: {} +jobs: + a: + runs-on: ubuntu-latest + steps: + - run: echo ok +EOF +cat > .github/workflows/localcall.yml <<'EOF' +# SPDX-License-Identifier: MPL-2.0 +on: push +permissions: {} +jobs: + a: + uses: ./.github/workflows/runonly.yml +EOF +FAKE_VERIFY_FINDING=not-pinned-nothing GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null +echo "PASS: a run-only or ./-only workflow with no lock entry is accepted" + +if FAKE_VERIFY_FINDING=not-pinned-remote GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null 2>&1; then + echo "FAIL: a workflow with a remote action and no lock entry was accepted" >&2 + exit 1 +fi +echo "PASS: a workflow with a remote action and no lock entry remains blocking" + +if FAKE_VERIFY_FINDING=not-pinned-with-dependency GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null 2>&1; then + echo "FAIL: a not-pinned dependency was accepted" >&2 + exit 1 +fi +echo "PASS: a not-pinned dependency remains blocking" +rm -f .github/workflows/runonly.yml .github/workflows/localcall.yml + +# v0.2.0 also reports every `uses: ./…` as `local-action`. The estate runs +# `./` (check-lock-sync.sh fails the `$/` rewrite instead), so the finding is +# accepted only when each `./` path exists in the checkout. +cat > .github/workflows/localaction.yml <<'EOF' +# SPDX-License-Identifier: MPL-2.0 +on: push +permissions: {} +jobs: + a: + runs-on: ubuntu-latest + steps: + - uses: ./.github/actions/thing +EOF +if FAKE_VERIFY_FINDING=local-action GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null 2>&1; then + echo "FAIL: a ./ action that does not exist was accepted" >&2 + exit 1 +fi +echo "PASS: a ./ action that does not exist remains blocking" +mkdir -p .github/actions/thing +FAKE_VERIFY_FINDING=local-action GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null +echo "PASS: a ./ action that exists in the checkout is accepted" +rm -rf .github/workflows/localaction.yml .github/actions + # The same caller in KYAML (YAML-POLICY Y-3). A line matcher never saw the # quoted value, so the exact dependency was rejected; the parser reads it. cp .github/workflows/reusable.yml "$WORK/reusable.block" diff --git a/scripts/update-actions-lock.sh b/scripts/update-actions-lock.sh index 217ba055e..ddcc4552c 100755 --- a/scripts/update-actions-lock.sh +++ b/scripts/update-actions-lock.sh @@ -68,6 +68,40 @@ workflow_references_reusable_dependency() { ' } +# Succeeds when has nothing for the lock to pin: every `uses:` is a +# same-repository path (./… or $/…), or there is none. gh-actions-lock v0.2.0 +# reports such a workflow as `not-pinned` ("workflow has no lockfile entry") +# when a v0.1.6-written lock omits it. v0.1.6 never asked for that entry, and +# GitHub runs these workflows without one: measured 2026-10-14 on standards, +# where lock-sync-gate.yml (only `run:` steps) and scorecard.yml, +# secret-scanner.yml and self-ci.yml (only ./ reusable calls) all succeed with +# no entry. A parse failure returns 1 -- the finding stays a failure. +workflow_has_nothing_to_pin() { + workflow=$1 + [[ -f "$workflow" ]] || return 1 + refs=$(yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$workflow" 2>/dev/null) || + return 1 + ! printf '%s\n' "$refs" | grep -v -e '^$' -e '^\./' -e '^\$/' | grep -q . +} + +# Succeeds when uses at least one same-repository `./…` action or +# reusable workflow and every one of them exists in this checkout (run from +# the repository root). gh-actions-lock v0.2.0 reports any `./` action as +# `local-action` ("not supported", rewrite to `$/…`). The estate measured the +# opposite: check-lock-sync.sh fails the `$/` spelling as an invalid rewrite, +# and v0.1.6 never flagged `./`. A missing path or a parse failure returns 1, +# so the finding stays a failure. +workflow_local_uses_resolve() { + workflow=$1 + [[ -f "$workflow" ]] || return 1 + refs=$(yq -r '.. | select(tag == "!!map") | select(has("uses")) | .uses | select(tag == "!!str")' "$workflow" 2>/dev/null) || + return 1 + local_refs=$(printf '%s\n' "$refs" | grep '^\./') || return 1 + while IFS= read -r ref; do + [[ -e "${ref%%@*}" ]] || return 1 + done <<< "$local_refs" +} + is_advisory_category() { # Advisory findings never affect the tool's own `valid` bit: a tree whose # only findings are `sha-as-ref` reports `"valid": true` (measured @@ -77,7 +111,8 @@ is_advisory_category() { # # Discriminate by CATEGORY, never by severity. gh-actions-lock v0.1.6 labels # every finding `"severity": "warning"`, `stale` included, so a severity test - # would swallow real desyncs and turn this gate vacuous. + # would swallow real desyncs and turn this gate vacuous. v0.2.0 relabels + # `stale` as `"error"`: the severity is not stable across releases either. case "$1" in sha-as-ref) return 0 ;; *) return 1 ;; @@ -85,11 +120,16 @@ is_advisory_category() { } verify_lock_coverage() { - # gh-actions-lock v0.1.6 does not recognise reusable-workflow `uses:` - # paths. GitHub's startup enforcement nevertheless requires callers to - # carry the reusable repository and its transitive actions in actions.lock. - # Accept only the tool's `stale` false positive when the named workflow - # contains the exact owner/repo/.github/workflows/file@ref dependency. + # gh-actions-lock (v0.1.6, and still v0.2.0) does not recognise + # reusable-workflow `uses:` paths. GitHub's startup enforcement nevertheless + # requires callers to carry the reusable repository and its transitive + # actions in actions.lock. Accept exactly three tool false positives: + # - `stale`, when the named workflow contains the exact + # owner/repo/.github/workflows/file@ref dependency; + # - `not-pinned` with no dependency ("workflow has no lockfile entry", + # v0.2.0), when the workflow has nothing to pin; + # - `local-action` with no dependency (v0.2.0), when every `./` action + # the workflow uses exists in this checkout. # Every other finding, including a wrong ref, remains blocking. command -v jq >/dev/null 2>&1 || { "$GH_BIN" actions-lock --verify-local @@ -129,6 +169,14 @@ verify_lock_coverage() { workflow_references_reusable_dependency "$workflow" "$dependency"; then echo "Accepted reusable-workflow lock coverage: $workflow -> $dependency" accepted=$((accepted + 1)) + elif [[ "$category" = not-pinned && -z "$dependency" ]] && + workflow_has_nothing_to_pin "$workflow"; then + echo "Accepted workflow with nothing to pin: $workflow" + accepted=$((accepted + 1)) + elif [[ "$category" = local-action && -z "$dependency" ]] && + workflow_local_uses_resolve "$workflow"; then + echo "Accepted same-repository ./ action(s): $workflow" + accepted=$((accepted + 1)) else remaining=$((remaining + 1)) fi