Merge commit '08ad7b8136baa8d1d26f532ab18a5f78bdfefb61' into docs/sboz-stable-identities

This commit is contained in:
Edward Langley
2026-09-24 12:43:42 -07:00
67 changed files with 12973 additions and 972 deletions
+5 -5
View File
@@ -13,8 +13,8 @@ exit 0 every cited id is a ticket bd lists
exit 1 at least one cited id is not; each is printed
exit 3 bd could not answer: missing, failing, or listing nothing
exit 4 usage error, not inside a git repository, or a cited file
could not be read or is absent (only a file `git ls-files -d`
reports as deleted is skipped)
could not be read or is absent (only a file `git diff
--diff-filter=D` reports as deleted is skipped)
The comparison is POSITIVE: a cited id passes only by appearing in what
`bd list --all` returns. The recipe this replaced looked for bd's "not found"
@@ -43,13 +43,13 @@ top=$(git rev-parse --show-toplevel 2>/dev/null) || {
exit 4
}
cd "$top"
# A deleted tracked file is skipped ON PURPOSE: its ids are leaving the tree.
# Only `ls-files -d` says deleted; any other absent path is exit 4 (lqs0, tdfd).
# A deleted tracked file is skipped ON PURPOSE: its ids are leaving the tree. Only
# `diff --diff-filter=D` says deleted; any other absent path is exit 4 (tdfd, cota).
: > "$work/absent"
git ls-files -z --cached --others --exclude-standard -- "${ROOTS[@]}" |
while IFS= read -r -d '' f; do
if [ -e "$f" ] || [ -L "$f" ]; then printf '%s\0' "$f"
elif [ -z "$(git --literal-pathspecs ls-files -d -- "$f")" ]; then
elif [ -z "$(git --literal-pathspecs diff --name-only --diff-filter=D -- "$f")" ]; then
printf '%s\n' "$f" >> "$work/absent"
fi
done > "$work/files"
+12
View File
@@ -132,6 +132,18 @@ git -C "$r2" update-index --no-skip-worktree context/b.md
&& ok "a skip-worktree file missing from the tree is exit 4, and named" \
|| bad "a skip-worktree file missing from the tree is exit 4, and named" "exit $got: $(head -c 200 "$work/out")"
# ls-files -d DOES list an absent assume-unchanged path, yet status hides it and
# commit -a never records the deletion (improvise-cota).
git -C "$r2" update-index --assume-unchanged context/b.md
mv "$r2/context/b.md" "$work/b.md.away"
(cd "$r2" && "$AUDIT") > "$work/out" 2>&1
got=$?
mv "$work/b.md.away" "$r2/context/b.md"
git -C "$r2" update-index --no-assume-unchanged context/b.md
[ "$got" = 4 ] && grep -q 'context/b.md' "$work/out" \
&& ok "an assume-unchanged file missing from the tree is exit 4, and named" \
|| bad "an assume-unchanged file missing from the tree is exit 4, and named" "exit $got: $(head -c 200 "$work/out")"
mkdir -p "$work/not-a-repo"
(cd "$work/not-a-repo" && GIT_CEILING_DIRECTORIES="$work" "$AUDIT") > "$work/out" 2>&1
got=$?
+648 -6
View File
@@ -6,16 +6,26 @@ README name three of seven while the rest read as ungated (improvise-x7cq).
Stating it was not enough -- a new job was added during review of PR #83 and
nothing noticed, so the rule is a gate. See context/code/tooling.md.
Second rule: every suite `scripts/checkers.sh --list` knows must run in some
CI step. ci.yml names its suites by hand, so a new `*-test.sh` ran locally and
never in CI until someone remembered to add it (improvise-dak5). Each job runs
`checkers.sh --ci <job>`, whose table lives in checkers.sh; no shell is parsed
here, and what a row runs is asked of `--would-run` (improvise-maax, st8q).
`--self-test` checks the checker: each case is a graph this must refuse.
"""
import os
import re
import subprocess
import sys
import tempfile
WORKFLOW = ".github/workflows/ci.yml"
DEPLOY = "pages-deploy"
_JOB = re.compile(r"^ ([A-Za-z0-9_-]+):$")
_JOB_OPEN = re.compile(r"^ ([A-Za-z0-9_-]+):(\s|$)")
_NEEDS = re.compile(r"^ needs:\s*\[(.*)\]\s*$")
@@ -24,7 +34,9 @@ def read_graph(text):
Both come off indentation, which is what a workflow's own shape is. A job
key is two spaces; `needs:` is four INSIDE the job it belongs to, so the
current job is tracked rather than the first `needs:` taken.
current job is tracked rather than the first `needs:` taken. A job opens on
`_JOB_OPEN`, wider than `_JOB`: this reader refuses nothing, so it reads wide
(improvise-j49r).
"""
jobs, needs, current = [], None, None
in_jobs = False
@@ -34,7 +46,7 @@ def read_graph(text):
continue
if not in_jobs:
continue
m = _JOB.match(raw)
m = _JOB_OPEN.match(raw)
if m:
current = m.group(1)
jobs.append(current)
@@ -60,6 +72,611 @@ def verdict(jobs, needs):
return out
_JOB_KEY = re.compile(r"^ ([A-Za-z_][\w-]*):(.*)$")
_STEP_START = re.compile(r"^ - ")
_STEP_KEY = re.compile(r"^ (?:- | )([A-Za-z_][\w-]*):(.*)$")
_ANY_KEY = re.compile(r"^(\s*)(?:- )?([A-Za-z_][\w-]*):(.*)$")
_CI_CALL = re.compile(r"^(?:nix shell \S+ --command )?\./scripts/checkers\.sh --ci ([\w-]+)$")
_DISQUALIFY = ("if", "continue-on-error", "shell")
_NAME = r"[A-Za-z_][\w-]*"
_TOP_KEY = re.compile(rf"^{_NAME}:(\s|$)")
_NEEDS_VALUE = re.compile(rf"^(|{_NAME}|\[\s*{_NAME}(\s*,\s*{_NAME})*\s*\])$")
_NEEDS_ITEM = re.compile(rf"^ - {_NAME}$")
_COMMENT = re.compile(r"\s#")
_UNSAFE_CHAR = re.compile("[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029]")
def _readable(line, indent, in_steps, in_needs, job_keyed):
"""Whether a structural line inside `jobs:` is one this gate can read. Anything
else is refused, not parsed: context/code/tooling.md (improvise-w0j4, rjaa)."""
if indent == 2:
return bool(_JOB.match(line))
if indent == 4:
m = _JOB_KEY.match(line)
return bool(m) and (m.group(1) != "needs"
or bool(_NEEDS_VALUE.match(_inline_value(m.group(2)))))
if in_needs:
return bool(_NEEDS_ITEM.match(line))
if in_steps:
return indent in (6, 8) and bool(_STEP_KEY.match(line))
return indent > 4 and job_keyed
def _inline_value(rest):
"""A key's inline value. No comment is stripped: a line carrying one is
refused, so every matcher reads the same string (improvise-j49r)."""
return rest.strip()
_FLOW_LINE = re.compile(r"""^(\[[^\[\]{}"'#]*\]|\{[^\[\]{}"'#]*\})$""")
_BLOCK_HEADER = re.compile(r"^[|>][-+0-9]*$")
def _plain_value(value):
"""Whether a value is one of the few shapes the gate reads, all ending on their own
line; anything else is refused: context/code/tooling.md (improvise-0wn3)."""
if value == "" or _BLOCK_HEADER.match(value) or _FLOW_LINE.match(value):
return True
if value[0] in "\"'":
return not _opens_quote(value)
return value[0] not in "[]{}&*!|>%@`?,"
def _line_value(raw):
"""The value a line carries: a key's, a list item's, or the whole line's."""
k = _ANY_KEY.match(raw)
if k:
return _inline_value(k.group(3))
body = raw.strip()
return body[2:].strip() if body.startswith("- ") else body
def _opens_quote(value):
"""True when a quoted inline value is not closed on its own line."""
if value[:1] not in ("'", '"') or len(value) < 2:
return value[:1] in ("'", '"')
q, body = value[0], value[1:]
if q == "'":
return not body.replace("''", "").endswith("'")
return not re.sub(r'\\.', "", body).endswith('"')
def ci_steps(text):
"""(job, value, disqualifiers) for every step of every job, and every line
mentioning scripts/checkers.sh that is not a step's whole `run:` value.
Nothing is read as shell. A key with an inline value owns every following
line indented deeper, which is how a block scalar, a folded value, a plain
continuation or a heredoc inside one is skipped whole (improvise-st8q)."""
steps, stray, unread = [], [], []
job, in_steps, job_flags, step = None, False, set(), None
in_jobs, job_keyed = False, False
owner, owner_step = None, None
needs, if_jobs, in_needs = {}, set(), False
def close_step():
if step is not None:
steps.append(step)
for n, raw in enumerate(text.split("\n"), 1):
if _UNSAFE_CHAR.search(raw):
unread.append((n, "char"))
continue
indent = len(raw) - len(raw.lstrip())
if owner is not None and (not raw.strip() or indent > owner):
if "scripts/checkers.sh" in raw:
stray.append(n)
if owner_step is not None and raw.strip():
owner_step["multiline"] = True
continue
owner, owner_step = None, None
if not raw.strip() or raw.lstrip().startswith("#"):
continue
if indent == 0:
in_jobs = raw.startswith("jobs:")
readable = bool(_TOP_KEY.match(raw))
else:
readable = not in_jobs or indent > 8 or _readable(raw, indent, in_steps,
in_needs, job_keyed)
k = _ANY_KEY.match(raw)
shaped = _plain_value(_line_value(raw))
if _COMMENT.search(raw) or not shaped or not readable:
unread.append((n, "comment" if _COMMENT.search(raw) else
"plain" if not readable else "value"))
m = _JOB.match(raw)
if m:
close_step()
step = None
job, in_steps, job_flags, in_needs = m.group(1), False, set(), False
job_keyed = False
continue
if in_needs and re.match(r"^ - ", raw):
needs[job].add(_inline_value(raw.strip()[2:]))
continue
m = _JOB_KEY.match(raw)
if m and job is not None:
job_keyed = True
close_step()
step = None
key, value = m.group(1), _inline_value(m.group(2))
in_steps = key == "steps" and value == ""
in_needs = key == "needs" and value == ""
if key == "needs":
needs[job] = {x.strip() for x in value.strip("[]").split(",") if x.strip()}
if key == "if":
if_jobs.add(job)
if key in _DISQUALIFY:
job_flags.add(key)
for s in steps:
if s["job"] == job:
s["flags"].add("job " + key)
if in_steps and _STEP_START.match(raw):
close_step()
step = {"job": job, "run": None, "flags": {"job " + f for f in job_flags},
"line": n}
m = _STEP_KEY.match(raw) if in_steps and step is not None else None
if m:
key, value = m.group(1), _inline_value(m.group(2))
if key == "run":
step["run"] = value
if "scripts/checkers.sh" in raw and not _CI_CALL.match(value):
stray.append(n)
elif key in _DISQUALIFY:
step["flags"].add(key)
elif "scripts/checkers.sh" in raw:
stray.append(n)
elif "scripts/checkers.sh" in raw:
stray.append(n)
if k and _inline_value(k.group(3)):
owner = len(k.group(1)) + (2 if raw.lstrip().startswith("- ") else 0)
owner_step = step if m and m.group(1) == "run" else None
close_step()
for s in steps:
s["flags"] |= {f"needs {j}, which has if" for j in _needed(s["job"], needs) & if_jobs}
return steps, stray, unread
def _needed(job, needs):
"""Every job `job` needs, transitively: a skipped one skips it too (improvise-bbwa)."""
seen, todo = set(), list(needs.get(job, ()))
while todo:
j = todo.pop()
if j not in seen:
seen.add(j)
todo += needs.get(j, ())
return seen
def would_run(args):
"""What checkers.sh itself says these arguments run. Its argument rules and
its CI table are not restated here (improvise-maax)."""
out = subprocess.run(["scripts/checkers.sh", "--would-run", *args],
capture_output=True, text=True).stdout
ran, missing = set(), set()
for line in out.split("\n"):
if line.startswith("would-run: "):
ran.add(line[len("would-run: "):])
elif line.startswith("missing: "):
missing.add(line[len("missing: "):])
return ran, missing
def _unrun(s):
return (f"`{s}` is a checkers.sh suite that no CI step runs; add it to a row of "
"ci_suites in scripts/checkers.sh")
def _unread(n, kind="plain"):
if kind == "char":
return (f"ci.yml line {n} holds a control character or a line break YAML sees and "
"this gate does not (U+0085, U+2028, U+2029); remove it")
if kind == "value":
return (f"ci.yml line {n} has a value that is not one plain line (a multi-line "
"quote or flow, a nested or quoted flow, an anchor, alias or tag); spell "
"it plainly")
if kind == "comment":
return (f"ci.yml line {n} has a ` #`, which YAML may read as a comment; this gate "
"reads no comments, so put it on a line of its own")
return (f"ci.yml line {n} is a key or `needs:` value this gate cannot read as a "
"plain name, so it cannot trust the graph; spell it plainly")
def suite_verdict(suites, text):
"""The complaints: suites no counted step runs, and every step or line that
does not count, each named so the fix is obvious rather than silent."""
steps, stray, unread = ci_steps(text)
out, ran, missing = [_unread(n, c) for n, c in unread], set(), set()
for s in steps:
m = _CI_CALL.match(s["run"] or "")
if not m:
continue
if m.group(1) != s["job"]:
out.append(f"job `{s['job']}` runs `checkers.sh --ci {m.group(1)}`; "
"a job runs only its own row of the CI table")
elif s.get("multiline"):
out.append(f"job `{s['job']}`'s `checkers.sh --ci` step's run: continues "
"onto another line, so it does not count")
elif s["flags"]:
out.append(f"job `{s['job']}`'s `checkers.sh --ci` step is conditional "
f"({', '.join(sorted(s['flags']))}), so it does not count")
else:
r, mi = would_run(["--ci", s["job"]])
ran |= r
missing |= mi
out += [f"ci.yml line {n} mentions scripts/checkers.sh outside a step whose "
"whole `run:` is `./scripts/checkers.sh --ci <job>`" for n in stray]
out += [_unrun(s) for s in sorted(set(suites) - ran)]
out += [f"checkers.sh's CI table names `{s}`, which is not a suite"
for s in sorted(missing)]
return out
def read_workflow(path):
"""The workflow's text. Universal newlines fold CR and CRLF into the one break the
gate splits on; every other break YAML knows is refused (improvise-xpzh)."""
return open(path, encoding="utf8").read()
def list_suites():
"""The suites, from checkers.sh itself, so its naming rule has one home."""
out = subprocess.run(["scripts/checkers.sh", "--list"], capture_output=True,
text=True, check=True).stdout
return [s for s in out.split("\n") if s.strip()]
def _wf(*jobs):
return "jobs:\n" + "".join(jobs)
def _job(name, *steps, head=""):
return f" {name}:\n runs-on: x\n{head} steps:\n" + "".join(steps)
_BUILD = _job("build", " - run: ./scripts/checkers.sh --ci build\n")
_REQ = ["assemble-stamp", "pr-watch"]
_NO_PW = [_unrun("pr-watch")]
def _stray(n):
return (f"ci.yml line {n} mentions scripts/checkers.sh outside a step whose "
"whole `run:` is `./scripts/checkers.sh --ci <job>`")
_GATE = " gate:\n if: false\n steps:\n - run: echo\n"
_KB_RUN = " - run: ./scripts/checkers.sh --ci kb\n"
_YAML_ONLY_BREAKS = ("\x85", "\u2028", "\u2029")
_INNER_RUN = " run: ./scripts/checkers.sh --ci kb\n"
_NEEDS_GATE = ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), "
"so it does not count"] + _NO_PW
def _unreadable(name, text, *markers, rest=(), kind="plain"):
"""A case whose workflow spells a key or a `needs:` value the gate must refuse."""
lines = text.split("\n")
at = [next(i for i, l in enumerate(lines, 1) if m in l) for m in markers]
return (name, _REQ, text, [_unread(n, kind) for n in at] + list(rest))
# (name, suites to require, workflow text, the complaints it must draw). Every
# case asks the REAL checkers.sh table, where `kb`'s row holds pr-watch.
SUITE_SELF_TEST = [
("two jobs each running their own row are accepted", _REQ,
_wf(_BUILD, _job("kb", " - run: nix shell nixpkgs#trealla --command "
"./scripts/checkers.sh --ci kb\n")), []),
_unreadable("a trailing comment on the counted run: line is refused (j49r)",
_wf(_BUILD, _job("kb", _KB_RUN.rstrip("\n") + " # the kb row\n")),
"# the kb row", rest=[_stray(9)] + _NO_PW, kind="comment"),
("a job that never calls its row leaves its suites unrun", _REQ,
_wf(_BUILD, _job("kb", " - run: echo nothing\n")), _NO_PW),
("a call inside a heredoc in a block run: does not count (st8q)", _REQ,
_wf(_BUILD, _job("kb", " - run: |\n cat >> x <<'EOF'\n"
" ./scripts/checkers.sh --ci kb\n EOF\n")),
[_stray(11)] + _NO_PW),
("a folded run: does not count (st8q)", _REQ,
_wf(_BUILD, _job("kb", " - run: >\n ./scripts/checkers.sh --ci kb\n")),
[_stray(10)] + _NO_PW),
("a plain continuation line does not count", _REQ,
_wf(_BUILD, _job("kb", " - run: echo\n ./scripts/checkers.sh --ci kb\n")),
[_stray(10)] + _NO_PW),
_unreadable("a run: line inside a multi-line quoted value: the quote is refused (6zf1)",
_wf(_BUILD, _job("kb", " - name: \"a\n"
" run: ./scripts/checkers.sh --ci kb\n b\"\n")),
'- name: "a', kind="value", rest=[_unread(11)]),
("a whole job inside a top-level block scalar does not count", _REQ,
_wf(_BUILD) + "notes: |\n kb:\n steps:\n"
" - run: ./scripts/checkers.sh --ci kb\n",
[_stray(9)] + _NO_PW),
("a step-shaped line inside a job-level block scalar does not count", _REQ,
_wf(_BUILD, _job("kb", " - run: echo\n",
head=" notes: |\n - run: ./scripts/checkers.sh --ci kb\n")),
[_stray(9)] + _NO_PW),
("a step-shaped item under a job key other than steps: does not count", _REQ,
_wf(_BUILD, " kb:\n services:\n - run: ./scripts/checkers.sh --ci kb\n"
" steps:\n - run: echo\n"),
[_stray(8)] + _NO_PW),
("the call with a continuation line does not count (1be2)", _REQ,
_wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n"
" || true\n")),
["job `kb`'s `checkers.sh --ci` step's run: continues onto another line, "
"so it does not count"] + _NO_PW),
("a job that needs a job under if: does not count (bbwa)", _REQ,
_wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n",
_job("kb", " - run: ./scripts/checkers.sh --ci kb\n",
head=" needs: [build, gate]\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), "
"so it does not count"] + _NO_PW),
("the same, with needs: written as a list", _REQ,
_wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n",
_job("kb", " - run: ./scripts/checkers.sh --ci kb\n",
head=" needs:\n - gate\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), "
"so it does not count"] + _NO_PW),
("the 1be2 continuation exactly one column past the key does not count (w0j4)", _REQ,
_wf(_BUILD, _job("kb", _KB_RUN + " || true\n")),
["job `kb`'s `checkers.sh --ci` step's run: continues onto another line, "
"so it does not count"] + _NO_PW),
("control: a padded flow list is still read (w0j4)", _REQ,
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: [ gate ]\n")),
_NEEDS_GATE),
("control: a plain scalar needs: is still read (w0j4)", _REQ,
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n")),
_NEEDS_GATE),
_unreadable("a quoted name in a flow needs: is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=' needs: ["gate"]\n')),
'needs: ["gate"]'),
_unreadable("a quoted scalar needs: is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: 'gate'\n")),
"needs: 'gate'"),
_unreadable("a quoted item in a block needs: is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=' needs:\n - "gate"\n')),
'- "gate"'),
_unreadable("an anchored flow needs: is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: &d [gate]\n")),
"needs: &d"),
_unreadable("an anchored block needs: is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: &d\n - gate\n")),
"needs: &d"),
_unreadable("a flow needs: over two lines is refused (w0j4)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: [build,\n gate]\n")),
"needs: [build,"),
_unreadable("a quoted job key is refused (w0j4)",
_wf(_BUILD, _job("kb", _KB_RUN, head=' "if": false\n')),
'"if"'),
_unreadable("a quoted step key is refused (w0j4)",
_wf(_BUILD, _job("kb", " - \"if\": false\n"
" run: ./scripts/checkers.sh --ci kb\n")),
'"if"'),
_unreadable("a single-quoted continue-on-error is refused (w0j4)",
_wf(_BUILD, _job("kb", _KB_RUN, head=" 'continue-on-error': true\n")),
"'continue-on-error'"),
_unreadable("a key with a space before its colon is refused (w0j4)",
_wf(_BUILD, _job("kb", _KB_RUN, head=" if : false\n")),
"if : false"),
_unreadable("a quoted job name is refused (w0j4)",
_wf(_BUILD, ' "gate":\n if: false\n steps:\n - run: echo\n',
_job("kb", _KB_RUN)),
'"gate"',
rest=["job `build`'s `checkers.sh --ci` step is conditional (job if), "
"so it does not count", _unrun("assemble-stamp")]),
_unreadable("a quoted top-level key is refused (w0j4)",
'"defaults":\n run:\n shell: bash\n' + _wf(_BUILD, _job("kb", _KB_RUN)),
'"defaults"'),
("control: a job key's block content at indent 6 is content (rjaa)", _REQ,
_wf(_BUILD, _job("kb", _KB_RUN, head=" env:\n FOO: bar\n")), []),
_unreadable("a needed job whose keys sit at indent 6 is refused (rjaa)",
_wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n",
_job("kb", _KB_RUN, head=" needs: gate\n")),
" if: false", " steps:", " - run: echo"),
_unreadable("a needed job whose keys sit at indent 5 is refused (rjaa)",
_wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n",
_job("kb", _KB_RUN, head=" needs: [gate]\n")),
" if: false", " steps:", " - run: echo"),
_unreadable("a job between, with its needs: at indent 6, is refused (rjaa)",
_wf(_BUILD, _GATE,
" mid:\n needs: gate\n steps:\n - run: echo\n",
_job("kb", _KB_RUN, head=" needs: mid\n")),
" needs: gate", " steps:", " - run: echo"),
_unreadable("an explicit ? key is refused (w0j4)",
_wf(_BUILD, _job("kb", _KB_RUN, head=" ? if\n : false\n")),
"? if", ": false"),
("the same, two needs: away", _REQ,
_wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n",
" mid:\n needs: gate\n steps:\n - run: echo\n",
_job("kb", " - run: ./scripts/checkers.sh --ci kb\n",
head=" needs: [mid]\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), "
"so it does not count"] + _NO_PW),
("a step under if: does not count (tvte)", _REQ,
_wf(_BUILD, _job("kb", " - if: false\n run: ./scripts/checkers.sh --ci kb\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (if), so it does not count"]
+ _NO_PW),
("a job under if: does not count (tvte)", _REQ,
_wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n",
head=" if: false\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (job if), so it does not count"]
+ _NO_PW),
("a job-level if: after its steps still disqualifies them", _REQ,
_wf(_BUILD, " kb:\n steps:\n - run: ./scripts/checkers.sh --ci kb\n"
" if: false\n"),
["job `kb`'s `checkers.sh --ci` step is conditional (job if), so it does not count"]
+ _NO_PW),
("a step under a defaults: run: shell: is counted (the user's ruling)", _REQ,
"defaults:\n run:\n shell: nix develop .#ci --command bash -e {0}\n"
+ _wf(_BUILD, _job("kb", _KB_RUN, head=" defaults:\n run:\n shell: bash\n")),
[]),
("continue-on-error does not count", _REQ,
_wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n"
" continue-on-error: true\n")),
["job `kb`'s `checkers.sh --ci` step is conditional (continue-on-error), "
"so it does not count"] + _NO_PW),
("shell control flow around the call does not count", _REQ,
_wf(_BUILD, _job("kb", " - run: true || ./scripts/checkers.sh --ci kb\n")),
[_stray(9)] + _NO_PW),
("a job running another job's row is refused", _REQ,
_wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci build\n")),
["job `kb` runs `checkers.sh --ci build`; a job runs only its own row of the CI table"]
+ _NO_PW),
("a bare mention in a step name is refused", _REQ,
_wf(_BUILD, _job("kb", " - name: runs scripts/checkers.sh\n"
" run: ./scripts/checkers.sh --ci kb\n")),
[_stray(9)]),
_unreadable("a job header with a trailing comment is refused (j49r)",
_wf(_BUILD, " test:\n runs-on: x\n",
" gate: # skipped on purpose\n if: false\n steps:\n - run: echo\n",
_job("kb", _KB_RUN, head=" needs: gate\n")),
"gate: # skipped", kind="comment"),
_unreadable("a closed quote followed by a comment is refused (j49r)",
_wf(_BUILD, _job("kb", _KB_RUN, head=" env:\n A: \"x\" # note\n")
+ " if: false\n", " after:\n steps:\n - run: echo \"z\"\n"),
'"x" # note', kind="comment",
rest=["job `kb`'s `checkers.sh --ci` step is conditional (job if), "
"so it does not count"] + _NO_PW),
_unreadable("a # inside a quoted value, below indent 8, is refused (j49r)",
_wf(_BUILD, _job("kb", _KB_RUN + " - uses: x\n with:\n"
" foo: \"x # y\"\n")
+ " if: false\n", " after:\n steps:\n - run: echo \"z\"\n"),
'"x # y"', kind="comment",
rest=["job `kb`'s `checkers.sh --ci` step is conditional (job if), "
"so it does not count"] + _NO_PW),
_unreadable("a trailing comment on a step is refused (j49r)",
_wf(_BUILD, _job("kb", " - uses: actions/checkout@v7 # pin\n" + _KB_RUN)),
"# pin", kind="comment"),
_unreadable("a one-line run: with a ` #` inside quotes is refused (j49r)",
_wf(_BUILD, _job("kb", " - run: echo \"a # b\"\n" + _KB_RUN)),
'"a # b"', kind="comment"),
_unreadable("a multi-line double-quoted value is refused, escaped quote and all (6zf1)",
_wf(_BUILD, " gate:\n runs-on: x\n name: \"a\n it\\\" is\n b: \"\n"
" if: false\n steps:\n - run: echo \"z\"\n",
_job("kb", _KB_RUN, head=" needs: gate\n")),
'name: "a', kind="value", rest=_NEEDS_GATE),
_unreadable("a multi-line single-quoted value is refused, doubled quote and all (6zf1)",
_wf(_BUILD, " gate:\n runs-on: x\n name: 'a\n it''s\n b: '\n"
" if: false\n steps:\n - run: echo 'z'\n",
_job("kb", _KB_RUN, head=" needs: gate\n")),
"name: 'a", kind="value", rest=_NEEDS_GATE),
_unreadable("an opening line ending in an escaped \\\" still opens a quote (6zf1)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n name: \"a\\\"\n"
" needs: ok\n x: y\"\n")),
'name: "a', kind="value"),
_unreadable("an opening line ending in a doubled '' still opens a quote (6zf1)",
_wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n name: 'it''\n"
" needs: ok\n x: y'\n")),
"name: 'it", kind="value"),
_unreadable("a flow list whose quote runs onto the next lines is refused (0wn3)",
_wf(_BUILD, _job("kb", " - name: [a, \"b\n" + _INNER_RUN + " c: x\"]\n")),
'name: [a, "b', kind="value"),
_unreadable("the same with a single-quoted string (0wn3)",
_wf(_BUILD, _job("kb", " - name: [a, 'b\n" + _INNER_RUN + " c: x']\n")),
"name: [a, 'b", kind="value"),
_unreadable("the same as a flow map (0wn3)",
_wf(_BUILD, _job("kb", " - name: {a: \"b\n" + _INNER_RUN + " c: x\"}\n")),
'name: {a: "b', kind="value"),
_unreadable("a flow list that ends in its closer with a quote still open is refused (0wn3)",
_wf(_BUILD, _job("kb", " - name: [a, \"b]\n" + _INNER_RUN + " c: x\"]\n")),
'name: [a, "b]', kind="value"),
_unreadable("a flow value opened on a line of its own is refused too (0wn3)",
_wf(_BUILD, _job("kb", " - name:\n [a, \"b\n" + _INNER_RUN
+ " c: x\"]\n")),
'[a, "b', kind="value"),
_unreadable("an anchored multi-line flow is refused (0wn3)",
_wf(_BUILD, _job("kb", " - name: &a [x, \"b\n" + _INNER_RUN + " c: x\"]\n")),
'name: &a', kind="value"),
_unreadable("a tagged multi-line flow is refused (0wn3)",
_wf(_BUILD, _job("kb", " - name: !!seq [x, \"b\n" + _INNER_RUN
+ " c: x\"]\n")),
'name: !!seq', kind="value"),
*[_unreadable(f"a U+{ord(sep):04X} inside a line is refused (xpzh)",
_wf(_BUILD, " kb:\n runs-on: x" + sep + " if: false\n steps:\n" + _KB_RUN),
sep, kind="char")
for sep in _YAML_ONLY_BREAKS],
_unreadable("a line break inside a block scalar's body is refused too (xpzh)",
_wf(_BUILD, _job("kb", _KB_RUN + " - run: |\n echo a\u2028 if: false\n")),
"echo a", kind="char"),
_unreadable("a line break inside a full-line comment is refused too (xpzh)",
_wf(_BUILD, " kb:\n runs-on: x\n # note\u2028 if: false\n steps:\n"
+ _KB_RUN), "# note", kind="char"),
("a YAML comment mentioning checkers.sh is fine", _REQ,
_wf(_BUILD, _job("kb", " # every suite goes through scripts/checkers.sh\n"
" - run: ./scripts/checkers.sh --ci kb\n")), []),
]
# (name, file bytes as text, the complaints it must draw), read back through
# read_workflow, because what a CR means depends on that read (improvise-xpzh).
FILE_SELF_TEST = [
("control: CRLF line ends are folded by the read and accepted (xpzh)",
_wf(_BUILD, _job("kb", _KB_RUN)).replace("\n", "\r\n"), []),
("control: a lone CR is a line break, as YAML reads it (xpzh)",
_wf(_BUILD, " kb:\n runs-on: x\r if: false\n steps:\n" + _KB_RUN),
["job `kb`'s `checkers.sh --ci` step is conditional (job if), so it does not count"]
+ _NO_PW),
]
# (name, workflow text, the complaints it must draw).
SELF_TEST = [
("a job nobody gates is named",
@@ -79,6 +696,11 @@ SELF_TEST = [
"jobs:\n test:\n x: 1\n pages-deploy:\n x: 1\n",
[f"{DEPLOY} has no `needs:` list, so nothing gates the deploy"]),
("a job header with a trailing comment is still a job (j49r)",
"jobs:\n test:\n x: 1\n gate: # skipped on purpose\n x: 1\n"
" pages-deploy:\n needs: [test]\n",
[f"{DEPLOY} does not need `gate`, so a failing gate cannot stop a deploy"]),
# `needs:` belongs to the job it is indented under. Taking the first one
# seen would read ANOTHER job's list as the deploy's and pass.
("another job's needs is not mistaken for the deploy's",
@@ -95,28 +717,48 @@ def self_test():
got = verdict(jobs, needs)
if got != want:
failures.append(f" {name}: expected {want}, saw {got}")
for name, suites, text, want in SUITE_SELF_TEST:
got = suite_verdict(suites, text)
if got != want:
failures.append(f" {name}: expected {want}, saw {got}")
for name, content, want in FILE_SELF_TEST:
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "ci.yml")
with open(path, "w", encoding="utf8", newline="") as f:
f.write(content)
got = suite_verdict(_REQ, read_workflow(path))
if got != want:
failures.append(f" {name}: expected {want}, saw {got}")
if failures:
print("ci-graph self-test FAILED:", file=sys.stderr)
print("\n".join(failures), file=sys.stderr)
return 1
print(f"ci-graph self-test: {len(SELF_TEST)} graphs judged")
print(f"ci-graph self-test: {len(SELF_TEST)} graphs, "
f"{len(SUITE_SELF_TEST)} suite lists and {len(FILE_SELF_TEST)} files judged")
return 0
def main():
if "--self-test" in sys.argv[1:]:
return self_test()
jobs, needs = read_graph(open(WORKFLOW, encoding="utf8").read())
text = read_workflow(WORKFLOW)
jobs, needs = read_graph(text)
if len(jobs) < 2:
print(f"error: {WORKFLOW}: found {len(jobs)} jobs; the parse is wrong",
file=sys.stderr)
return 2
complaints = verdict(jobs, needs)
suites = list_suites()
if not suites:
print("error: checkers.sh --list named no suites; the listing is wrong",
file=sys.stderr)
return 2
complaints = verdict(jobs, needs) + suite_verdict(suites, text)
if complaints:
for c in complaints:
print(f"ci-graph: {c}", file=sys.stderr)
return 1
print(f"ci-graph: {DEPLOY} needs all {len(jobs) - 1} other jobs")
print(f"ci-graph: {DEPLOY} needs all {len(jobs) - 1} other jobs; "
f"all {len(suites)} checkers.sh suites run in CI")
return 0
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# check_the_checker for scripts/check-harness-lock.sh (improvise-zs2r). The
# fixtures are path-only crates, so every case runs offline with no cache.
set -uo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUN="$here/check-harness-lock.sh"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
pass=0; fail=0
ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' FAIL %s (exit %s, wanted %s)\n' "$1" "$2" "$3"; fail=$((fail+1)); }
# $1 name, $2 expected exit, $3 expected stderr substring ("" for none), rest: args
run_case() {
local name="$1" want="$2" says="$3"; shift 3
local err
err="$("$RUN" "$@" 2>&1 >/dev/null)"
local got=$?
if [ "$got" != "$want" ]; then
bad "$name" "$got" "$want"
printf '%s\n' "$err" | sed 's/^/ /'
elif [ -n "$says" ] && [[ "$err" != *"$says"* ]]; then
bad "$name: stderr did not say $says" "$got" "$want"
else
ok "$name"
fi
}
# $1 dir, $2 crate name, $3 extra [dependencies] lines, $4 "workspace" or ""
crate() {
mkdir -p "$1/src"
: > "$1/src/lib.rs"
{
printf '[package]\nname = "%s"\nversion = "0.0.0"\nedition = "2021"\n' "$2"
[ "${4:-}" = workspace ] && printf '\n[workspace]\n'
printf '\n[dependencies]\n%s' "$3"
} > "$1/Cargo.toml"
}
# A two-crate workspace shaped like the harness: `app` is its own root and
# reaches `lib` by path, so `lib`'s manifest is one the lock resolves against.
fixture() {
local root="$1"
crate "$root/lib" lib ""
crate "$root/extra" extra ""
crate "$root/app" app 'lib = { path = "../lib" }
' workspace
cargo generate-lockfile --offline --manifest-path "$root/app/Cargo.toml" 2>/dev/null
}
fixture "$work/current"
run_case "a current lock passes" 0 "" "$work/current/app/Cargo.toml"
# The shape improvise-zs2r names: only a PATH DEPENDENCY's manifest changed.
fixture "$work/stale"
crate "$work/stale/lib" lib 'extra = { path = "../extra" }
'
run_case "a manifest-only change to a path dependency is stale" 1 "STALE" \
"$work/stale/app/Cargo.toml"
# Offline and unresolvable is NOT a stale lock: the gate did not run.
fixture "$work/cold"
crate "$work/cold/lib" lib 'zs2r-no-such-crate-anywhere = "1"
'
run_case "an offline resolution failure is not a verdict" 2 "did not run" \
"$work/cold/app/Cargo.toml"
run_case "a missing manifest is a usage error" 2 "no manifest" \
"$work/nowhere/Cargo.toml"
run_case "two manifests is a usage error" 2 "" \
"$work/current/app/Cargo.toml" "$work/stale/app/Cargo.toml"
run_case "an unknown option is a usage error" 2 "unknown option" --bogus
printf '\ncheck-harness-lock: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" = 0 ]
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Fails in seconds when harness/opfs-contract/Cargo.lock no longer matches the
# manifests it resolves against. Reasoning: context/code/tooling.md, "The
# harness lock gate" (improvise-zs2r).
set -uo pipefail
usage() {
cat >&2 <<'EOF'
usage: check-harness-lock.sh [--allow-network] [manifest]
manifest the Cargo.toml whose lock is checked; defaults to
harness/opfs-contract/Cargo.toml beside this script
--allow-network resolve without --offline (CI); the default never
touches the network
exit 0 the lock is current
exit 1 the lock is stale: a manifest it resolves against changed
exit 2 the gate did not run: usage error, no cargo, or cargo could not
resolve at all (offline, with a dependency missing from the local
cache). NO verdict was produced. Never collapse 2 into 1.
EOF
}
main() {
local network=0 manifest=""
while [ $# -gt 0 ]; do
case "$1" in
--allow-network) network=1 ;;
-h|--help) usage; return 2 ;;
-*) printf 'check-harness-lock: unknown option %s\n' "$1" >&2; usage; return 2 ;;
*)
if [ -n "$manifest" ]; then usage; return 2; fi
manifest="$1" ;;
esac
shift
done
if [ -z "$manifest" ]; then
manifest="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/harness/opfs-contract/Cargo.toml"
fi
if [ ! -f "$manifest" ]; then
printf 'check-harness-lock: no manifest at %s\n' "$manifest" >&2
return 2
fi
if ! command -v cargo >/dev/null 2>&1; then
echo 'check-harness-lock: no cargo on PATH' >&2
return 2
fi
local -a args=(metadata --locked --format-version 1 --manifest-path "$manifest")
if [ "$network" = 0 ]; then args+=(--offline); fi
local err rc
err="$(cargo "${args[@]}" 2>&1 >/dev/null)"
rc=$?
if [ "$rc" = 0 ]; then
printf 'check-harness-lock: %s is current\n' "${manifest%Cargo.toml}Cargo.lock"
return 0
fi
# The one message that means "resolution succeeded and disagrees with the lock".
if printf '%s' "$err" | grep -q 'cannot update the lock file .* because --locked was passed'; then
printf 'check-harness-lock: STALE: %s\n' "${manifest%Cargo.toml}Cargo.lock" >&2
printf ' Run: cargo update -w --manifest-path %s, and commit the lock.\n' "$manifest" >&2
return 1
fi
printf 'check-harness-lock: the gate did not run; cargo could not resolve:\n%s\n' "$err" >&2
if [ "$network" = 0 ]; then
echo ' Offline, this is usually a dependency missing from the local cache.' >&2
echo ' Retry with --allow-network, or fetch once and rerun.' >&2
fi
return 2
}
main "$@"
+46 -63
View File
@@ -67,21 +67,7 @@ direct=$?
unset name
# ---------------------------------------------------------------------------
# 2. The bash-5 `local` semantics. A multi-assignment `local` does not read
# left to right on bash 5, so this suite passes on macOS bash 3.2 and dies
# under `set -u` on bash 5 -- PR #133's 19/19 local against 2/19 in CI.
d="$(sandbox bash5)"
suite "$d" vers <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
f() { local a="$1" b="$a/x"; echo "$b"; }
f one
EOF
expect "a bash-3.2-only construct fails under the runner's pinned bash" \
1 "$d/checkers.sh" vers
# ---------------------------------------------------------------------------
# 3. The shebang rule. `#!/bin/sh` is dash in CI and bash-in-sh-mode on macOS.
# 2. The shebang rule. `#!/bin/sh` is dash in CI and bash-in-sh-mode on macOS.
d="$(sandbox shebang)"
suite "$d" fine <<'EOF'
#!/usr/bin/env bash
@@ -101,7 +87,7 @@ rm -f "$d/legacy.sh"
expect "and the same sandbox passes once it is removed" 0 "$d/checkers.sh" fine
# ---------------------------------------------------------------------------
# 4. A failing suite is reported, and a passing one is not.
# 3. A failing suite is reported, and a passing one is not.
d="$(sandbox rc)"
suite "$d" red <<'EOF'
#!/usr/bin/env bash
@@ -117,52 +103,7 @@ EOF
expect "a passing suite passes the runner" 0 "$d/checkers.sh" blue
# ---------------------------------------------------------------------------
# 5. No bash >= 5 and no nix is exit 3, not a silent run under whatever is
# there. This is the arm that would have refused the macOS system bash.
d="$(sandbox oldbash)"
suite "$d" any <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
mkdir -p "$work/fakebin"
cat > "$work/fakebin/bash" <<'EOF'
#!/bin/sh
case "$*" in *BASH_VERSINFO*) echo 3; exit 0 ;; esac
exit 0
EOF
chmod +x "$work/fakebin/bash"
# The runner is invoked through a REAL bash, so the fake one below is only
# what its own `command -v bash` probe finds -- not its interpreter.
# utilbin carries only the utilities the runner itself needs, and no nix, so
# the fake bash is the only candidate and the nix fallback is absent. Linked
# one by one rather than taking /usr/bin:/bin, which on NixOS hold only `env`
# and `sh` -- the runner died on `dirname` there, exit 1 instead of 3. `head`
# and `env` are for 5b's 5.1 control, which runs past the version check.
mkdir -p "$work/utilbin"
for tool in dirname head env; do
ln -sf "$(command -v "$tool")" "$work/utilbin/$tool"
done
env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \
"${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1
got=$?
[ "$got" = 3 ] && ok "an old bash with no nix is exit 3" \
|| bad "an old bash with no nix is exit 3" "exit $got, wanted 3"
# 5b. The floor is 5.1, not 5: `wait -n -p` is 5.1 (improvise-fd0j). The 5.1
# control proves the 5.0 refusal is the version and not the fake itself.
for v in "5 0:3:a bash 5.0 with no nix is exit 3, not 5.x" \
"5 1:0:a bash 5.1 is accepted"; do
printf '#!/bin/sh\ncase "$*" in *BASH_VERSINFO*) echo %s; exit 0 ;; esac\nexit 0\n' \
"${v%%:*}" > "$work/fakebin/bash"
want="${v#*:}"; name="${want#*:}"; want="${want%%:*}"
env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \
"${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1
got=$?
[ "$got" = "$want" ] && ok "$name" || bad "$name" "exit $got, wanted $want"
done
# ---------------------------------------------------------------------------
# 6. A named suite that does not exist is a usage error, not a silent no-op.
# 4. A named suite that does not exist is a usage error, not a silent no-op.
d="$(sandbox missing)"
suite "$d" present <<'EOF'
#!/usr/bin/env bash
@@ -171,7 +112,7 @@ EOF
expect "an unknown suite name is exit 4" 4 "$d/checkers.sh" absent
# ---------------------------------------------------------------------------
# 7. --list names every suite in the directory and nothing else.
# 5. --list names every suite in the directory and nothing else.
d="$(sandbox listing)"
suite "$d" alpha <<'EOF'
#!/usr/bin/env bash
@@ -196,5 +137,47 @@ listed="$(tr '\n' ' ' < "$work/out")"
&& ok "an X-test.sh with no X beside it is not a suite" \
|| bad "an X-test.sh with no X beside it is not a suite" "got [$listed]"
# ---------------------------------------------------------------------------
# 8. --would-run reports what the rest of the arguments run, and runs nothing.
# check-ci-graph.py asks it, so each argument rule below is answered HERE.
d="$(sandbox wouldrun)"
for n in alpha beta; do
suite "$d" "$n" <<EOF
#!/usr/bin/env bash
touch "$work/ran-$n"
EOF
done
rm -f "$work"/ran-*
"$d/checkers.sh" --would-run > "$work/out" 2>&1
got="$(tr '\n' ' ' < "$work/out")"
[ "$got" = "would-run: alpha would-run: beta " ] && [ ! -e "$work/ran-alpha" ] \
&& ok "--would-run with no names reports every suite and runs none" \
|| bad "--would-run with no names reports every suite and runs none" "got [$got]"
"$d/checkers.sh" --would-run --self-test alpha > "$work/out" 2>&1
got="$(tr '\n' ' ' < "$work/out")"
[ "$got" = "missing: checkers " ] \
&& ok "--would-run --self-test reports checkers and discards the names" \
|| bad "--would-run --self-test reports checkers and discards the names" "got [$got]"
"$d/checkers.sh" --would-run --list alpha > "$work/out" 2>&1
grep -q '^would-run:' "$work/out" \
&& bad "--would-run --list reports nothing as run" "$(tr '\n' ' ' < "$work/out")" \
|| ok "--would-run --list reports nothing as run"
expect "--would-run anywhere but first is a usage error" 4 "$d/checkers.sh" alpha --would-run
[ ! -e "$work/ran-alpha" ] && ok "and it runs nothing" \
|| bad "and it runs nothing" "alpha ran"
# ---------------------------------------------------------------------------
# 9. --ci <job> runs that job's row of the CI table and nothing else. The
# sandbox has none of the real suites, so a row reads back as `missing:`.
"$d/checkers.sh" --would-run --ci js > "$work/out" 2>&1
got="$(tr '\n' ' ' < "$work/out")"
[ "$got" = "missing: node-test " ] && ok "--ci reads that job's row of the table" \
|| bad "--ci reads that job's row of the table" "got [$got]"
expect "--ci with a job the table does not have is exit 4" 4 "$d/checkers.sh" --would-run --ci nope
expect "--ci with nothing after it is exit 4" 4 "$d/checkers.sh" --would-run --ci
expect "--ci beside a suite name is exit 4, not a union" 4 \
"$d/checkers.sh" --would-run --ci js alpha
expect "--ci beside --self-test is exit 4" 4 "$d/checkers.sh" --would-run --self-test --ci js
printf '\ncheckers-test: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+54 -45
View File
@@ -3,15 +3,14 @@
# and so does the local gate, so a suite cannot be green under one shell and
# red under the other. Reasoning: context/code/tooling.md section 3.
#
# It does three things a bare `./scripts/X-test.sh` does not:
# It does two things a bare `./scripts/X-test.sh` does not. It runs every
# suite under the bash running it (the dev shell's), and it:
#
# 1. pins a bash >= 5.1, taken from nix when the ambient one is older, so the
# macOS system bash 3.2 can never be the shell a suite passes under;
# 2. scrubs the environment down to an allowlist, because `nix develop`
# 1. scrubs the environment down to an allowlist, because `nix develop`
# exports `name`, `out`, `system`, `shell`, `builder` and two dozen more
# generically-named variables that CI does not have -- a `set -u` fault
# reading one of them is SILENT inside the dev shell and fatal in CI;
# 3. refuses a shell script in scripts/ whose shebang is not
# 2. refuses a shell script in scripts/ whose shebang is not
# `#!/usr/bin/env bash`, so the sh/bash split cannot come back.
set -euo pipefail
@@ -19,7 +18,7 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() {
cat >&2 <<'EOF'
usage: checkers.sh [--list] [--self-test] [name ...]
usage: checkers.sh [--list] [--self-test] [--ci <job>] [name ...]
name a suite to run, spelled without the -test.sh suffix
(e.g. `pr-reviews` for scripts/pr-reviews-test.sh).
@@ -27,10 +26,16 @@ usage: checkers.sh [--list] [--self-test] [name ...]
--list print the suite names and exit
--self-test run scripts/checkers-test.sh, which injects the faults this
runner exists to catch
--ci <job> run the suites the CI table gives that job of
.github/workflows/ci.yml, and nothing else. Each CI job runs
exactly this, so the table is the one list of what CI runs
--would-run as the FIRST argument only: run nothing, and print
`would-run: <suite>` (or `missing: <name>`) for each suite
the rest of the arguments would run. check-ci-graph.py
asks this, so the argument rules have one home
exit 0 every suite passed
exit 1 a suite failed, or a shebang is not `#!/usr/bin/env bash`
exit 3 no bash >= 5.1 could be found and nix could not supply one
exit 4 usage error
THIS SCRIPT'S `$?` MUST BE THE ONE YOU READ. Nothing may follow it in the same
@@ -46,22 +51,6 @@ ALLOW="HOME TMPDIR TMP TEMP TERM LANG LC_ALL USER LOGNAME
NIX_PATH NIX_REMOTE NIX_SSL_CERT_FILE SSL_CERT_FILE
XDG_CACHE_HOME XDG_CONFIG_HOME XDG_DATA_HOME"
# Prints the absolute path of a bash >= 5.1, or nothing. The ambient one is tried
# first so a machine that already has a modern bash pays no nix startup at all.
find_modern_bash() {
local candidate major minor
candidate="$(command -v bash || true)"
if [ -n "$candidate" ]; then
read -r major minor < <("$candidate" -c 'echo "${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}"' \
2>/dev/null || echo 0 0)
if [ "$(( ${major:-0} * 100 + ${minor:-0} ))" -ge 501 ] 2>/dev/null; then
echo "$candidate"; return 0
fi
fi
command -v nix >/dev/null 2>&1 || return 0
nix shell nixpkgs#bash --command bash -c 'command -v bash' 2>/dev/null || true
}
# Every shell script in scripts/ says `#!/usr/bin/env bash` and nothing else.
# `#!/bin/sh` is dash in CI and bash 3.2 in sh-mode on macOS: a second platform
# split, undetected for the same reason the first one was.
@@ -94,28 +83,40 @@ suites() {
done
}
# $1 the pinned bash, $2 the suite file. The suite runs with the pinned bash
# FIRST on PATH, so a script it invokes through its own `#!/usr/bin/env bash`
# -- or through `sh` -- gets the pinned one too, not the system 3.2.
# $1 the suite file, run by the bash running this script, with PATH unchanged.
run_suite() {
local pinned="$1" file="$2"
local bindir; bindir="$(dirname "$pinned")"
local file="$1"
local -a env_args=(-i)
local v
for v in $ALLOW ${CHECKERS_KEEP:-}; do
if [ -n "${!v+set}" ]; then env_args+=("$v=${!v}"); fi
done
env_args+=("PATH=$bindir:${PATH:-/usr/bin:/bin}")
env "${env_args[@]}" "$pinned" "$file"
env_args+=("PATH=$PATH")
env "${env_args[@]}" "$BASH" "$file"
}
# The CI table: the suites each job of .github/workflows/ci.yml runs, by that
# job's `checkers.sh --ci <job>` step. check-ci-graph.py holds it to --list.
ci_suites() {
case "$1" in
build) echo assemble-stamp ;;
js) echo node-test ;;
# check-harness-lock must never read "could not resolve" as "stale" (improvise-zs2r).
kb) echo kb checkers pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids check-harness-lock ;;
*) return 1 ;;
esac
}
main() {
local self_test=0
local self_test=0 would_run=0 ci_job=""
local -a names=()
# Only as the FIRST argument, so a CI step spelling it anywhere is an error.
if [ "${1:-}" = --would-run ]; then would_run=1; shift; fi
while [ $# -gt 0 ]; do
case "$1" in
--list) suites; return 0 ;;
--self-test) self_test=1 ;;
--ci) ci_job="${2:-}"; [ -n "$ci_job" ] || { usage; return 4; }; shift ;;
-h|--help) usage; return 4 ;;
-*) printf 'checkers: unknown option %s\n' "$1" >&2; usage; return 4 ;;
*) names+=("$1") ;;
@@ -123,25 +124,33 @@ main() {
shift
done
local pinned; pinned="$(find_modern_bash)"
if [ -z "$pinned" ]; then
echo 'checkers: no bash >= 5.1 on PATH and nix cannot supply one.' >&2
echo ' Enter the dev shell (direnv allow / nix develop) and retry.' >&2
return 3
fi
printf 'checkers: bash %s at %s\n' \
"$("$pinned" -c 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"')" "$pinned"
local rc=0
check_shebangs || rc=1
if [ "$self_test" = 1 ]; then
if [ -n "$ci_job" ]; then
if [ "$self_test" = 1 ] || [ ${#names[@]} -gt 0 ]; then
echo 'checkers: --ci takes a job and nothing else' >&2; return 4
fi
local table
table="$(ci_suites "$ci_job")" || {
printf 'checkers: no CI job %s in the table\n' "$ci_job" >&2; return 4; }
read -r -a names <<< "$table"
elif [ "$self_test" = 1 ]; then
names=(checkers)
elif [ ${#names[@]} -eq 0 ]; then
while IFS= read -r line; do names+=("$line"); done < <(suites)
fi
local n file
if [ "$would_run" = 1 ]; then
for n in ${names[@]+"${names[@]}"}; do
if [ -f "$HERE/$n-test.sh" ]; then printf 'would-run: %s\n' "$n"
else printf 'missing: %s\n' "$n"; fi
done
return 0
fi
printf 'checkers: bash %s at %s\n' "$BASH_VERSION" "$BASH"
local rc=0
check_shebangs || rc=1
for n in ${names[@]+"${names[@]}"}; do
file="$HERE/$n-test.sh"
if [ ! -f "$file" ]; then
@@ -149,7 +158,7 @@ main() {
rc=4; continue
fi
printf '\n=== %s ===\n' "$n"
run_suite "$pinned" "$file" || rc=1
run_suite "$file" || rc=1
done
return $rc
}
+1 -1
View File
@@ -87,7 +87,7 @@ ledger_path() {
# first line; bytes alone are not enough, since a restore can reproduce them.
file_stamp() {
printf '%s %s' "$(stamp_of "$1")" \
"$(stat -c '%i %.9Y' "$1" 2>/dev/null || stat -f '%i %Fm' "$1")"
"$(stat -c '%i %.9Y' "$1")"
}
ledger_holds() { [ -f "$1" ] && [ "$(head -n 1 "$1")" = "% kb-stamp $(file_stamp "$2")" ]; }
+4 -3
View File
@@ -9,12 +9,13 @@ work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
# Every run is bounded, so a loop that never stops FAILS (exit 124) rather than
# hanging the suite. /bin/sleep, because some cases put a no-op `sleep` first on PATH.
# hanging the suite. The real sleep, resolved before any case stubs PATH; NixOS has no /bin/sleep.
BOUND=20
REAL_SLEEP="$(type -P sleep)"
bounded_reviews() {
"$here/pr-reviews" "$@" &
local pid=$! dog done rc
/bin/sleep "$BOUND" &
"$REAL_SLEEP" "$BOUND" &
dog=$!
wait -n -p done "$pid" "$dog"; rc=$?
if [ "$done" = "$pid" ]; then kill "$dog" 2>/dev/null; wait "$dog" 2>/dev/null; return "$rc"; fi
@@ -218,7 +219,7 @@ clock="$work/clock"
mkdir -p "$clock"
cat > "$clock/date" <<'EOF'
#!/usr/bin/env bash
[ "${1:-}" = "+%s" ] || exec /bin/date "$@"
[ "$*" = "+%s" ] || { echo "fake date: only +%s is faked, got: $*" >&2; exit 2; }
n=$(( $(cat "$CLOCK_TICKS" 2>/dev/null || echo 0) + 1 ))
echo "$n" > "$CLOCK_TICKS"
echo $(( 1000 + n ))