From 1efc86ecdf9f8bf5eb7b05f08cf201926e064469 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 18:59:57 -0600 Subject: [PATCH 1/3] release: fix the notes step's quoting; parse every workflow script in CI The v0.6.0 release run built every artifact, then its GitHub Release job failed before creating the release: bash 5.2 read the apostrophe in "${prev:-GitHub's default}" as an opening quote and the step did not parse ("unexpected EOF while looking for matching `''"). The step only runs on a tag, and actionlint runs with shellcheck off, so no PR run parsed it. The message now reads "the GitHub default". CI's Lint job gains scripts/ci/check-workflow-run-syntax.py, which runs `bash -n` over every bash run block under .github/workflows (174 today), with ${{ ... }} expressions replaced by a plain word. Without the fix it fails on exactly this step; with it, all 174 parse. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 9 ++++ .github/workflows/release.yml | 2 +- scripts/ci/check-workflow-run-syntax.py | 55 +++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100755 scripts/ci/check-workflow-run-syntax.py 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..77ad4e97e --- /dev/null +++ b/scripts/ci/check-workflow-run-syntax.py @@ -0,0 +1,55 @@ +#!/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. +""" + +import glob +import re +import subprocess +import sys + +import yaml + + +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(): + checked = failed = 0 + for path in sorted(glob.glob(".github/workflows/*.yml")): + 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 + if run_shell(step, job, workflow).split()[0] not in ("bash", "sh"): + continue + checked += 1 + script = re.sub(r"\$\{\{.*?\}\}", "X", script, flags=re.S) + r = subprocess.run(["bash", "-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") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 3c00afdda1138b05a4ebb0afce6c45482dd71d60 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 20:26:26 -0600 Subject: [PATCH 2/3] ci: parse sh steps with sh, find .yaml workflows, keep quoted }} in expressions Three gaps in check-workflow-run-syntax.py from review: - A `shell: sh` step was parsed with `bash -n`, so a bashism there passed the check and would fail under dash on the runner. Each block is now parsed by its own shell. - Only *.yml was globbed; GitHub also runs *.yaml. - The ${{ ... }} substitution stopped at the first `}}`, even inside a quoted literal: `echo ${{ format('{0}}}', github.ref) }}` left a stray quote and failed. The pattern now skips single-quoted literals. The script also takes workflow paths as arguments, which is how each case was checked against a fixture: a bashism under `shell: sh` fails, a .yaml-only workflows directory is found, the unquoted format() call passes (the previous version failed it), and the v0.6.0 quoting bug still fails. The repository's 174 run blocks all parse. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/ci/check-workflow-run-syntax.py | 27 ++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/scripts/ci/check-workflow-run-syntax.py b/scripts/ci/check-workflow-run-syntax.py index 77ad4e97e..f6d9d9af2 100755 --- a/scripts/ci/check-workflow-run-syntax.py +++ b/scripts/ci/check-workflow-run-syntax.py @@ -7,7 +7,12 @@ on a quote character inside ${prev:-...}. ${{ ... }} expressions are replaced with a plain word first; GitHub -substitutes them before the shell sees the script. +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 @@ -17,6 +22,10 @@ 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 {}, @@ -26,9 +35,12 @@ def run_shell(step, job, workflow): return "bash" -def main(): +def main(paths): + if not paths: + paths = sorted(glob.glob(".github/workflows/*.yml") + + glob.glob(".github/workflows/*.yaml")) checked = failed = 0 - for path in sorted(glob.glob(".github/workflows/*.yml")): + for path in paths: with open(path) as f: workflow = yaml.safe_load(f) for job_id, job in (workflow.get("jobs") or {}).items(): @@ -36,11 +48,12 @@ def main(): script = step.get("run") if script is None: continue - if run_shell(step, job, workflow).split()[0] not in ("bash", "sh"): + shell = run_shell(step, job, workflow).split()[0] + if shell not in ("bash", "sh"): continue checked += 1 - script = re.sub(r"\$\{\{.*?\}\}", "X", script, flags=re.S) - r = subprocess.run(["bash", "-n"], input=script, text=True, + script = EXPRESSION.sub("X", script) + r = subprocess.run([shell, "-n"], input=script, text=True, capture_output=True) if r.returncode: failed += 1 @@ -52,4 +65,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + sys.exit(main(sys.argv[1:])) From bd3c70dacc618baa735ea78c5b5f02b5a6206054 Mon Sep 17 00:00:00 2001 From: Benjamen Keroack Date: Mon, 5 Oct 2026 20:58:18 -0600 Subject: [PATCH 3/3] ci: fail the workflow script check when it finds nothing to check Run from the wrong directory, or after the workflows directory moved, the check printed "0 run blocks parsed, 0 failed" and passed. It now fails when no run block was found. From an empty directory the previous version exits 0 and this one exits 1; the repository's 174 run blocks still pass. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/ci/check-workflow-run-syntax.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/ci/check-workflow-run-syntax.py b/scripts/ci/check-workflow-run-syntax.py index f6d9d9af2..a11e60d8f 100755 --- a/scripts/ci/check-workflow-run-syntax.py +++ b/scripts/ci/check-workflow-run-syntax.py @@ -61,6 +61,11 @@ def main(paths): 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