diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 80170942..a24bed77 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -19,6 +19,40 @@ jobs: - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y zsh + # Pin shellcheck instead of inheriting whatever ubuntu-latest ships. + # + # The runner image carried 0.9.0 while developer machines have 0.11.0 + # from pacman, and the shellcheck hook is `language: system` -- so it + # used whichever binary happened to be on PATH. The two versions + # disagree (0.11.0 demoted SC2002 and several SC2015/SC2119 cases as + # noise), which meant a commit could pass every local check and still + # fail CI on findings the developer could not reproduce. That is exactly + # what kept this workflow red from b83ce5d onward. + # + # Keep SHELLCHECK_VERSION in step with the version developers install; + # the verification below fails loudly rather than silently drifting. + - name: Install shellcheck (pinned) + env: + SHELLCHECK_VERSION: v0.11.0 + SHELLCHECK_SHA256: 8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 + run: | + set -euo pipefail + tarball="shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" + curl -fsSL -o "$tarball" \ + "https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/${tarball}" + echo "${SHELLCHECK_SHA256} ${tarball}" | sha256sum --check --strict + tar -xJf "$tarball" + sudo install -m 0755 "shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck + # Fail here, not 40 hooks later, if PATH still resolves to another copy. + installed="$(shellcheck --version | awk '/^version:/ { print $2 }')" + expected="${SHELLCHECK_VERSION#v}" + if [ "$installed" != "$expected" ]; then + echo "::error::shellcheck on PATH is ${installed}, expected ${expected}" >&2 + command -v -a shellcheck >&2 + exit 1 + fi + echo "shellcheck ${installed} pinned and on PATH" + - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/docs/superpowers/evidence/pin-shellcheck-version-2026-08-04.json b/docs/superpowers/evidence/pin-shellcheck-version-2026-08-04.json new file mode 100644 index 00000000..88b3ea05 --- /dev/null +++ b/docs/superpowers/evidence/pin-shellcheck-version-2026-08-04.json @@ -0,0 +1,64 @@ +{ + "intent": "Close the root cause behind the CI outage fixed in 9dd9aeb: CI and developer machines ran different shellcheck versions, so a commit could pass every local gate and still fail CI on findings the developer could not reproduce. Pin CI to the version developers actually run, and make any future divergence visible locally at commit time instead of as an unreproducible CI failure.", + "scope": [ + ".github/workflows/pre-commit.yml (pin + verify shellcheck)", + "linux_configuration/.githooks/lib/common.sh (local drift warning)", + "Non-goal: changing which findings shellcheck reports, or any hook's behaviour", + "Non-goal: pinning the local shellcheck itself, which comes from pacman on a rolling distro where version bumps are routine", + "Non-goal: pinning the other language:system tools (prettier, shfmt, zsh, node); only shellcheck has demonstrated this failure" + ], + "changes": [ + "Added an 'Install shellcheck (pinned)' step to the pre-commit workflow that downloads shellcheck v0.11.0 from the official GitHub release, verifies it against a recorded sha256 with 'sha256sum --check --strict', installs it to /usr/local/bin, and then asserts the version resolved on PATH matches. Previously the workflow never installed shellcheck at all and silently inherited whatever ubuntu-latest shipped, which was 0.9.0.", + "The version assertion fails the step immediately, with 'command -v -a shellcheck' output, rather than letting a wrong binary surface as confusing findings 40 hooks later.", + "Added EXPECTED_SHELLCHECK_VERSION and check_shellcheck_version() to the git hook library, called from verify_toolchain(), so a local shellcheck that disagrees with the CI pin prints a warning naming both files to update.", + "The local check warns rather than aborts on purpose: shellcheck arrives via pacman on a rolling distro, so a bump is routine and must not block a commit. What must not happen silently is local and CI disagreeing about what counts as a finding." + ], + "verification": [ + { + "command": "python3 -c \"import yaml; yaml.safe_load(open('.github/workflows/pre-commit.yml'))\"", + "result": "pass", + "evidence": "Parses cleanly; steps are checkout, setup-python, Install system dependencies, Install shellcheck (pinned), Install dependencies, Run pre-commit hooks -- the pin lands before the hooks run." + }, + { + "command": "the workflow's install steps, executed verbatim in a scratch directory", + "result": "pass", + "evidence": "curl fetched shellcheck-v0.11.0.linux.x86_64.tar.xz (HTTP 200); 'sha256sum --check --strict' reported OK; the extracted binary reports version 0.11.0, so the version gate would pass in CI." + }, + { + "command": "check_shellcheck_version with a stub shellcheck reporting 0.9.0 on PATH", + "result": "pass", + "evidence": "Emitted: '⚠ shellcheck 0.9.0 locally, CI pins 0.11.0.' followed by both file paths to update. The drift warning was observed firing, not merely assumed to work." + }, + { + "command": "check_shellcheck_version against the real local shellcheck 0.11.0", + "result": "pass", + "evidence": "No output, as intended -- the warning stays silent when the versions agree." + }, + { + "command": "shellcheck linux_configuration/.githooks/lib/common.sh", + "result": "pass", + "evidence": "Zero findings." + }, + { + "command": "bash ~/.claude/scripts/finish.sh gate ~/testsAndMisc <2 files>", + "result": "pass", + "evidence": "FINISH_GATE {\"risk\":\"review\",\"changed_files\":2,\"phase\":\"ok\"} with 1193 Python tests at 100% coverage." + }, + { + "command": "gh run view 30940953208 # Pre-commit checks on 9dd9aeb", + "result": "pass", + "evidence": "Pre-commit checks = success -- the first green run of that workflow since it broke at b83ce5d on 2026-08-03. This change protects that result rather than producing it." + } + ], + "risks": [ + "The pinned version now has to be maintained in two places. The local warning exists precisely to surface that, but a developer who ignores it can still drift.", + "The recorded sha256 must be updated alongside SHELLCHECK_VERSION; a bump without it fails the checksum step, which is the intended direction of failure.", + "CI now depends on the GitHub release asset being reachable at build time. A transient outage fails the workflow rather than silently falling back to the runner's copy, which is deliberate but is a new external dependency.", + "The other language:system tools (prettier, shfmt, zsh, node) remain unpinned and could develop the same class of divergence; only shellcheck has demonstrated it so far." + ], + "rollback": [ + "git revert the commit; both changes are purely additive and no existing step or function was modified.", + "After reverting, CI returns to using the runner image's shellcheck, so expect findings to reappear that local 0.11.0 does not report.", + "Confirm with: pre-commit run shellcheck --all-files locally, and check the next CI 'Pre-commit checks' run still passes." + ] +} diff --git a/linux_configuration/.githooks/lib/common.sh b/linux_configuration/.githooks/lib/common.sh index a5749a1b..872e8220 100644 --- a/linux_configuration/.githooks/lib/common.sh +++ b/linux_configuration/.githooks/lib/common.sh @@ -89,6 +89,35 @@ Run this yourself, then retry: ! sudo pacman -S ${pkg}" } +# The shellcheck version CI pins (see .github/workflows/pre-commit.yml). +# Kept here so a drift between the two is visible locally, at commit time, +# rather than as a CI failure nobody can reproduce on their own machine. +readonly EXPECTED_SHELLCHECK_VERSION="0.11.0" + +# Warn when the local shellcheck disagrees with the one CI pins. +# +# Deliberately a warning, not an abort: shellcheck comes from pacman on a +# rolling distro, so a version bump here is routine and should not block a +# commit. What must not happen silently is the situation this check exists +# for -- local and CI disagreeing about what counts as a finding, so a commit +# passes every local gate and then fails CI on output the developer cannot +# reproduce. When this fires, update EXPECTED_SHELLCHECK_VERSION and +# SHELLCHECK_VERSION in the workflow together. +check_shellcheck_version() { + local installed + + command -v shellcheck >/dev/null 2>&1 || return 0 + + installed="$(shellcheck --version 2>/dev/null | awk '/^version:/ { print $2 }')" + [[ -n $installed ]] || return 0 + [[ $installed == "$EXPECTED_SHELLCHECK_VERSION" ]] && return 0 + + hook_log "⚠ shellcheck ${installed} locally, CI pins ${EXPECTED_SHELLCHECK_VERSION}." + hook_log " Findings may differ between here and CI. Update both:" + hook_log " .github/workflows/pre-commit.yml (SHELLCHECK_VERSION)" + hook_log " linux_configuration/.githooks/lib/common.sh (EXPECTED_SHELLCHECK_VERSION)" +} + # Ensure every tool in REQUIRED_TOOLS is present. Fails closed on the first # one that cannot be provided. verify_toolchain() { @@ -98,4 +127,6 @@ verify_toolchain() { pkg="${entry##*:}" require_tool "$cmd" "$pkg" done + + check_shellcheck_version }