diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 47cd55a4..e4e924e1 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -29,7 +29,9 @@ jobs: - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: "3.13" + python-version: | + 3.12 + 3.13 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -216,6 +218,9 @@ jobs: - name: Install pre-commit run: pip install 'pre-commit==4.0.1' + - name: Verify actionlint native transport and pinned image parity + run: bash scripts/test-actionlint-hook.sh --docker-parity + # Cache pre-commit's per-hook environments (Go, Python, Node, etc. # virtualenvs it builds on first run). Keyed on the hook config # because pre-commit will rebuild any env whose pinned rev changes. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 34650f1c..d4316a81 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -102,23 +102,16 @@ repos: # versions, so an expression injection is caught before the push rather than # after. Keep the versions here and in ci.yml in lockstep. # - # actionlint is a local docker_image hook rather than the upstream - # rhysd/actionlint hook for the reason spelled out above for hadolint: the - # upstream actionlint-docker hook's entry pins the image by mutable tag, and - # `rev:` only pins the hook definition, not the image it runs. This entry - # pins the digest, so the ruleset can only change by editing this line. - # - # The image bundles shellcheck. actionlint silently skips every run: block - # and still exits 0 when shellcheck is absent from PATH, which is how the - # rollback.yml injection (#1542) went unflagged, so a system hook that - # depends on each developer having shellcheck installed would reintroduce - # exactly that gap. + # Match the pinned CI image, including Python 3.12. Require env-owned checkers + # because actionlint silently skips ShellCheck and pyflakes when missing. - repo: local hooks: - id: actionlint name: Lint GitHub Actions workflows - language: docker_image - entry: rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 + language: python + language_version: python3.12 + additional_dependencies: ['shellcheck-py==0.11.0.1', 'pyflakes==3.4.0'] + entry: python scripts/actionlint-hook.py types: [yaml] files: ^\.github/workflows/ diff --git a/scripts/actionlint-hook.py b/scripts/actionlint-hook.py new file mode 100644 index 00000000..d8f4cfbd --- /dev/null +++ b/scripts/actionlint-hook.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +import fcntl +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time + +ACTIONLINT_MODULE = "github.com/rhysd/actionlint" +ACTIONLINT_VERSION = "v1.7.12" +ACTIONLINT_SUM = "h1:vQ4GeJN86C0QH+gTUQcs8McmK62OLT3kmakPMtEWYnY=" +LOCK_TIMEOUT_SECONDS = 600 + + +def main() -> int: + tools = Path(sys.executable).parent + versions = {"shellcheck": "0.11.0", "pyflakes": "3.4.0"} + for checker, expected in versions.items(): + binary = tools / checker + if not binary.is_file() or not os.access(binary, os.X_OK): + print(f"actionlint: missing required hook dependency {checker}; rebuild the pre-commit environment", file=sys.stderr) + return 2 + try: + result = subprocess.run( + [str(binary), "--version"], check=True, capture_output=True, + text=True, timeout=10, + ) + except (OSError, subprocess.SubprocessError) as error: + print(f"actionlint: cannot verify {checker}: {error}", file=sys.stderr) + return 2 + actual = result.stdout.splitlines() + valid = f"version: {expected}" in actual if checker == "shellcheck" else result.stdout.startswith(f"{expected} ") + if not valid: + print(f"actionlint: required {checker} {expected}; rebuild the pre-commit environment", file=sys.stderr) + return 2 + go = shutil.which("go") + if go is None: + print("actionlint: Go is required to run pinned actionlint v1.7.12; install the project Go toolchain", file=sys.stderr) + return 2 + actionlint = tools / "actionlint" + try: + with (tools / ".actionlint-install.lock").open("a") as lock: + deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS + while True: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + print("actionlint: timed out waiting for bootstrap lock; retry after the other hook process finishes", file=sys.stderr) + return 2 + time.sleep(0.1) + if actionlint.is_symlink(): + print("actionlint: cached actionlint must not be a symlink; rebuild the pre-commit environment", file=sys.stderr) + return 2 + if not actionlint.exists(): + subprocess.run( + [go, "install", f"{ACTIONLINT_MODULE}/cmd/actionlint@{ACTIONLINT_VERSION}"], + env={**os.environ, "GOBIN": str(tools)}, check=True, timeout=300, + ) + info = json.loads(subprocess.check_output( + [go, "version", "-m", "-json", str(actionlint)], text=True, timeout=10, + )) + expected_module = {"Path": ACTIONLINT_MODULE, "Version": ACTIONLINT_VERSION, "Sum": ACTIONLINT_SUM} + if ( + info.get("Path") != f"{ACTIONLINT_MODULE}/cmd/actionlint" + or info.get("Main") != expected_module + or not os.access(actionlint, os.X_OK) + ): + print("actionlint: cached binary does not match the pinned module; rebuild the pre-commit environment", file=sys.stderr) + return 2 + except (OSError, subprocess.SubprocessError, ValueError) as error: + print(f"actionlint: cannot provision or verify pinned actionlint: {error}", file=sys.stderr) + return 2 + os.execv(str(actionlint), [ + str(actionlint), + "-shellcheck", str(tools / "shellcheck"), + "-pyflakes", str(tools / "pyflakes"), *sys.argv[1:], + ]) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test-actionlint-hook.sh b/scripts/test-actionlint-hook.sh new file mode 100644 index 00000000..429780a2 --- /dev/null +++ b/scripts/test-actionlint-hook.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +parity=false +if [[ $# -eq 1 && "$1" == --docker-parity ]]; then + parity=true +elif [[ $# -ne 0 ]]; then + echo "Usage: $0 [--docker-parity]" >&2 + exit 2 +fi + +root="$(git rev-parse --show-toplevel)" +scratch="$(mktemp -d "${TMPDIR:-/tmp}/actionlint-hook.XXXXXXXX")" +trap 'printf "actionlint test evidence: %s\n" "$scratch"' EXIT +mkdir -p "$scratch/repo/scripts" "$scratch/repo/.github/workflows" +cp "$root/scripts/actionlint-hook.py" "$scratch/repo/scripts/" +python3 -c 'import sys,yaml; c=yaml.safe_load(open(sys.argv[1])); h=next(h for r in c["repos"] if r["repo"]=="local" for h in r["hooks"] if h["id"]=="actionlint"); yaml.safe_dump({"repos":[{"repo":"local","hooks":[h]}]},open(sys.argv[2],"w"))' \ + "$root/.pre-commit-config.yaml" "$scratch/repo/hook config.yaml" +git -C "$scratch/repo" init -q +export PRE_COMMIT_HOME="$scratch/pre-commit" +export GOMODCACHE="$scratch/modules" + +expect() { + local wanted="$1" diagnostic="$2" log="$3" + shift 3 + local status=0 + "$@" >"$log" 2>&1 || status=$? + if [[ "$wanted" == pass && "$status" -ne 0 ]] || [[ "$wanted" == fail && "$status" -eq 0 ]]; then + cat "$log" >&2 + echo "Unexpected $wanted result: $* (status $status)" >&2 + exit 1 + fi + if [[ -n "$diagnostic" ]] && ! grep -Fq -- "$diagnostic" "$log"; then + cat "$log" >&2 + echo "Missing diagnostic: $diagnostic" >&2 + exit 1 + fi +} + +run_hook() ( + cd "$scratch/repo" + pre-commit run --config 'hook config.yaml' actionlint --files "$@" +) + +for fixture in clean shell python syntax; do + name=".github/workflows/$fixture workflow.yml" + cp "$root/scripts/testdata/actionlint-hook/$fixture.fixture" "$scratch/repo/$name" + wanted=fail + case "$fixture" in + clean) wanted=pass; diagnostic='Passed' ;; + shell) diagnostic='SC2086' ;; + python) diagnostic="undefined name 'undefined_name'" ;; + syntax) diagnostic='[syntax-check]' ;; + esac + expect "$wanted" "$diagnostic" "$scratch/$fixture.native.log" run_hook "$name" + if "$parity"; then + image='rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667' + docker_diagnostic="$diagnostic" + if [[ "$wanted" == pass ]]; then docker_diagnostic=''; fi + expect "$wanted" "$docker_diagnostic" "$scratch/$fixture.docker.log" \ + docker run --rm -v "$scratch/repo:/repo" -w /repo "$image" "$name" + fi +done + +cp "$root/scripts/testdata/actionlint-hook/clean.fixture" "$scratch/repo/.github/workflows/clean workflow.yaml" +expect pass 'Passed' "$scratch/yaml.native.log" run_hook '.github/workflows/clean workflow.yaml' +if "$parity"; then + expect pass '' "$scratch/yaml.docker.log" docker run --rm -v "$scratch/repo:/repo" -w /repo "$image" '.github/workflows/clean workflow.yaml' +fi + +interpreters=("$PRE_COMMIT_HOME"/repo*/py_env-python3.12/bin/python) +[[ ${#interpreters[@]} -eq 1 && -x "${interpreters[0]}" ]] +python="${interpreters[0]}" +tools="${python%/python}" +wrapper="$scratch/repo/scripts/actionlint-hook.py" +clean="$scratch/repo/.github/workflows/clean workflow.yaml" +expect pass '' "$scratch/warm-offline.log" env GOPROXY=off "$python" "$wrapper" "$clean" +expect fail 'timed out waiting for bootstrap lock' "$scratch/lock-timeout.log" "$python" -c \ + 'import fcntl,importlib.util,signal,sys; from pathlib import Path; spec=importlib.util.spec_from_file_location("hook",sys.argv[1]); hook=importlib.util.module_from_spec(spec); spec.loader.exec_module(hook); hook.LOCK_TIMEOUT_SECONDS=0.1; lock=(Path(sys.executable).parent/".actionlint-install.lock").open("a"); fcntl.flock(lock,fcntl.LOCK_EX); signal.alarm(2); sys.exit(hook.main())' "$wrapper" +expect fail 'Go is required' "$scratch/missing-go.log" env PATH=/nonexistent "$python" "$wrapper" "$clean" + +python3.12 -m venv --without-pip "$scratch/bootstrap" +bootstrap="$scratch/bootstrap/bin" +ln -s "$tools/shellcheck" "$bootstrap/shellcheck" +ln -s "$tools/pyflakes" "$bootstrap/pyflakes" +expect fail 'module lookup disabled by GOPROXY=off' "$scratch/cold-offline.log" \ + env GOPROXY=off GOMODCACHE="$scratch/empty-modules" "$bootstrap/python" "$wrapper" "$clean" +"$bootstrap/python" "$wrapper" "$clean" > "$scratch/concurrent-one.log" 2>&1 & +one=$! +"$bootstrap/python" "$wrapper" "$clean" > "$scratch/concurrent-two.log" 2>&1 & +two=$! +status=0 +wait "$one" || status=1 +wait "$two" || status=1 +[[ "$status" -eq 0 ]] +expect pass '' "$scratch/concurrent-verified.log" env GOPROXY=off "$bootstrap/python" "$wrapper" "$clean" +cp "$(command -v go)" "$bootstrap/actionlint" +expect fail 'cached binary does not match the pinned module' "$scratch/wrong-actionlint.log" "$bootstrap/python" "$wrapper" "$clean" +printf 'corrupt cached binary\n' > "$bootstrap/actionlint" +expect fail 'cannot provision or verify pinned actionlint' "$scratch/corrupt-actionlint.log" "$bootstrap/python" "$wrapper" "$clean" +mv "$bootstrap/actionlint" "$bootstrap/actionlint.corrupt" +ln -s "$scratch/outside-actionlint" "$bootstrap/actionlint" +expect fail 'cached actionlint must not be a symlink' "$scratch/broken-link.log" "$bootstrap/python" "$wrapper" "$clean" +[[ ! -e "$scratch/outside-actionlint" ]] +cp "$tools/actionlint" "$scratch/outside-actionlint" +expect fail 'cached actionlint must not be a symlink' "$scratch/valid-link.log" "$bootstrap/python" "$wrapper" "$clean" +cmp "$tools/actionlint" "$scratch/outside-actionlint" + +python3.12 -m venv --without-pip "$scratch/probe" +probe="$scratch/probe/bin" +expect fail 'missing required hook dependency shellcheck' "$scratch/missing-shellcheck.log" "$probe/python" "$wrapper" "$clean" +ln -s "$tools/shellcheck" "$probe/shellcheck" +expect fail 'missing required hook dependency pyflakes' "$scratch/missing-pyflakes.log" "$probe/python" "$wrapper" "$clean" +"$python" -c 'import pathlib,sys; p=pathlib.Path(sys.argv[1]); p.write_text("#!/bin/sh\nexit 9\n"); p.chmod(0o755)' "$probe/pyflakes" +expect fail 'cannot verify pyflakes' "$scratch/corrupt-pyflakes.log" "$probe/python" "$wrapper" "$clean" +"$python" -c 'import pathlib,sys; p=pathlib.Path(sys.argv[1]); p.write_text("#!/bin/sh\nprintf \"0.0.0 Python\\n\"\n"); p.chmod(0o755)' "$probe/pyflakes" +expect fail 'required pyflakes 3.4.0' "$scratch/wrong-version.log" "$probe/python" "$wrapper" "$clean" + +printf 'self-hosted-runner:\n labels: [custom-transport-runner]\n' > "$scratch/config with spaces.yaml" +"$python" -c 'from pathlib import Path; import sys; Path(sys.argv[2]).write_text(Path(sys.argv[1]).read_text().replace("ubuntu-latest", "custom-transport-runner"))' "$clean" "$scratch/custom runner.yaml" +expect fail '[runner-label]' "$scratch/config-control.native.log" "$python" "$wrapper" "$scratch/custom runner.yaml" +expect pass '' "$scratch/config.native.log" "$python" "$wrapper" -config-file "$scratch/config with spaces.yaml" "$scratch/custom runner.yaml" +expect fail 'SC2086' "$scratch/debug.native.log" "$python" "$wrapper" -debug "$scratch/repo/.github/workflows/shell workflow.yml" +grep -Fq -- '--norc -f json -x --shell bash -e SC1091,SC2194,SC2050,SC2153,SC2154,SC2157,SC2043 -' "$scratch/debug.native.log" +expect fail "undefined name 'undefined_name'" "$scratch/debug-python.native.log" "$python" "$wrapper" -debug "$scratch/repo/.github/workflows/python workflow.yml" +grep -Eq 'Running .*/pyflakes for Python script:' "$scratch/debug-python.native.log" +printf 'actionlint transport regression checks passed\n' diff --git a/scripts/testdata/actionlint-hook/clean.fixture b/scripts/testdata/actionlint-hook/clean.fixture new file mode 100644 index 00000000..f1949e82 --- /dev/null +++ b/scripts/testdata/actionlint-hook/clean.fixture @@ -0,0 +1,9 @@ +name: valid +on: push +jobs: + check: + runs-on: ubuntu-latest + steps: + - run: echo "$VALUE" + - shell: python + run: print("valid") diff --git a/scripts/testdata/actionlint-hook/python.fixture b/scripts/testdata/actionlint-hook/python.fixture new file mode 100644 index 00000000..5b0e4711 --- /dev/null +++ b/scripts/testdata/actionlint-hook/python.fixture @@ -0,0 +1,8 @@ +name: bad python +on: push +jobs: + check: + runs-on: ubuntu-latest + steps: + - shell: python + run: print(undefined_name) diff --git a/scripts/testdata/actionlint-hook/shell.fixture b/scripts/testdata/actionlint-hook/shell.fixture new file mode 100644 index 00000000..e7c7e505 --- /dev/null +++ b/scripts/testdata/actionlint-hook/shell.fixture @@ -0,0 +1,7 @@ +name: bad shell +on: push +jobs: + check: + runs-on: ubuntu-latest + steps: + - run: echo $VALUE diff --git a/scripts/testdata/actionlint-hook/syntax.fixture b/scripts/testdata/actionlint-hook/syntax.fixture new file mode 100644 index 00000000..36bfc71c --- /dev/null +++ b/scripts/testdata/actionlint-hook/syntax.fixture @@ -0,0 +1,3 @@ +name: bad workflow +on: push +jobs: [