diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index db637c45..3fe2f698 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -1115,25 +1115,20 @@ jobs: # governance jobs on every PR estate-wide. github.sha resolves to the # same merge commit but is always fetchable. ref: ${{ github.sha }} - - name: Checkout standards for the duplicate-key check + - name: Checkout the pinned Standards policy helpers uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: main + # Execute helper code from the same immutable Standards revision as + # this reusable workflow. A moving `main` checkout would let a caller + # pinned to workflow revision X execute implementation revision Y. + ref: ${{ job.workflow_sha }} path: .standards-dupkey sparse-checkout: | scripts/check-workflow-duplicate-keys.sh scripts/update-actions-lock.sh tools/policy/check-workflows-parse.sh sparse-checkout-cone-mode: false - # ⚠ Not fatal if the file is absent. This checkout is pinned to - # standards@main, so during a rename of the script the fetch finds - # nothing — the new name does not exist on main until the renaming pull - # request merges. Without this, the RENAME ITSELF fails the linter, on - # the one pull request that cannot possibly be at fault. See the - # fallback in the next step. - continue-on-error: true - - name: Parse every tracked workflow run: | if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then @@ -1143,7 +1138,7 @@ jobs: SCRIPT=".standards-dupkey/tools/policy/check-workflows-parse.sh" fi if [ ! -f "$SCRIPT" ]; then - echo "::error::workflow parser gate not found in standards@main or locally" + echo "::error::workflow parser gate not found in the pinned Standards revision or locally" exit 1 fi bash "$SCRIPT" @@ -1158,10 +1153,9 @@ jobs: # were dead this way, including a CodeQL workflow with zero # successful runs in its entire lifetime. set -euo pipefail - # Standards must exercise its pull-request scripts; every consumer - # must use the canonical scripts fetched from standards@main. A - # file-existence fallback is insufficient because the old script on - # main still exists while its replacement is being tested. + # Standards exercises its pull-request scripts; every consumer uses + # the canonical scripts fetched from this workflow's immutable + # Standards revision. if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then SCRIPT="scripts/check-workflow-duplicate-keys.sh" echo "Using this repository's own copy (standards self-lint)." @@ -1170,7 +1164,7 @@ jobs: fi if [ ! -f "$SCRIPT" ]; then echo "::error::duplicate-key checker not found — neither fetched from" \ - "standards@main nor present locally." + "the pinned Standards revision nor present locally." exit 1 fi cp "$SCRIPT" "$RUNNER_TEMP/dupkeys.sh" @@ -1185,7 +1179,7 @@ jobs: fi if [ ! -f "$LOCK_SCRIPT" ]; then echo "::error::actions-lock verifier not found — neither fetched from" \ - "standards@main nor present locally." + "the pinned Standards revision nor present locally." exit 1 fi cp "$LOCK_SCRIPT" "$RUNNER_TEMP/update-actions-lock.sh" diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index 29d3949c..af04436e 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -54,40 +54,4 @@ jobs: python3 -m pip install --user --quiet pyyaml - name: Run tests/*.sh and scripts/tests/*.sh - run: | - set -uo pipefail - - mapfile -t TESTS < <( - { - find tests -maxdepth 1 -name '*.sh' -type f - find scripts/tests -maxdepth 1 -name '*.sh' -type f - } | sort - ) - - # Fail closed. If the suite is empty the discovery is broken, and a - # green tick here would assert something untrue. - if [ ${#TESTS[@]} -eq 0 ]; then - echo "::error::No tests found under tests/ or scripts/tests/ — discovery is broken." - exit 1 - fi - echo "Discovered ${#TESTS[@]} test file(s)." - - failed=0 - for t in "${TESTS[@]}"; do - echo "::group::$t" - if bash "$t"; then - echo "PASS $t" - else - rc=$? - echo "::error file=$t::$t failed (exit $rc)" - failed=$((failed+1)) - fi - echo "::endgroup::" - done - - echo - if [ "$failed" -gt 0 ]; then - echo "::error::$failed of ${#TESTS[@]} test file(s) failed." - exit 1 - fi - echo "All ${#TESTS[@]} test file(s) passed." + run: bash scripts/run-shell-test-suite.sh diff --git a/Justfile b/Justfile index 9991fec9..2360c4a2 100644 --- a/Justfile +++ b/Justfile @@ -161,7 +161,7 @@ build: # Run all sub-project test suites test: - # K9 Rust binding tests now live in hyperpolymath/k9-ecosystem (rs/) — see #491 + @bash scripts/run-shell-test-suite.sh # Regression test: test aggregation fails for missing prerequisites and test failures. test-runner-test: diff --git a/rhodium-standard-repositories/.github/workflows/allowlist-preflight.yml b/rhodium-standard-repositories/.github/workflows/allowlist-preflight.yml index 3b0432c4..99494c67 100644 --- a/rhodium-standard-repositories/.github/workflows/allowlist-preflight.yml +++ b/rhodium-standard-repositories/.github/workflows/allowlist-preflight.yml @@ -1,6 +1,7 @@ # SPDX-License-Identifier: MPL-2.0 -# Allowlist Preflight — surfaces an Actions-allowlist gap as an ORDINARY red -# check instead of an opaque, estate-wide `startup_failure` blackout. +# Allowlist Preflight — thin, reproducible caller of the canonical focused +# Standards implementation. It surfaces an Actions-allowlist gap as an +# ordinary red check instead of an opaque `startup_failure` blackout. # # Under `allowed_actions: selected`, a workflow that `uses:` an un-allowlisted # action/reusable dies at STARTUP with no job and no log. This job uses ONLY @@ -27,14 +28,4 @@ concurrency: jobs: allowlist: name: Actions allowlist coverage - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Every workflow `uses:` must be on the canonical allowlist - run: | - # Fetch the estate detector; it fetches the canonical allowlist itself. - curl -fsSL https://raw.githubusercontent.com/hyperpolymath/standards/main/scripts/check-allowed-actions.sh -o "${RUNNER_TEMP}/check-allowed-actions.sh" - bash "${RUNNER_TEMP}/check-allowed-actions.sh" + uses: hyperpolymath/standards/.github/workflows/allowlist-preflight-reusable.yml@f8cc276bb0add12debc9eddce79e2a842bac5d3e diff --git a/scripts/run-shell-test-suite.sh b/scripts/run-shell-test-suite.sh new file mode 100755 index 00000000..f8282562 --- /dev/null +++ b/scripts/run-shell-test-suite.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Canonical fail-closed shell test discovery used by CI and `just test`. +set -uo pipefail + +mapfile -t TESTS < <( + { + find tests -maxdepth 1 -name '*.sh' -type f + find scripts/tests -maxdepth 1 -name '*.sh' -type f + } | sort +) + +if [ "${#TESTS[@]}" -eq 0 ]; then + echo "ERROR: no tests found under tests/ or scripts/tests/ — discovery is broken." >&2 + exit 1 +fi + +echo "Discovered ${#TESTS[@]} test file(s)." +failed=0 + +for test_file in "${TESTS[@]}"; do + echo "::group::$test_file" + + if bash "$test_file"; then + echo "PASS $test_file" + else + status=$? + echo "::error file=$test_file::$test_file failed (exit $status)" + failed=$((failed + 1)) + fi + + echo "::endgroup::" +done + +echo +if [ "$failed" -gt 0 ]; then + echo "ERROR: $failed of ${#TESTS[@]} test file(s) failed." >&2 + exit 1 +fi + +echo "All ${#TESTS[@]} test file(s) passed." diff --git a/scripts/tests/actions-lock-update-test.sh b/scripts/tests/actions-lock-update-test.sh index 0ceeb6ff..c4058d62 100755 --- a/scripts/tests/actions-lock-update-test.sh +++ b/scripts/tests/actions-lock-update-test.sh @@ -25,10 +25,9 @@ cat > "$WORK/bin/fake-gh" <<'EOF' #!/usr/bin/env bash set -euo pipefail if [ "${2:-}" = "--verify-local" ]; then - grep -q "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" \ + grep -q "actions/checkout@v7.0.1" \ .github/workflows/actions.lock - sed -i 's#uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1#uses: actions/checkout@v7.0.1#' \ - .github/workflows/ci.yml + sed -i '1i# verifier-only mutation' .github/workflows/ci.yml case "${FAKE_VERIFY_FINDING:-}" in reusable-exact) printf '%s\n' '{"valid":false,"findings":[{"workflow":".github/workflows/reusable.yml","category":"stale","dependency":"hyperpolymath/standards@abc123"}]}' @@ -60,7 +59,8 @@ if [ "${2:-}" = "--verify-local" ]; then ;; esac fi -sed -i '1i# This workflow is managed by gh actions-lock.' .github/workflows/ci.yml +grep -q '^# This workflow is managed by gh actions-lock.$' .github/workflows/ci.yml || + 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#' \ .github/workflows/ci.yml cat > .github/workflows/actions.lock <<'LOCK' @@ -81,10 +81,10 @@ chmod +x "$WORK/bin/fake-gh" cd "$WORK" GH_BIN="$WORK/bin/fake-gh" bash "$UPDATE" .github/workflows >/dev/null -grep -q 'uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' \ +grep -q 'uses: actions/checkout@v7.0.1' \ .github/workflows/ci.yml [ "$(grep -c '^# This workflow is managed by gh actions-lock.$' .github/workflows/ci.yml)" -eq 1 ] -grep -q "'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':" \ +grep -q "'actions/checkout@v7.0.1':" \ .github/workflows/actions.lock cp .github/workflows/ci.yml "$WORK/ci.before" @@ -93,7 +93,7 @@ GH_BIN="$WORK/bin/fake-gh" bash "$UPDATE" .github/workflows >/dev/null cmp -s "$WORK/ci.before" .github/workflows/ci.yml cmp -s "$WORK/lock.before" .github/workflows/actions.lock -echo "PASS: Actions lock refresh preserves inline SHA source and is idempotent" +echo "PASS: Actions lock refresh keeps authoritative symbolic source and is idempotent" # Verification is mutating in released gh-actions-lock versions too. The safe # verification mode must restore workflow bytes while leaving the lock intact. diff --git a/scripts/tests/governance-reusable-contract-test.sh b/scripts/tests/governance-reusable-contract-test.sh new file mode 100755 index 00000000..89f74662 --- /dev/null +++ b/scripts/tests/governance-reusable-contract-test.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Static regression contract for the reusable governance boundaries. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +GOVERNANCE="$ROOT/.github/workflows/governance-reusable.yml" +FOCUSED="$ROOT/.github/workflows/allowlist-preflight-reusable.yml" +RSR_SEED="$ROOT/rhodium-standard-repositories/.github/workflows/allowlist-preflight.yml" +LOCK_HELPER="$ROOT/scripts/update-actions-lock.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +helper_checkout="$(grep -F -A 18 -- '- name: Checkout the pinned Standards policy helpers' "$GOVERNANCE")" +# GitHub expression is an asserted literal. +# shellcheck disable=SC2016 +printf '%s\n' "$helper_checkout" | grep -Fq 'ref: ${{ job.workflow_sha }}' || + fail "governance helpers are not fetched from job.workflow_sha" +if printf '%s\n' "$helper_checkout" | grep -Eq '^[[:space:]]*ref:[[:space:]]*main[[:space:]]*$'; then + fail "governance helper execution still follows moving main" +fi + +if grep -Fq 'bash scripts/update-actions-lock.sh --verify-local' "$GOVERNANCE"; then + fail "reusable governance still assumes a consumer-local Standards helper" +fi +# RUNNER_TEMP is an asserted workflow literal. +# shellcheck disable=SC2016 +grep -Fq 'bash "$RUNNER_TEMP/update-actions-lock.sh" --verify-local' "$GOVERNANCE" || + fail "reusable governance does not execute the staged pinned lock verifier" + +for workflow in "$GOVERNANCE" "$FOCUSED"; do + grep -Fq 'Live Actions policy (credentialed advisory)' "$workflow" || + fail "$workflow lacks the distinct live-policy advisory job" + grep -Fq "if: \${{ env.GH_TOKEN == '' }}" "$workflow" || + fail "$workflow does not report a missing optional credential" + grep -Fq "if: \${{ env.GH_TOKEN != '' }}" "$workflow" || + fail "$workflow can run the live API without a credential guard" + # rc is an asserted workflow-script literal. + # shellcheck disable=SC2016 + grep -Fq 'if [ "$rc" -eq 3 ]; then' "$workflow" || + fail "$workflow conflates API unavailability with a policy verdict" +done + +grep -Eq 'uses: hyperpolymath/standards/.github/workflows/allowlist-preflight-reusable.yml@[0-9a-f]{40}$' \ + "$RSR_SEED" || fail "RSR allowlist seed is not a thin immutable reusable-workflow caller" +if grep -Eq 'raw\.githubusercontent\.com/.*/main|curl[[:space:]]' "$RSR_SEED"; then + fail "RSR allowlist seed still downloads executable policy from moving main" +fi + +grep -Fq 'when actions.lock is present, gh actions-lock is authoritative' "$LOCK_HELPER" || + fail "lock helper does not state the authoritative-lock contract" +if grep -Fq 'relock-sha-keys.sh' "$LOCK_HELPER"; then + fail "lock helper still rewrites generated lock keys around legacy inline SHAs" +fi + +echo "PASS: reusable governance uses one pinned implementation with focused and suite entry points" diff --git a/scripts/update-actions-lock.sh b/scripts/update-actions-lock.sh index 54bd1419..380517bf 100755 --- a/scripts/update-actions-lock.sh +++ b/scripts/update-actions-lock.sh @@ -1,8 +1,16 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 # -# Refresh actions.lock without letting gh actions-lock rewrite the canonical -# workflow source from inline SHA pins to tag references. +# Refresh actions.lock under one unambiguous policy boundary: +# +# - when actions.lock is present, gh actions-lock is authoritative for direct +# and transitive commit resolution, so its symbolic workflow refs are kept; +# - when actions.lock is absent, the separate workflow gate requires inline +# immutable SHAs. +# +# Verification is treated as observational even though released tool versions +# can rewrite workflow bytes. Updates keep tool-authored workflow changes, but +# any failed update rolls both workflows and the lock back atomically. set -euo pipefail MODE=update @@ -19,8 +27,20 @@ GH_BIN="${GH_BIN:-gh}" SNAPSHOT="$(mktemp -d)" COMPLETE=false +snapshot_workflows() { + destination=$1 + mkdir -p "$destination" + + find "$WF_DIR" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 | + while IFS= read -r -d '' file; do + cp "$file" "$destination/$(basename "$file")" + done +} + restore_workflows() { - find "$SNAPSHOT/workflows" -maxdepth 1 -type f -print0 2>/dev/null | + source=$1 + + find "$source" -maxdepth 1 -type f -print0 2>/dev/null | while IFS= read -r -d '' file; do cp "$file" "$WF_DIR/$(basename "$file")" done @@ -73,10 +93,9 @@ verify_lock_coverage() { fi # The authoritative validity bit and the process exit are not equivalent. # gh-actions-lock v0.1.6 exits 1 for advisory findings such as `sha-as-ref` - # even while reporting `"valid": true`. Treating that advisory exit as an - # invalid lock makes every inline-SHA repository permanently red. Preserve - # the findings in the log, but accept the lock exactly when the tool says it - # is valid. + # even while reporting `"valid": true`. Preserve advisory findings in the + # log, but accept the lock exactly when the authoritative tool says it is + # valid. if printf '%s' "$result" | jq -e '.valid == true' >/dev/null; then if printf '%s' "$result" | jq -e '.findings | length > 0' >/dev/null; then printf '%s\n' "$result" @@ -109,7 +128,7 @@ verify_lock_coverage() { cleanup() { status=$? if [ "$COMPLETE" != true ]; then - restore_workflows + restore_workflows "$SNAPSHOT/original-workflows" if [ -f "$SNAPSHOT/actions.lock" ]; then cp "$SNAPSHOT/actions.lock" "$WF_DIR/actions.lock" else @@ -121,27 +140,17 @@ cleanup() { } trap cleanup EXIT -mkdir -p "$SNAPSHOT/workflows" +snapshot_workflows "$SNAPSHOT/original-workflows" [ ! -f "$WF_DIR/actions.lock" ] || cp "$WF_DIR/actions.lock" "$SNAPSHOT/actions.lock" -# gh actions-lock currently inserts its managed marker and rewrites direct SHA -# refs to their release tags. Those edits conflict with the estate's inline-SHA -# gate. Preserve every authored workflow while allowing the generated lockfile -# to change. -find "$WF_DIR" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 | - while IFS= read -r -d '' file; do - cp "$file" "$SNAPSHOT/workflows/$(basename "$file")" - done - if [ "$MODE" = update ]; then "$GH_BIN" actions-lock - restore_workflows - bash "$(dirname "$0")/relock-sha-keys.sh" "$WF_DIR" 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. +# Despite its name, --verify-local can mutate workflow bytes (including +# migrating local `./` action paths to an invalid `$/` spelling). Preserve the +# post-update state, run verification, then discard verifier-only mutations. +snapshot_workflows "$SNAPSHOT/preverify-workflows" verify_lock_coverage -restore_workflows +restore_workflows "$SNAPSHOT/preverify-workflows" COMPLETE=true