Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/pre-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 6 additions & 13 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
85 changes: 85 additions & 0 deletions scripts/actionlint-hook.py
Original file line number Diff line number Diff line change
@@ -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())
127 changes: 127 additions & 0 deletions scripts/test-actionlint-hook.sh
Original file line number Diff line number Diff line change
@@ -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'
9 changes: 9 additions & 0 deletions scripts/testdata/actionlint-hook/clean.fixture
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name: valid
on: push
jobs:
check:
runs-on: ubuntu-latest
steps:
- run: echo "$VALUE"
- shell: python
run: print("valid")
8 changes: 8 additions & 0 deletions scripts/testdata/actionlint-hook/python.fixture
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: bad python
on: push
jobs:
check:
runs-on: ubuntu-latest
steps:
- shell: python
run: print(undefined_name)
7 changes: 7 additions & 0 deletions scripts/testdata/actionlint-hook/shell.fixture
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: bad shell
on: push
jobs:
check:
runs-on: ubuntu-latest
steps:
- run: echo $VALUE
3 changes: 3 additions & 0 deletions scripts/testdata/actionlint-hook/syntax.fixture
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: bad workflow
on: push
jobs: [
Loading