From df3aa52ec173655948a0d33a5ab1b53416f76b5d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:28:26 +0100 Subject: [PATCH 1/2] fix(governance): verify reusable workflow locks --- scripts/tests/actions-lock-update-test.sh | 36 ++++++++++++- scripts/update-actions-lock.sh | 66 ++++++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/scripts/tests/actions-lock-update-test.sh b/scripts/tests/actions-lock-update-test.sh index 158b4c38..b8b84033 100755 --- a/scripts/tests/actions-lock-update-test.sh +++ b/scripts/tests/actions-lock-update-test.sh @@ -29,7 +29,20 @@ if [ "${2:-}" = "--verify-local" ]; then .github/workflows/actions.lock sed -i 's#uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1#uses: actions/checkout@v7.0.1#' \ .github/workflows/ci.yml - exit + case "${FAKE_VERIFY_FINDING:-}" in + reusable-exact) + printf '%s\n' '{"valid":false,"findings":[{"workflow":".github/workflows/reusable.yml","category":"stale","dependency":"hyperpolymath/standards@abc123"}]}' + exit 1 + ;; + reusable-wrong-ref) + printf '%s\n' '{"valid":false,"findings":[{"workflow":".github/workflows/reusable.yml","category":"stale","dependency":"hyperpolymath/standards@wrong456"}]}' + exit 1 + ;; + *) + printf '%s\n' '{"valid":true,"findings":[]}' + exit + ;; + esac fi sed -i '1i# This workflow is managed by gh actions-lock.' .github/workflows/ci.yml sed -i 's#actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1#actions/checkout@v7.0.1#' \ @@ -75,6 +88,27 @@ cmp -s "$WORK/ci.before-verify" .github/workflows/ci.yml cmp -s "$WORK/lock.before-verify" .github/workflows/actions.lock echo "PASS: Actions lock verification restores tool-authored workflow edits" +cat > .github/workflows/reusable.yml <<'EOF' +# SPDX-License-Identifier: MPL-2.0 +name: Reusable caller +on: push +permissions: {} +jobs: + governance: + uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@abc123 +EOF + +FAKE_VERIFY_FINDING=reusable-exact GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null +echo "PASS: exact reusable-workflow dependency is accepted" + +if FAKE_VERIFY_FINDING=reusable-wrong-ref GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null 2>&1; then + echo "FAIL: wrong reusable-workflow ref was accepted" >&2 + exit 1 +fi +echo "PASS: wrong reusable-workflow ref remains blocking" + # A failed refresh must restore both authored workflows and the previous # lockfile; this is the production failure mode that left the original checkout # half-rewritten when DNS resolution failed. diff --git a/scripts/update-actions-lock.sh b/scripts/update-actions-lock.sh index cc5c3f87..1fd1823b 100755 --- a/scripts/update-actions-lock.sh +++ b/scripts/update-actions-lock.sh @@ -26,6 +26,70 @@ restore_workflows() { done } +workflow_references_reusable_dependency() { + workflow=$1 + dependency=$2 + repo=${dependency%@*} + ref=${dependency#*@} + + [ -f "$workflow" ] || return 1 + awk -v prefix="$repo/.github/workflows/" -v suffix="@$ref" ' + /^[[:space:]]*uses:[[:space:]]*/ { + value = $0 + sub(/^[[:space:]]*uses:[[:space:]]*/, "", value) + sub(/[[:space:]]*#.*/, "", value) + sub(/[[:space:]]*$/, "", value) + if (index(value, prefix) == 1 && + length(value) >= length(suffix) && + substr(value, length(value) - length(suffix) + 1) == suffix) { + found = 1 + } + } + END { exit(found ? 0 : 1) } + ' "$workflow" +} + +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. + # Every other finding, including a wrong ref, remains blocking. + command -v jq >/dev/null 2>&1 || { + "$GH_BIN" actions-lock --verify-local + return + } + + set +e + result=$("$GH_BIN" actions-lock --verify-local --json=valid,findings) + status=$? + set -e + + if ! printf '%s' "$result" | jq -e '.valid != null and (.findings | type == "array")' >/dev/null 2>&1; then + printf '%s\n' "$result" + return "$status" + fi + if [ "$(printf '%s' "$result" | jq -r '.valid')" = true ]; then + return 0 + fi + + remaining=0 + while IFS=$'\t' read -r category workflow dependency; do + if [ "$category" = stale ] && + workflow_references_reusable_dependency "$workflow" "$dependency"; then + echo "Accepted reusable-workflow lock coverage: $workflow -> $dependency" + else + remaining=$((remaining + 1)) + fi + done < <(printf '%s' "$result" | jq -r '.findings[] | [.category, .workflow, .dependency] | @tsv') + + if [ "$remaining" -ne 0 ]; then + printf '%s\n' "$result" + return 1 + fi +} + cleanup() { status=$? if [ "$COMPLETE" != true ]; then @@ -62,6 +126,6 @@ fi # Despite its name, --verify-local can migrate local `./` action paths to an # invalid `$/` spelling. Treat verification as mutating and restore authored # workflow bytes afterward too. -"$GH_BIN" actions-lock --verify-local +verify_lock_coverage restore_workflows COMPLETE=true From 4ed2cef42085cedb159c6a413e3a4320ba5be36a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:36:34 +0100 Subject: [PATCH 2/2] fix(governance): fail closed on malformed lock output --- scripts/tests/actions-lock-update-test.sh | 22 ++++++++++++++++++++++ scripts/update-actions-lock.sh | 11 ++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/scripts/tests/actions-lock-update-test.sh b/scripts/tests/actions-lock-update-test.sh index b8b84033..8ca997b5 100755 --- a/scripts/tests/actions-lock-update-test.sh +++ b/scripts/tests/actions-lock-update-test.sh @@ -38,6 +38,14 @@ if [ "${2:-}" = "--verify-local" ]; then printf '%s\n' '{"valid":false,"findings":[{"workflow":".github/workflows/reusable.yml","category":"stale","dependency":"hyperpolymath/standards@wrong456"}]}' exit 1 ;; + reusable-non-stale) + printf '%s\n' '{"valid":false,"findings":[{"workflow":".github/workflows/reusable.yml","category":"missing","dependency":"hyperpolymath/standards@abc123"}]}' + exit 1 + ;; + malformed-success) + printf '%s\n' 'not valid JSON' + exit 0 + ;; *) printf '%s\n' '{"valid":true,"findings":[]}' exit @@ -109,6 +117,20 @@ if FAKE_VERIFY_FINDING=reusable-wrong-ref GH_BIN="$WORK/bin/fake-gh" \ fi echo "PASS: wrong reusable-workflow ref remains blocking" +if FAKE_VERIFY_FINDING=reusable-non-stale GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null 2>&1; then + echo "FAIL: non-stale reusable-workflow finding was accepted" >&2 + exit 1 +fi +echo "PASS: non-stale reusable-workflow finding remains blocking" + +if FAKE_VERIFY_FINDING=malformed-success GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null 2>&1; then + echo "FAIL: malformed successful verifier output was accepted" >&2 + exit 1 +fi +echo "PASS: malformed verifier output fails closed" + # A failed refresh must restore both authored workflows and the previous # lockfile; this is the production failure mode that left the original checkout # half-rewritten when DNS resolution failed. diff --git a/scripts/update-actions-lock.sh b/scripts/update-actions-lock.sh index 1fd1823b..ed9bd1ea 100755 --- a/scripts/update-actions-lock.sh +++ b/scripts/update-actions-lock.sh @@ -32,7 +32,7 @@ workflow_references_reusable_dependency() { repo=${dependency%@*} ref=${dependency#*@} - [ -f "$workflow" ] || return 1 + [[ -f "$workflow" ]] || return 1 awk -v prefix="$repo/.github/workflows/" -v suffix="@$ref" ' /^[[:space:]]*uses:[[:space:]]*/ { value = $0 @@ -68,15 +68,16 @@ verify_lock_coverage() { if ! printf '%s' "$result" | jq -e '.valid != null and (.findings | type == "array")' >/dev/null 2>&1; then printf '%s\n' "$result" - return "$status" + [[ "$status" -ne 0 ]] && return "$status" + return 1 fi - if [ "$(printf '%s' "$result" | jq -r '.valid')" = true ]; then + if printf '%s' "$result" | jq -e '.valid == true and (.findings | length == 0)' >/dev/null; then return 0 fi remaining=0 while IFS=$'\t' read -r category workflow dependency; do - if [ "$category" = stale ] && + if [[ "$category" = stale ]] && workflow_references_reusable_dependency "$workflow" "$dependency"; then echo "Accepted reusable-workflow lock coverage: $workflow -> $dependency" else @@ -84,7 +85,7 @@ verify_lock_coverage() { fi done < <(printf '%s' "$result" | jq -r '.findings[] | [.category, .workflow, .dependency] | @tsv') - if [ "$remaining" -ne 0 ]; then + if [[ "$remaining" -ne 0 ]]; then printf '%s\n' "$result" return 1 fi