The shipper's report: with no arguments, cross-remote.sh died under the Mac's bash 3.2 with 'CARGO_ARGS: unbound variable' and
its chain kept the previous exes. Cause: a comment appended to the defaults line in the box-work merge turned OUT, COMPARE,
TARGET_DIR and CARGO_ARGS=() into comment text, so the array was never declared (the same shape build-remote.sh had at 19:5x).
Fix: the line split, and the default tests use [ -n "${CARGO_ARGS[*]:-}" ] in both tools, safe whether or not the array
exists. Proof: the default cross-build from a fork worktree under /bin/bash 3.2.57 built both exes (igneumd.exe 8f7e2ae3...,
igneum-miner.exe 6f8b49d8...). tools/ci/defaults-line-check.sh fails CI on a line where a comment start is followed by an
assignment list (quoted strings, ${...}, $# and [^#] dropped first); self-test fires on the swallowed shape and passes
${a#b}, sed s#x#y#, $# loops and prose mentions; the tree is clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
57 lines
4.1 KiB
Bash
Executable file
57 lines
4.1 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# The swallowed-defaults class (6 October 2026, twice in one evening): a comment appended to a line of shell assignments
|
|
# (`JOBS=...; # ... OUT=""; CARGO_ARGS=()`) turns every assignment after the `#` into comment text; the script still parses,
|
|
# bash -n and shellcheck say nothing, and the first use of the undeclared array dies under the Mac's bash 3.2 with
|
|
# "unbound variable" (build-remote.sh at 19:5x UTC, cross-remote.sh at 21:xx UTC: the shipper's default cross-build never ran
|
|
# and its chain kept the previous exes). Rule: no `#` comment on a line that carries shell assignments after it.
|
|
# The scan (python3, which every CI host has) first drops quoted strings, ${...} expansions, $# and [^#] so a `#` inside them
|
|
# is not a comment, then flags a line where a comment start (start of line or whitespace, then #) is followed by `NAME=`.
|
|
#
|
|
# tools/ci/defaults-line-check.sh # exit 1 with file:line on a hit
|
|
# tools/ci/defaults-line-check.sh --self-test # fires on the swallowed shape, passes clean ones (including ${a#b} and sed s#x#y#)
|
|
set -euo pipefail
|
|
cd "$(dirname "$0")/../.."
|
|
scan() { # file names as arguments (python3 - takes its script from stdin, so stdin cannot carry the list), file:line per hit, exit 1 if any
|
|
python3 - "$@" <<'PY'
|
|
import re, sys
|
|
strip = [
|
|
(re.compile(r"\$\{[^}]*\}"), ""), # ${var#pat}, ${var%pat}, ${!i}
|
|
(re.compile(r"\$#"), ""), # the argument count
|
|
(re.compile(r"\[\^#\]"), ""), # a bracket expression excluding #
|
|
(re.compile(r"'[^']*'"), "''"), # single-quoted strings
|
|
(re.compile(r'"(?:[^"\\]|\\.)*"'), '""'), # double-quoted strings
|
|
(re.compile(r"\\#"), ""), # an escaped #
|
|
]
|
|
# the swallowed shape is an assignment LIST after the comment start: `NAME=...;` or `NAME=()`; a prose mention such as
|
|
# "(access public|private, private only in NET_MODE=private)" or "OTA_SKIP=1 (the default now)" has neither
|
|
hit = re.compile(r"(^|\s)#.*\s[A-Za-z_][A-Za-z0-9_]*=(\(\)|[^;()]*;)")
|
|
bad = 0
|
|
for path in sys.argv[1:]:
|
|
try: lines = open(path, encoding="utf-8", errors="replace").read().split("\n")
|
|
except OSError: continue
|
|
for n, line in enumerate(lines, 1):
|
|
s = line
|
|
for rx, rep in strip: s = rx.sub(rep, s)
|
|
if s.lstrip().startswith("#"): continue # a whole-line comment may say anything
|
|
if hit.search(s):
|
|
print(f"defaults-line: {path}:{n}: a comment swallows assignments after it: {line.strip()[:140]}"); bad = 1
|
|
sys.exit(bad)
|
|
PY
|
|
}
|
|
if [ "${1:-}" = --self-test ]; then
|
|
t=$(mktemp -d); trap 'rm -rf "$t"' EXIT
|
|
printf '%s\n' 'JOBS="${JOBS:-}"; # empty = the box decides OUT=""; CARGO_ARGS=()' > "$t/bad.sh"
|
|
printf '%s\n' 'JOBS="${JOBS:-}"; OUT=""; CARGO_ARGS=() # one line, nothing assigned after the comment' \
|
|
'ver=${tarball#node-}; ver=${ver%-linux-x64.tar.xz}' \
|
|
"epoch=\$(sed -n 's/^#define X \"\\(.*\\)\"/\\1/p' \"\$ph\"); day=\$(sed -n 's/^#define Y/x/p')" \
|
|
'rel="${a#"$TARGET_DIR"/}"; dest="$OUT/$rel"' \
|
|
'for ((i=1;i<=$#;i++)); do fee="${!i}"; done' \
|
|
"A=\"\$(find . | sed 's#^\\./##' | sort)\"; B=1" \
|
|
'hetzner_create_one() { # name type location [role] [access] (access public|private, private only in NET_MODE=private)' \
|
|
'if [ "${OTA_SKIP:-}" = 0 ]; then SIGN=1; fi # the old spelling; OTA_SKIP=1 (the default now) leaves the manifest alone' \
|
|
'# a whole-line comment with OUT=""; CARGO_ARGS=() in it' > "$t/good.sh"
|
|
if scan "$t/bad.sh" >/dev/null; then echo "defaults-line self-test: the swallowed shape did NOT fire"; exit 1; fi
|
|
if scan "$t/good.sh"; then echo "defaults-line self-test: fires on the swallowed shape, passes the clean shapes"; exit 0; else echo "defaults-line self-test: a clean shape fired"; exit 1; fi
|
|
fi
|
|
# shellcheck disable=SC2046 # paths in this tree carry no spaces
|
|
if scan $(git ls-files 'tools/*.sh' 'tools/**/*.sh' 'infra/**/*.sh' 'proto-cuda/**/*.sh' 'packaging/**/*.sh'); then echo "defaults-line: no assignment hides behind a comment"; else exit 1; fi
|