The app's install clears the jobs folder on a PC, so a run job whose kit was fetched by an earlier fetch job finds
nothing after an update and fails in seconds (5 October 2026, 21:49Z, the AMD kit; bench-log 4df339f). Rule: a run
playbook that reaches a path under the jobs folder other than its own tests the kit is there before its first use,
and the fetch is republished under a new id after any app update.
tools/ci/kit-path-check.sh reads every *.ps1 under relay/playbooks/ and tools/. A kit root is a path derived from
the jobs folder (`$jobs = Split-Path $env:IGNEUM_JOB_DIR` then `Join-Path $jobs '<fetch id>'`, the race-5090.ps1
shape) or one carrying a literal `jobs\` (the amd-card-test.ps1 shape); every path built from it belongs to that kit.
A presence check (Test-Path, [IO.File]::Exists, [IO.Directory]::Exists, Get-Item or Get-ChildItem with -ErrorAction)
on the root or anything under it covers the whole kit. A use before that line fails with "kit path used before a
presence check: republish the fetch after any app update", as does a literal jobs\ path in a command with no check.
The job's own folder ($env:IGNEUM_JOB_DIR) is not a kit path.
Fixtures: kit-path-ok.ps1 (both shapes, checked; a sibling pack file covered by the worker's check) and
kit-path-unchecked.ps1 (the worker run before its check, a literal never checked); --self-test asserts the lines.
Wired into ci.yml after the bash-body step, and into publish-jobs.sh add --kind run beside the other two checks;
test-publish-jobs.sh gains the refusal (34 passed, 0 failed). The current tree: race-5090.ps1 is the one playbook
with a kit, checked before use. README-ship.md: the rule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
110 lines
6.1 KiB
Bash
Executable file
110 lines
6.1 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# The wiped-jobs-folder class (5 October 2026, 21:49Z, bench-log 39f02ff): the app's install clears the jobs folder on
|
|
# a PC, so a run job whose kit was fetched by an earlier fetch job finds nothing after an update and fails in seconds.
|
|
# Rule: a run playbook that reaches a path under the app's jobs folder other than its own
|
|
# (`$jobs = Split-Path $env:IGNEUM_JOB_DIR` then `Join-Path $jobs '<fetch id>'`, the race-5090.ps1 shape; or a literal
|
|
# `jobs\<id>\...`, the amd-card-test.ps1 shape) tests that the kit is there (Test-Path, [IO.File]::Exists,
|
|
# [IO.Directory]::Exists, Get-Item or Get-ChildItem with -ErrorAction) before its first use, and republishes the fetch
|
|
# after any app update. A check on the kit's root or on anything under it covers the whole kit. This check reads every
|
|
# *.ps1 under relay/playbooks/ and tools/ and fails on a kit path used before a presence check. The job's own folder
|
|
# ($env:IGNEUM_JOB_DIR, made by the app for the run) is not a kit path.
|
|
#
|
|
# Usage: tools/ci/kit-path-check.sh # the tree (tools/ci/fixtures/ left out)
|
|
# tools/ci/kit-path-check.sh <file.ps1>... # named files (the jobs publisher runs it on the script it publishes)
|
|
# tools/ci/kit-path-check.sh --self-test # must fire on the unchecked fixture and stay quiet on the correct one
|
|
set -uo pipefail
|
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
REPO="$(cd "$HERE/../.." && pwd)"
|
|
|
|
check_files() {
|
|
python3 - "$@" <<'PY'
|
|
import re, sys
|
|
|
|
SEED_LIT = re.compile(r'jobs\\') # a literal path under the jobs folder
|
|
SEED_FOLDER = re.compile(r'Split-Path\s+\$env:IGNEUM_JOB_DIR') # the jobs folder itself: the parent of this job's folder
|
|
CHECK = re.compile(r'Test-Path|\[IO\.(File|Directory)\]::Exists|Get-(Item|ChildItem)\b[^|]*-ErrorAction')
|
|
ASSIGN = re.compile(r'^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)\s*(.*)$')
|
|
# a right-hand side that only builds a path (the kit var it names is then a path, not a use of one)
|
|
DERIV = re.compile(r'^(Join-Path\b|Split-Path\b|\(|"|\'|\[IO\.Path\]::Combine|\$env:|\$[A-Za-z_][A-Za-z0-9_]*\s*(\+|\.(TrimEnd|Replace)|$))')
|
|
TEXT_ONLY = re.compile(r'^\s*(Write-Output|Write-Host|Write-Verbose|Say|Log)\b|^\s*"')
|
|
VAR = re.compile(r'\$([A-Za-z_][A-Za-z0-9_]*)\b(?!:)')
|
|
MSG = 'kit path used before a presence check: republish the fetch after any app update'
|
|
|
|
rc = 0
|
|
files = sys.argv[1:]
|
|
with_kits = 0
|
|
for path in files:
|
|
try:
|
|
lines = open(path, encoding='utf-8', errors='replace').read().split('\n')
|
|
except OSError as e:
|
|
print('FAIL %s: %s' % (path, e)); rc = 1; continue
|
|
folder = set() # vars that are the jobs folder (always there)
|
|
root_of = {} # kit var -> its root var
|
|
root_line = {} # root var -> the line it is defined on
|
|
checked = set() # roots with a presence check so far
|
|
reported = set()
|
|
inline_checked = False
|
|
fails = []
|
|
for ln, raw in enumerate(lines, 1):
|
|
s = raw.strip()
|
|
if not s or s.startswith('#'): continue
|
|
refs = set(VAR.findall(raw))
|
|
kit_refs = refs & set(root_of)
|
|
m = ASSIGN.match(raw)
|
|
if m:
|
|
name, rhs = m.group(1), m.group(2).strip()
|
|
if SEED_FOLDER.search(rhs) and not SEED_LIT.search(rhs):
|
|
folder.add(name); continue
|
|
deriv = bool(DERIV.match(rhs))
|
|
if deriv and (SEED_LIT.search(rhs) or (refs & folder)):
|
|
root_of[name] = name; root_line[name] = ln; continue
|
|
if deriv and kit_refs:
|
|
root_of[name] = root_of[sorted(kit_refs)[0]]; continue
|
|
if CHECK.search(raw):
|
|
for v in kit_refs: checked.add(root_of[v])
|
|
if SEED_LIT.search(raw): inline_checked = True
|
|
continue
|
|
for v in sorted(kit_refs):
|
|
r = root_of[v]
|
|
if r in checked or r in reported: continue
|
|
reported.add(r)
|
|
fails.append('FAIL %s:%d %s ($%s, kit path $%s defined at line %d)' % (path, ln, MSG, v, r, root_line[r]))
|
|
if SEED_LIT.search(raw) and not inline_checked and not TEXT_ONLY.match(raw):
|
|
inline_checked = True
|
|
fails.append('FAIL %s:%d %s (a literal jobs\\ path in a command, never checked)' % (path, ln, MSG))
|
|
if fails:
|
|
rc = 1
|
|
for f in fails: print(f)
|
|
elif root_of or inline_checked:
|
|
with_kits += 1
|
|
print('ok %s (%d kit path%s, checked before use)' % (path, len(root_line), '' if len(root_line) == 1 else 's'))
|
|
print('kit-path-check: %d files, %d with a fetched kit, %s' % (len(files), with_kits, 'all checked before use' if rc == 0 else 'FAILURES above'))
|
|
sys.exit(rc)
|
|
PY
|
|
}
|
|
|
|
if [ "${1:-}" = "--self-test" ]; then
|
|
F="$HERE/fixtures"
|
|
ok_out="$(check_files "$F/kit-path-ok.ps1" 2>&1)"; ok_rc=$?
|
|
bad_out="$(check_files "$F/kit-path-unchecked.ps1" 2>&1)"; bad_rc=$?
|
|
echo "self-test correct fixture: rc $ok_rc"; printf '%s\n' "$ok_out" | sed 's/^/ /'
|
|
echo "self-test unchecked fixture: rc $bad_rc"; printf '%s\n' "$bad_out" | sed 's/^/ /'
|
|
[ $ok_rc -eq 0 ] || { echo "SELF-TEST FAILED: the correct fixture was flagged"; exit 1; }
|
|
printf '%s\n' "$ok_out" | grep -q '^ok .*2 kit paths' || { echo "SELF-TEST FAILED: the correct fixture holds 2 kit paths, a different number was found"; exit 1; }
|
|
[ $bad_rc -ne 0 ] || { echo "SELF-TEST FAILED: the unchecked fixture passed"; exit 1; }
|
|
for want in 'kit-path-unchecked.ps1:9 ' 'kit-path-unchecked.ps1:13 '; do
|
|
printf '%s\n' "$bad_out" | grep -q "^FAIL .*$want.*republish the fetch after any app update" || { echo "SELF-TEST FAILED: the unchecked use at $want was not reported"; exit 1; }
|
|
done
|
|
[ "$(printf '%s\n' "$bad_out" | grep -c '^FAIL ')" -eq 2 ] || { echo "SELF-TEST FAILED: 2 failures expected"; exit 1; }
|
|
echo "self-test passed: the unchecked kit paths fail, the checked ones pass"
|
|
exit 0
|
|
fi
|
|
|
|
if [ $# -gt 0 ]; then
|
|
check_files "$@"; exit $?
|
|
fi
|
|
cd "$REPO"
|
|
files=$(git ls-files 'relay/playbooks/**' 'tools/**' | grep -E '\.ps1$' | grep -v '^tools/ci/fixtures/' || true)
|
|
if [ -z "$files" ]; then echo "kit-path-check: no .ps1 files under relay/playbooks/ or tools/"; exit 0; fi
|
|
# shellcheck disable=SC2086
|
|
check_files $files
|