diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f05e2c60a..abedb7a6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -392,6 +392,15 @@ jobs: env: ACTIONLINT_VERSION: '1.7.7' + # actionlint leaves the embedded scripts alone, so a syntax error in a + # step that only runs on a tag would first fail at release time. + - name: Parse the workflow run scripts + run: | + set -euo pipefail + python3 -m venv /tmp/wf-venv + /tmp/wf-venv/bin/pip install -q 'pyyaml==6.0.2' + /tmp/wf-venv/bin/python scripts/ci/check-workflow-run-syntax.py + # Every canary script, workflow and Rust harness that runs a pinned # downstream image must take it from scripts/canary/PINS, and every image # there must be pinned by digest. Offline; the freshness half runs weekly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf133ec8b..5170d2265 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -945,7 +945,7 @@ jobs: | { cat; echo "${GITHUB_REF_NAME}"; } | sort -uV \ | awk -v t="${GITHUB_REF_NAME}" '$0 == t { print p; exit } { p = $0 }' || true) fi - echo "PR list compares against: ${prev:-GitHub's default}" + echo "PR list compares against: ${prev:-the GitHub default}" echo "previous_tag=${prev}" >> "$GITHUB_OUTPUT" - name: Create or update GitHub Release diff --git a/scripts/ci/check-workflow-run-syntax.py b/scripts/ci/check-workflow-run-syntax.py new file mode 100755 index 000000000..a11e60d8f --- /dev/null +++ b/scripts/ci/check-workflow-run-syntax.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Parse every bash `run:` block under .github/workflows with `bash -n`. + +actionlint runs with shellcheck off, so nothing else parses the scripts +before they run. A step that only runs on a tag (the GitHub Release job) +would otherwise fail for the first time at release time: v0.6.0's did, +on a quote character inside ${prev:-...}. + +${{ ... }} expressions are replaced with a plain word first; GitHub +substitutes them before the shell sees the script. Each block is parsed +by the shell it runs under (`bash -n` or `sh -n`); other shells are +skipped. + +Usage: check-workflow-run-syntax.py [workflow.yml ...] +With no arguments, every workflow under .github/workflows is checked. +""" + +import glob +import re +import subprocess +import sys + +import yaml + +# A GitHub expression, up to the first `}}` that is not inside one of its +# single-quoted string literals ('' escapes a quote inside a literal). +EXPRESSION = re.compile(r"\$\{\{(?:'(?:[^']|'')*'|[^'}]|\}(?!\}))*\}\}") + + +def run_shell(step, job, workflow): + for scope in (step, (job.get("defaults") or {}).get("run") or {}, + (workflow.get("defaults") or {}).get("run") or {}): + if scope.get("shell"): + return scope["shell"] + return "bash" + + +def main(paths): + if not paths: + paths = sorted(glob.glob(".github/workflows/*.yml") + + glob.glob(".github/workflows/*.yaml")) + checked = failed = 0 + for path in paths: + with open(path) as f: + workflow = yaml.safe_load(f) + for job_id, job in (workflow.get("jobs") or {}).items(): + for i, step in enumerate(job.get("steps") or []): + script = step.get("run") + if script is None: + continue + shell = run_shell(step, job, workflow).split()[0] + if shell not in ("bash", "sh"): + continue + checked += 1 + script = EXPRESSION.sub("X", script) + r = subprocess.run([shell, "-n"], input=script, text=True, + capture_output=True) + if r.returncode: + failed += 1 + name = step.get("name", f"step {i}") + print(f"::error file={path}::job {job_id}, {name}: " + f"{r.stderr.strip()}") + print(f"{checked} run blocks parsed, {failed} failed") + if checked == 0: + # A wrong working directory or a moved workflows directory would + # otherwise pass having checked nothing. + print("::error::no run blocks found to check") + return 1 + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))