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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions scripts/ci/check-workflow-run-syntax.py
Original file line number Diff line number Diff line change
@@ -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:]))
Loading