Merge commit '08ad7b8136baa8d1d26f532ab18a5f78bdfefb61' into docs/sboz-stable-identities
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Executable
+77
@@ -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 ]
|
||||
Executable
+73
@@ -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
@@ -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
@@ -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
@@ -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")" ]; }
|
||||
|
||||
|
||||
@@ -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 ))
|
||||
|
||||
Reference in New Issue
Block a user