The Collect guard added to the publish job in #121 shipped without its closing `fi`. Run 32096242353 -- the first nightly to reach the new single publish job -- built all 107 devices, staged 219 assets, and then died in `bash -e` with line 25: syntax error: unexpected end of file from `if' command on line 14 before writing anything. No nightly-20260818-* release exists, `nightly` and `latest` still carry the 17th's images, and ci-gate went red. The gate did its job; there was simply nothing left to publish by then. Nothing caught it earlier because the publish job carries `if: github.event_name != 'pull_request'` -- there are no artifacts to publish on a PR, so the entire path is unreachable from PR CI by construction. Both of #121's runs were green without ever executing the step. #121 said as much ("none of this path can run on a pull request") and checked the four states offline, but offline-checking the logic does not check that the script parses, and that is what broke. So the one-line fix, plus the check that would have caught it in seconds without a runner: lint-workflow-shell.py parses every `run:` block in .github/workflows/ with `bash -n`, and lint.yml runs it on PRs and on master pushes that touch either. Notes on the checker: - ${{ }} is not shell, so each expression is replaced with a plain word first. That is a real limitation -- an expression interpolating shell syntax is checked as the word, not as what it expands to -- and the substitution preserves line counts so reported lines still point at the right line. --self-test asserts an unterminated `if` containing an expression is still rejected while the closed form passes, because the way this breaks is by making everything pass. - Steps are found by walking for any mapping with a `run:` key rather than by the jobs.*.steps[*] path, so nothing depends on guessing where they live, and a discovery floor fails the run if the walk stops finding them. A checker that silently checks nothing looks exactly like a clean tree. - Syntax only. It says nothing about quoting or `-e` semantics; a shellcheck pass wants per-block disables for the substitution above and is a separate change. actionlint would cover more and is worth considering later; keeping this narrow is what makes it noise-free now. ci-matrix.py classifies both new files as no-build. They were names it had never heard of, and unknown widens by design, so the first push queued all 107 devices to prove a one-line YAML fix -- #121, which touched only master.yml, ran 19. This PR still took the full matrix because it edits ci-matrix.py itself, which always widens; the selector is not trusted to pick a smaller matrix for its own changes. PyYAML is installed explicitly rather than inherited from the runner image, and the workflow declares `contents: read` rather than taking the repo default. Both from review; neither would have failed the job today. Verified: the checker flags the merged master.yml with the same message the runner gave, passes the fixed tree, and all five guard states behave as #121 intended -- normal night publishes, a broken download and a green-but-empty dist both fail, and a dead matrix with nothing built still exits clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
314 lines
11 KiB
Python
Executable File
314 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Parse-check every `run:` block in .github/workflows/.
|
|
|
|
WHY THIS EXISTS. The publish job in master.yml carries
|
|
`if: github.event_name != 'pull_request'` -- there are no artifacts to publish
|
|
on a PR, so the whole path is unreachable from PR CI by construction. #121
|
|
edited that job's Collect step, both PR runs went green without ever executing
|
|
it, and the merge shipped an `if` with no closing `fi`. The first thing to
|
|
actually run the step was the 03:00 nightly: 107 devices built, 219 assets
|
|
staged, and the job died in `bash -e` with "syntax error: unexpected end of
|
|
file" before writing a single release. Nothing published that night.
|
|
|
|
A shell typo in a workflow is therefore not caught by the workflow's own CI --
|
|
it is caught by whatever fires that branch first, which for the release path is
|
|
a cron nobody is watching at 03:00. This is the cheap check that closes that
|
|
gap: it needs no runners, no artifacts, and no matrix, and it would have failed
|
|
#121 in seconds.
|
|
|
|
SCOPE is syntax only, deliberately. `-n` parses without executing, so a block
|
|
that passes here can still be wrong at runtime, and this says nothing about
|
|
quoting, unset variables, or the `-e` semantics that bite in these scripts. A
|
|
shellcheck pass over the same blocks is a strictly bigger change (it wants
|
|
per-block disable lists for the ${{ }} substitution below) and is worth doing
|
|
separately rather than smuggling in behind a syntax fix.
|
|
|
|
DISCOVERY is by structure, not by path: any mapping anywhere in the document
|
|
that has a `run:` key is a step. Hardcoding jobs.*.steps[*] would quietly miss
|
|
anything that moves -- composite actions, reusable workflows, a `defaults`
|
|
refactor -- and silently checking nothing looks exactly like a clean run, which
|
|
is the failure mode this file is here to prevent. Hence the floor check at the
|
|
end.
|
|
|
|
EXPRESSIONS. `${{ ... }}` is not shell and cannot be parsed as shell, so each
|
|
one is replaced with a plain word before the check. That is a real limitation:
|
|
an expression that interpolates to something with shell syntax in it -- a
|
|
matrix value holding `foo && bar`, say -- is checked as the word, not as what
|
|
it expands to. It is the same limitation `bash -n` has with any variable, and
|
|
the substitution keeps the line count identical so reported line numbers still
|
|
point at the right line of the workflow. --self-test asserts the substitution
|
|
did not neuter the check.
|
|
|
|
Usage:
|
|
lint-workflow-shell.py # check .github/workflows/
|
|
lint-workflow-shell.py PATH... # check specific files
|
|
lint-workflow-shell.py --self-test # check this checker still catches things
|
|
"""
|
|
|
|
import glob
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError: # pragma: no cover - depends on the host, not the tree
|
|
sys.exit(
|
|
"lint-workflow-shell: PyYAML is required and missing.\n"
|
|
" CI installs it (see .github/workflows/lint.yml); locally:\n"
|
|
" apt install python3-yaml # or: pip install pyyaml"
|
|
)
|
|
|
|
# Steps with no `shell:` run under bash on a Linux runner; GitHub's default is
|
|
# `bash -e {0}`. The -e changes runtime behaviour, never parsing, so -n alone is
|
|
# the right check for both.
|
|
DEFAULT_SHELL = "bash"
|
|
|
|
# How the `shell:` value maps to a parser. Anything not listed is skipped and
|
|
# reported, so a step that starts using `python` or `pwsh` shows up as a gap
|
|
# rather than being silently dropped.
|
|
PARSERS = {
|
|
"bash": ["bash", "-n"],
|
|
"sh": ["sh", "-n"],
|
|
}
|
|
|
|
EXPR = re.compile(r"\$\{\{.*?\}\}", re.DOTALL)
|
|
|
|
# The floor below is a discovery guard, not a target: it only has to catch "the
|
|
# walk broke and found nothing", which is the case that would otherwise report
|
|
# green. Deliberately well under the current count (20 across the four
|
|
# workflows) so ordinary churn never trips it, and only applied to a full-tree
|
|
# scan -- checking one file by hand legitimately finds fewer.
|
|
MIN_BLOCKS = 10
|
|
|
|
|
|
def substitute(text):
|
|
"""Replace every ${{ }} with a word, preserving the line count.
|
|
|
|
Line numbers have to survive or the errors point at nothing. A multi-line
|
|
expression is replaced by the word plus the newlines it spanned.
|
|
"""
|
|
|
|
def repl(m):
|
|
return "__GHA_EXPR__" + "\n" * m.group(0).count("\n")
|
|
|
|
return EXPR.sub(repl, text)
|
|
|
|
|
|
def walk(node, shell, out):
|
|
"""Collect (run_text, shell, line) for every step under `node`.
|
|
|
|
`shell` is the default inherited from an enclosing `defaults.run.shell`,
|
|
which is how both workflow-level and job-level defaults reach a step
|
|
without this needing to know where in the document it is.
|
|
"""
|
|
if isinstance(node, yaml.MappingNode):
|
|
keys = {}
|
|
for k, v in node.value:
|
|
if isinstance(k, yaml.ScalarNode):
|
|
keys[k.value] = v
|
|
|
|
# A `defaults: run: shell:` here applies to everything below it.
|
|
defaults = keys.get("defaults")
|
|
if isinstance(defaults, yaml.MappingNode):
|
|
for dk, dv in defaults.value:
|
|
if isinstance(dk, yaml.ScalarNode) and dk.value == "run":
|
|
if isinstance(dv, yaml.MappingNode):
|
|
for rk, rv in dv.value:
|
|
if (
|
|
isinstance(rk, yaml.ScalarNode)
|
|
and rk.value == "shell"
|
|
and isinstance(rv, yaml.ScalarNode)
|
|
):
|
|
shell = rv.value
|
|
|
|
run = keys.get("run")
|
|
if isinstance(run, yaml.ScalarNode):
|
|
step_shell = shell
|
|
sh = keys.get("shell")
|
|
if isinstance(sh, yaml.ScalarNode):
|
|
step_shell = sh.value
|
|
name = keys.get("name")
|
|
label = name.value if isinstance(name, yaml.ScalarNode) else "<unnamed>"
|
|
out.append(
|
|
{
|
|
"text": run.value,
|
|
"shell": step_shell,
|
|
"line": run.start_mark.line + 1,
|
|
"name": label,
|
|
}
|
|
)
|
|
|
|
for _, v in node.value:
|
|
walk(v, shell, out)
|
|
|
|
elif isinstance(node, yaml.SequenceNode):
|
|
for v in node.value:
|
|
walk(v, shell, out)
|
|
|
|
|
|
def parse_error(text, shell):
|
|
"""Return bash's complaint about `text`, or None if it parses."""
|
|
# `shell: bash` and `shell: bash {0}` and `shell: bash --noprofile {0}` all
|
|
# mean bash; take the program name and ignore the argument template.
|
|
prog = (shell or DEFAULT_SHELL).split()[0]
|
|
argv = PARSERS.get(prog)
|
|
if argv is None:
|
|
return ("skip", prog)
|
|
|
|
body = substitute(text)
|
|
with tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) as f:
|
|
f.write(body)
|
|
tmp = f.name
|
|
try:
|
|
p = subprocess.run(
|
|
argv + [tmp], capture_output=True, text=True, timeout=30
|
|
)
|
|
if p.returncode == 0:
|
|
return None
|
|
# Rewrite the temp path out of the message; the caller adds the real
|
|
# location. Keep the shell's own line number, it is the offset into the
|
|
# block and the substitution preserved it.
|
|
return ("fail", p.stderr.replace(tmp, "<run block>").strip())
|
|
finally:
|
|
Path(tmp).unlink(missing_ok=True)
|
|
|
|
|
|
def check(paths, enforce_floor):
|
|
blocks = 0
|
|
failures = 0
|
|
skipped = []
|
|
|
|
for path in paths:
|
|
text = Path(path).read_text()
|
|
try:
|
|
root = yaml.compose(text)
|
|
except yaml.YAMLError as e:
|
|
print(f"FAIL {path}: not parseable as YAML: {e}")
|
|
failures += 1
|
|
continue
|
|
if root is None:
|
|
continue
|
|
|
|
found = []
|
|
walk(root, DEFAULT_SHELL, found)
|
|
|
|
for b in found:
|
|
blocks += 1
|
|
where = f"{path}:{b['line']} ({b['name']})"
|
|
result = parse_error(b["text"], b["shell"])
|
|
if result is None:
|
|
print(f"ok {where}")
|
|
elif result[0] == "skip":
|
|
skipped.append(f"{where} [shell: {result[1]}]")
|
|
else:
|
|
print(f"FAIL {where}")
|
|
for line in result[1].splitlines():
|
|
print(f" {line}")
|
|
failures += 1
|
|
|
|
print()
|
|
if skipped:
|
|
print(f"skipped {len(skipped)} block(s) in a shell this does not parse:")
|
|
for s in skipped:
|
|
print(f" -- {s}")
|
|
print()
|
|
|
|
print(f"checked {blocks} run block(s)")
|
|
|
|
# A walk that matched nothing is indistinguishable from a clean run unless
|
|
# something asserts it found the work. See the module docstring.
|
|
if enforce_floor and blocks < MIN_BLOCKS:
|
|
print(
|
|
f"FAIL only {blocks} run block(s) discovered, expected at least "
|
|
f"{MIN_BLOCKS}; the walk has regressed rather than the workflows "
|
|
f"having shrunk that far."
|
|
)
|
|
return 1
|
|
|
|
if failures:
|
|
print(f"{failures} block(s) failed to parse")
|
|
return 1
|
|
|
|
print("all run blocks parse clean")
|
|
return 0
|
|
|
|
|
|
def self_test():
|
|
"""Prove the checker still rejects what it is supposed to reject.
|
|
|
|
The substitution in particular is easy to break in a way that makes
|
|
everything pass -- replace ${{ }} with something that swallows the rest of
|
|
the line and every block parses forever after.
|
|
"""
|
|
ok = True
|
|
|
|
def expect(name, text, shell, should_fail):
|
|
nonlocal ok
|
|
got = parse_error(text, shell)
|
|
failed = got is not None and got[0] == "fail"
|
|
if failed != should_fail:
|
|
want = "reject" if should_fail else "accept"
|
|
print(f"FAIL self-test: expected to {want} {name}, got {got!r}")
|
|
ok = False
|
|
else:
|
|
print(f"ok self-test: {name}")
|
|
|
|
# The exact shape that shipped in #121: an if with no fi.
|
|
expect(
|
|
"unterminated if",
|
|
'if [ "${{ needs.x.result }}" = "success" ]; then\n echo hi\n',
|
|
"bash",
|
|
True,
|
|
)
|
|
# The same block, closed. If the substitution is broken this fails too, and
|
|
# the pair together is what makes the check meaningful.
|
|
expect(
|
|
"terminated if with an expression in it",
|
|
'if [ "${{ needs.x.result }}" = "success" ]; then\n echo hi\nfi\n',
|
|
"bash",
|
|
False,
|
|
)
|
|
expect("unbalanced quote", 'echo "unterminated\n', "bash", True)
|
|
expect("plain block", "echo hello\n", None, False)
|
|
|
|
# An expression spanning lines must not shift the reported line number.
|
|
shifted = substitute("a\n${{ foo\n .bar }}\nc\n")
|
|
if shifted.count("\n") != 4:
|
|
print("FAIL self-test: substitution changed the line count")
|
|
ok = False
|
|
else:
|
|
print("ok self-test: substitution preserves line count")
|
|
|
|
print()
|
|
if not ok:
|
|
print("self-test failed")
|
|
return 1
|
|
print("self-test passed")
|
|
return 0
|
|
|
|
|
|
def main(argv):
|
|
if "--self-test" in argv:
|
|
return self_test()
|
|
|
|
paths = [a for a in argv if not a.startswith("-")]
|
|
# The discovery floor only means anything over the whole tree; an explicit
|
|
# file list is someone checking one thing on purpose.
|
|
full_scan = not paths
|
|
if full_scan:
|
|
paths = sorted(
|
|
glob.glob(".github/workflows/*.yml")
|
|
+ glob.glob(".github/workflows/*.yaml")
|
|
)
|
|
if not paths:
|
|
print("FAIL no workflow files found; run this from the repo root.")
|
|
return 1
|
|
return check(paths, enforce_floor=full_scan)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|