open-standards/scripts/check-wai-release-quickstart.py
Transaction Science a511f1dee1 conform-output on toolchain-fixes: the twin and the verb-list test as both changes have them
With toolchain-fixes under it, clean-machine.sh has a contents step and checks WAI Studio in
wai dev --check. clean-machine.ps1, its twin, gains both, so check-wai-release-quickstart.py's
rule (the twin runs the script's steps and commands) holds. The check now lists the contents
step, and takes a step's wai command up to the first ; } ' | > or && after it, so the output
the dev-check step keeps is not part of its command. The twin's help range follows its
longer header. The new PowerShell code has not run: no host here has PowerShell, and the
Windows runner is down. The release workflow's comment and the changelog entry say so.

The catalogue test every_text_that_states_the_verb_list_states_the_same_list no longer looks
for the doors' check's own lists, which conform-output removed. It checks that the script
names catalogue.rs as its source and keeps no list, and that what its reader takes from this
file's source is exactly VERBS and NEAR_SYNONYMS. The README must record doctor as well as
upscale and tonemap. The definitions' openings are spelt in two pieces in the test, so that
catalogue.rs holds each once and the doors' check's self-test can change it in a copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 02:00:51 -05:00

263 lines
11 KiB
Python
Executable file

#!/usr/bin/env python3
"""check-wai-release-quickstart.py: the release runs the quickstart from each archive before it
attaches it, and the Windows twin keeps the script's steps.
wai-release.yml builds the quickstart archive on each platform and attaches it to the release.
Before it is attached, each archive runs the quickstart as a new user runs it, from the archive
alone: wai/quickstart/test/clean-machine.sh on macOS and Linux, its PowerShell twin
clean-machine.ps1 on Windows. Each step of the twin is a step of the script, with the same
`wai` command. This holds the three files to that:
- each of the jobs macos, linux and windows runs its script in a step's command (a comment or
a step's name that names it is not a run of it); that step has no `if:` and no
`continue-on-error:`, and neither has its job, so it can neither be skipped nor fail without
failing the job; and it comes before the job's first step that attaches anything to the
release (one that runs wai/quickstart/publish.sh or uploads to a release's assets);
- clean-machine.sh's steps are unpack, contents, hello, verify, create, dev-check and two-tabs
(the browser's), and clean-machine.ps1's are the same steps in the same order, the browser's
aside, each running the same `wai` command (the command alone, up to the first `;`, `}`,
`'`, `|`, `>` or `&&` after it);
- the unpack step of each checks the archive against the SHA-256 beside it ("$ARCHIVE.sha256",
"$Archive.sha256") and fails when they differ, before it unpacks anything.
check-wai-release-quickstart.py [--root DIR]
Exit 0 when all of it holds, 1 with one line a finding otherwise. Standard library only. The
selftest (check-wai-release-quickstart-selftest.sh) breaks each rule in a copy and requires a
finding.
"""
import os
import re
import sys
WORKFLOW = ".github/workflows/wai-release.yml"
SH = "wai/quickstart/test/clean-machine.sh"
PS = "wai/quickstart/test/clean-machine.ps1"
# The job, and the script it runs on its archive.
JOBS = [("macos", "clean-machine.sh"), ("linux", "clean-machine.sh"), ("windows", "clean-machine.ps1")]
SH_STEPS = ["unpack", "contents", "hello", "verify", "create", "dev-check", "two-tabs"]
BROWSER_STEP = "two-tabs"
def indent(line):
return len(line) - len(line.lstrip(" "))
def is_comment(line):
return line.strip().startswith("#")
def jobs(text):
"""{job name: its lines}, the jobs under the top-level `jobs:` key."""
out = {}
lines = text.splitlines()
try:
start = next(i for i, l in enumerate(lines) if l.rstrip() == "jobs:")
except StopIteration:
return out
name = None
for l in lines[start + 1:]:
if l.strip() and not is_comment(l) and indent(l) == 0:
break
m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", l)
if m:
name = m.group(1)
out[name] = []
elif name is not None:
out[name].append(l)
return out
def job_keys(lines):
"""The job's own keys (indented four spaces), comments aside."""
return [re.match(r"^\s*([A-Za-z0-9_-]+):", l).group(1) for l in lines if indent(l) == 4 and not is_comment(l) and re.match(r"^\s*([A-Za-z0-9_-]+):", l)]
def steps(lines):
"""The job's steps, in order: each {"keys": [its keys], "name": …, "run": [its command's
lines, comments aside]}. A step is a list item of `steps:`; its keys are the item's first
key and those at the indent of the item's content."""
out = []
in_steps = False
item_indent = None
cur = None
run_indent = None
for l in lines:
if not l.strip():
continue
if indent(l) == 4 and not is_comment(l):
in_steps = l.strip() == "steps:"
cur = None
continue
if not in_steps:
continue
s = l.lstrip(" ")
if s.startswith("- ") and not is_comment(l) and (item_indent is None or indent(l) == item_indent):
item_indent = indent(l)
cur = {"keys": [], "name": "", "run": []}
out.append(cur)
run_indent = None
l = " " * (item_indent + 2) + s[2:]
if cur is None:
continue
key_indent = item_indent + 2
if run_indent is not None:
if indent(l) > key_indent:
if not is_comment(l):
cur["run"].append(l.strip())
continue
run_indent = None
if is_comment(l) or indent(l) != key_indent:
continue
m = re.match(r"^\s*([A-Za-z0-9_-]+):\s*(.*)$", l)
if not m:
continue
key, value = m.group(1), m.group(2).strip()
cur["keys"].append(key)
if key == "name":
cur["name"] = value.strip("'\"")
elif key == "run":
if value in ("|", ">", "|-", ">-", "|+", ">+"):
run_indent = key_indent
elif value and not value.startswith("#"):
cur["run"].append(value)
return out
def runs_script(step, script):
"""Whether the step's command runs `script` (wai/quickstart/test/<script>, either slash)."""
want = (f"wai/quickstart/test/{script}", f"wai\\quickstart\\test\\{script}")
return any(w in l.split(" #")[0] for l in step["run"] for w in want)
def attaches(step):
"""Whether the step attaches something to the release."""
return any("wai/quickstart/publish.sh" in l or re.search(r"/assets\b", l) for l in step["run"])
def check_workflow(text, findings):
js = jobs(text)
for job, script in JOBS:
if job not in js:
findings.append(f"{WORKFLOW}: no job {job}, which runs {script} on the archive it attaches")
continue
where = f"{WORKFLOW}: the {job} job"
for k in ("if", "continue-on-error"):
if k in job_keys(js[job]):
findings.append(f"{where} has `{k}:`, so the quickstart from its archive can be skipped or fail without failing it")
st = steps(js[job])
running = [i for i, s in enumerate(st) if runs_script(s, script)]
if not running:
findings.append(f"{where} runs {script} in no step's command: the archive it attaches is not run as a new user runs it")
continue
attach = next((i for i, s in enumerate(st) if attaches(s)), None)
if attach is None:
findings.append(f"{where} has no step that attaches its archive to the release (wai/quickstart/publish.sh, or an upload to the release's assets)")
for i in running:
s = st[i]
what = f"{where}'s step {s['name']!r}, which runs {script},"
for k in ("if", "continue-on-error"):
if k in s["keys"]:
findings.append(f"{what} has `{k}:`: it can be skipped or fail without failing the job")
if attach is not None and i > attach:
findings.append(f"{what} comes after {st[attach]['name']!r}, which attaches the archive: the archive is attached before it is run")
def wai_command(rest):
"""The `wai` command in a step's text, alone: from `wai ` up to the first `;`, `}`, `'`,
`|`, `>` or `&&` after it (what the step does with its output is not the command); None
when the step runs no `wai` command."""
i = rest.find("wai ")
return re.split(r"[;}'|>]|&&", rest[i:])[0].strip() if i >= 0 else None
def sh_steps(text):
"""clean-machine.sh's steps: (name, the `wai` command it runs, or None)."""
out = []
for l in text.splitlines():
t = l.strip()
m = re.match(r"^(?:\[[^]]*\]\s*&&\s*)*step ([A-Za-z0-9-]+)(?: (.*))?$", t)
if not m:
continue
out.append((m.group(1), wai_command(m.group(2) or "")))
return out
def ps_steps(text):
"""clean-machine.ps1's steps: (name, the `wai` command its block runs, or None)."""
out = []
for l in text.splitlines():
m = re.search(r"\bStep '([A-Za-z0-9-]+)' \{(.*)$", l.strip())
if not m:
continue
out.append((m.group(1), wai_command(m.group(2))))
return out
def block(text, start_re):
"""The lines of the block that starts on the line matching `start_re` and ends at the next
line that is `}` alone at the start of a line."""
lines = text.splitlines()
for i, l in enumerate(lines):
if re.search(start_re, l):
for j in range(i + 1, len(lines)):
if lines[j].rstrip() == "}":
return lines[i + 1:j]
return lines[i + 1:]
return None
def check_unpack(name, body, sums, compare, unpack, findings):
"""The unpack step's body reads the SHA-256 from `sums`, fails when it differs (`compare`),
and does both before it unpacks (`unpack`)."""
if body is None:
findings.append(f"{name}: no unpack step")
return
code = [l.strip() for l in body if not l.strip().startswith("#")]
reads = next((i for i, l in enumerate(code) if sums in l), None)
fails = next((i for i, l in enumerate(code) if re.search(compare, l)), None)
opens = next((i for i, l in enumerate(code) if re.search(unpack, l)), None)
if reads is None:
findings.append(f"{name}: the unpack step does not read the SHA-256 beside the archive ({sums})")
if fails is None:
findings.append(f"{name}: the unpack step does not fail when the archive's SHA-256 is not the one beside it")
if opens is None:
findings.append(f"{name}: the unpack step unpacks nothing")
elif any(x is not None and x > opens for x in (reads, fails)):
findings.append(f"{name}: the unpack step unpacks the archive before it checks its SHA-256")
def check_scripts(sh, ps, findings):
s, p = sh_steps(sh), ps_steps(ps)
names = [n for n, _ in s]
if names != SH_STEPS:
findings.append(f"{SH}: its steps are {names}, not {SH_STEPS}")
want = [x for x in s if x[0] != BROWSER_STEP]
if p != want:
findings.append(f"{PS}: its steps are {p}; the twin runs clean-machine.sh's, the browser's aside: {want}")
check_unpack(SH, block(sh, r"^unpack\(\)\s*\{"), '"$ARCHIVE.sha256"', r'\[ "\$got" = "\$want" \] \|\| \{.*\breturn 1', r"\btar -x", findings)
check_unpack(PS, block(ps, r"^Step 'unpack' \{"), '"$Archive.sha256"', r"if \(\$got -ne \$want\) \{\s*throw\b", r"\bExpand-Archive\b|\btar -x", findings)
def main(argv):
root = "."
if argv[:1] == ["--root"] and len(argv) == 2:
root = argv[1]
elif argv:
print("usage: check-wai-release-quickstart.py [--root DIR]", file=sys.stderr)
return 2
read = lambda p: open(os.path.join(root, p), encoding="utf-8").read()
findings = []
check_workflow(read(WORKFLOW), findings)
check_scripts(read(SH), read(PS), findings)
for f in findings:
print(f"FAIL {f}")
if not findings:
print(f"ok {WORKFLOW}: the macos, linux and windows jobs run the quickstart from the archive before attaching it")
print(f"ok {PS} keeps {SH}'s steps, and both check the archive's SHA-256 before unpacking it")
print(f"check-wai-release-quickstart: {len(findings)} finding(s)")
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))