|
| 1 | +#!/usr/bin/env bash |
| 2 | +# SPDX-License-Identifier: MPL-2.0 |
| 3 | +# |
| 4 | +# Mutants for scripts/check-lock-gate-pin-freshness.sh. |
| 5 | +# |
| 6 | +# Every control builds a THROWAWAY git repository with real commits, so the |
| 7 | +# freshness comparison is exercised against genuine history with no network and |
| 8 | +# no dependence on this repo's own state. A control that asserted against the |
| 9 | +# real tree would go green or red for reasons unrelated to the mutation. |
| 10 | + |
| 11 | +set -uo pipefail |
| 12 | + |
| 13 | +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" |
| 14 | +GUARD="$ROOT/scripts/check-lock-gate-pin-freshness.sh" |
| 15 | +[ -f "$GUARD" ] || { echo "FAIL: guard not found at $GUARD" >&2; exit 1; } |
| 16 | + |
| 17 | +pass=0 |
| 18 | +fail=0 |
| 19 | + |
| 20 | +check() { # name expected_rc actual_rc [haystack needle] |
| 21 | + local name="$1" want="$2" got="$3" |
| 22 | + if [ "$got" != "$want" ]; then |
| 23 | + echo "FAIL: $name — expected rc=$want, got rc=$got" >&2 |
| 24 | + fail=$((fail + 1)) |
| 25 | + return |
| 26 | + fi |
| 27 | + if [ "$#" -ge 5 ] && ! printf '%s' "$4" | grep -Fq -- "$5"; then |
| 28 | + echo "FAIL: $name — rc was right but the message never mentioned '$5'" >&2 |
| 29 | + echo "----- output -----" >&2; printf '%s\n' "$4" >&2; echo "------------------" >&2 |
| 30 | + fail=$((fail + 1)) |
| 31 | + return |
| 32 | + fi |
| 33 | + echo "ok: $name" |
| 34 | + pass=$((pass + 1)) |
| 35 | +} |
| 36 | + |
| 37 | +refute() { # name haystack needle |
| 38 | + local name="$1" |
| 39 | + if printf '%s' "$2" | grep -Fq -- "$3"; then |
| 40 | + echo "FAIL: $name — output mentioned '$3' and must not" >&2 |
| 41 | + fail=$((fail + 1)) |
| 42 | + return |
| 43 | + fi |
| 44 | + echo "ok: $name" |
| 45 | + pass=$((pass + 1)) |
| 46 | +} |
| 47 | + |
| 48 | +# Build a repo whose helper changed in the SECOND commit, plus an unrelated file |
| 49 | +# that also changed, so path-scoping can be told apart from "any divergence". |
| 50 | +WORK="$(mktemp -d)" |
| 51 | +trap 'rm -rf "$WORK"' EXIT |
| 52 | +cd "$WORK" || exit 1 |
| 53 | +git init --quiet -b main . |
| 54 | +git config user.email t@example.invalid |
| 55 | +git config user.name t |
| 56 | +mkdir -p scripts .machine_readable |
| 57 | +echo v1 > scripts/update-actions-lock.sh |
| 58 | +echo v1 > scripts/check-actions-lock-gate.sh |
| 59 | +echo v1 > .machine_readable/lock-allow.txt |
| 60 | +echo v1 > UNRELATED.md |
| 61 | +git add -A && git commit --quiet -m c1 |
| 62 | +OLD="$(git rev-parse HEAD)" |
| 63 | +echo v2 > scripts/update-actions-lock.sh |
| 64 | +echo v2 > UNRELATED.md |
| 65 | +git add -A && git commit --quiet -m c2 |
| 66 | +NEW="$(git rev-parse HEAD)" |
| 67 | +# A third commit touching ONLY the unrelated file, to prove path-scoping. |
| 68 | +echo v3 > UNRELATED.md |
| 69 | +git add -A && git commit --quiet -m c3 |
| 70 | +NEWEST="$(git rev-parse HEAD)" |
| 71 | + |
| 72 | +# $1 = the `ref:` value; $2 (optional) = step name override. |
| 73 | +write_fixture() { |
| 74 | + local ref="$1" name="${2:-Checkout standards for the lock gate}" |
| 75 | + mkdir -p "$WORK/.github/workflows" |
| 76 | + cat > "$WORK/.github/workflows/governance-reusable.yml" <<YAML |
| 77 | +jobs: |
| 78 | + gate: |
| 79 | + steps: |
| 80 | + - name: A preceding step that also has a ref |
| 81 | + uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| 82 | + with: |
| 83 | + ref: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
| 84 | + - name: $name |
| 85 | + uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| 86 | + with: |
| 87 | + repository: hyperpolymath/standards |
| 88 | + ref: $ref |
| 89 | + path: .standards-lock |
| 90 | + sparse-checkout: | |
| 91 | + scripts/check-actions-lock-gate.sh |
| 92 | + scripts/update-actions-lock.sh |
| 93 | + .machine_readable/lock-allow.txt |
| 94 | + sparse-checkout-cone-mode: false |
| 95 | + - name: A following step with a decoy ref |
| 96 | + uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| 97 | + with: |
| 98 | + ref: cccccccccccccccccccccccccccccccccccccccc |
| 99 | +YAML |
| 100 | + export LOCK_GATE_WORKFLOW="$WORK/.github/workflows/governance-reusable.yml" |
| 101 | +} |
| 102 | + |
| 103 | +run() { bash "$GUARD" "$@" 2>&1; } |
| 104 | + |
| 105 | +# 1. The real defect: the pin predates a helper change that is already on main. |
| 106 | +write_fixture "$OLD" |
| 107 | +out="$(run "$NEW")"; rc=$? |
| 108 | +check "stale pin is refused" 1 "$rc" "$out" "scripts/update-actions-lock.sh" |
| 109 | + |
| 110 | +# 2. And it must not name files outside the staged scope. UNRELATED.md differs |
| 111 | +# between the two commits too; if it appears, the guard is diffing the whole |
| 112 | +# tree and every rebase would redden it. |
| 113 | +refute "stale report is path-scoped (never names UNRELATED.md)" "$out" "UNRELATED.md" |
| 114 | + |
| 115 | +# 3. The cured state passes. |
| 116 | +write_fixture "$NEW" |
| 117 | +out="$(run "$NEW")"; rc=$? |
| 118 | +check "fresh pin is accepted" 0 "$rc" "$out" "PASS" |
| 119 | + |
| 120 | +# 4. Path-scoping: an unrelated commit on top must NOT fail a fresh pin. |
| 121 | +# Without this, every rebase would redden the gate and the guard would be |
| 122 | +# turned off rather than obeyed. |
| 123 | +write_fixture "$NEW" |
| 124 | +out="$(run "$NEWEST")"; rc=$? |
| 125 | +check "unrelated divergence does not fail it" 0 "$rc" "$out" "PASS" |
| 126 | + |
| 127 | +# 5. A moving ref is refused — the whole point of pinning. |
| 128 | +write_fixture "main" |
| 129 | +out="$(run "$NEW")"; rc=$? |
| 130 | +check "ref: main is refused" 1 "$rc" "$out" "not an immutable 40-hex commit" |
| 131 | + |
| 132 | +# 6. A short/abbreviated SHA is refused. |
| 133 | +write_fixture "${NEW:0:12}" |
| 134 | +out="$(run "$NEW")"; rc=$? |
| 135 | +check "abbreviated sha is refused" 1 "$rc" "$out" "not an immutable 40-hex commit" |
| 136 | + |
| 137 | +# 7. Renaming the step must FAIL, not vacuously pass. This is the exact way the |
| 138 | +# existing contract test lost its subject: it greps a step name, and a step |
| 139 | +# that no longer matches simply stops being checked. |
| 140 | +write_fixture "$NEW" "Checkout standards for something else" |
| 141 | +out="$(run "$NEW")"; rc=$? |
| 142 | +check "renamed step fails loudly" 1 "$rc" "$out" "lost its subject" |
| 143 | + |
| 144 | +# 8. A pin that cannot be resolved must FAIL, never skip. |
| 145 | +write_fixture "dddddddddddddddddddddddddddddddddddddddd" |
| 146 | +out="$(run "$NEW")"; rc=$? |
| 147 | +check "unresolvable pin fails, not skips" 1 "$rc" "$out" "does not pass on an unverifiable pin" |
| 148 | + |
| 149 | +# 9. No ref: at all. |
| 150 | +mkdir -p "$WORK/.github/workflows" |
| 151 | +cat > "$WORK/.github/workflows/governance-reusable.yml" <<'YAML' |
| 152 | +jobs: |
| 153 | + gate: |
| 154 | + steps: |
| 155 | + - name: Checkout standards for the lock gate |
| 156 | + uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| 157 | + with: |
| 158 | + repository: hyperpolymath/standards |
| 159 | + sparse-checkout: | |
| 160 | + scripts/update-actions-lock.sh |
| 161 | +YAML |
| 162 | +export LOCK_GATE_WORKFLOW="$WORK/.github/workflows/governance-reusable.yml" |
| 163 | +out="$(run "$NEW")"; rc=$? |
| 164 | +check "missing ref: is refused" 1 "$rc" "$out" "would follow the default branch" |
| 165 | + |
| 166 | +# 10. Staging nothing must not be a free pass. |
| 167 | +cat > "$WORK/.github/workflows/governance-reusable.yml" <<YAML |
| 168 | +jobs: |
| 169 | + gate: |
| 170 | + steps: |
| 171 | + - name: Checkout standards for the lock gate |
| 172 | + uses: actions/checkout@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| 173 | + with: |
| 174 | + ref: $NEW |
| 175 | + path: .standards-lock |
| 176 | +YAML |
| 177 | +out="$(run "$NEW")"; rc=$? |
| 178 | +check "empty staged scope is refused" 1 "$rc" "$out" "stages no sparse-checkout paths" |
| 179 | + |
| 180 | +echo |
| 181 | +echo "$pass passed, $fail failed" |
| 182 | +[ "$fail" -eq 0 ] || exit 1 |
| 183 | +echo "PASS: lock-gate pin freshness guard refuses a stale pin and cannot be silenced by renaming its subject" |
0 commit comments