Files
codebase-memory-mcp/tests/test_script_exec_bit_contract.sh
T
Martin Vogel 5a479facf7 fix(release): invoke the new CI scripts via bash, and pin the exec-bit class
Completes the previous commit, which carried only the two mode changes because
the call-site edits were not staged when it landed (--amend is denied in this
repo, so this is additive rather than a rewrite).

Call sites now go through `bash`, which is what most of this repo already does and
which cannot break if a mode bit is lost to a patch application or a non-POSIX
checkout:

  scripts/package-release.sh   -> bash scripts/ci/check-binary-composition.sh
  .github/workflows/release.yml -> bash scripts/ci/append-vt-notes.sh

With the 100755 modes from the previous commit, both sides are now correct, and
either alone would have been sufficient.

tests/test_script_exec_bit_contract.sh pins the class so it cannot recur: any
tracked .sh whose COMMITTED mode is non-executable must not appear as the first
word of a command in workflows, scripts, test-infrastructure or the Makefiles.
The committed mode is the thing that matters and the thing no local run can
check -- the working copy having the bit is exactly why this shipped.

Verified in both directions: passes on this tree, and fails on the exact defect
when the mode and the call site are reverted together. It joins backslash
continuations before analysing, because its own first draft reported a false
positive on

    ... && bash \
        test-infrastructure/vm/vm-run-tests.sh --soak

where the interpreter sits on the preceding line. A contract that cries wolf
teaches people to ignore contracts, so that had to be right before it could be
useful.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-30 03:05:12 +02:00

117 lines
4.6 KiB
Bash

#!/usr/bin/env bash
# Contract: a shell script invoked as a COMMAND must be executable in git.
#
# This exists because it cost a release build phase. scripts/ci/check-binary-
# composition.sh was committed 100644 while being invoked directly from
# scripts/package-release.sh, so every unix build leg died with:
#
# scripts/package-release.sh: line 190: .../check-binary-composition.sh: Permission denied
#
# It passed every local check because the WORKING COPY had the bit — only the
# committed mode was wrong, which no amount of running it locally can reveal.
# scripts/ci/append-vt-notes.sh had the identical defect queued up for the verify
# step at the very end of the release.
#
# The rule is about the CALL SITE, not the file: `bash foo.sh` is equally correct
# and mode-independent (and is what most of this repo does). What must not happen
# again is a call site that needs the bit paired with a file that lacks it.
#
# Python, not shell: the analysis is "is this path the first word of a command",
# and expressing that in `case` patterns is how the first version of this file
# got a quoting bug of its own.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
python3 - "$ROOT" <<'PY'
import pathlib
import re
import subprocess
import sys
root = pathlib.Path(sys.argv[1])
def git(*args):
return subprocess.run(["git", "-C", str(root), *args],
capture_output=True, text=True, check=True).stdout
# Tracked .sh files whose COMMITTED mode is non-executable.
non_exec = set()
for line in git("ls-files", "-s", "*.sh").splitlines():
mode, _, _, path = line.split(maxsplit=3)
if mode == "100644":
non_exec.add(path)
# Places that actually execute things.
search_roots = [root / ".github" / "workflows", root / "scripts",
root / "test-infrastructure"]
search_files = [root / "Makefile.cbm", root / "Makefile"]
for base in search_roots:
if base.is_dir():
search_files += [p for p in base.rglob("*")
if p.is_file() and p.suffix in {".yml", ".yaml", ".sh", ""}]
# Strip shell/YAML/Make prefixes that mean "not the first word of a command".
INTERPRETED = re.compile(r"^(bash|sh|zsh|source|\.)\s")
LEADERS = re.compile(r"^(?:[-@\t ]*)(?:run:\s*)?(?:then\s+|else\s+|do\s+|&&\s*|\|\|\s*|;\s*)*")
failures = []
for path in search_files:
try:
text = path.read_text(errors="replace")
except OSError:
continue
# Join backslash continuations into LOGICAL lines first. Without this,
# ... && bash \
# test-infrastructure/vm/vm-run-tests.sh --soak
# reads as a bare script path at the start of a line and reports a false
# positive — which is how the first draft of this contract flagged a call
# site that was already correct.
logical = []
pending, start = "", 0
for number, raw in enumerate(text.splitlines(), 1):
if not pending:
start = number
stripped_end = raw.rstrip()
if stripped_end.endswith("\\"):
pending += stripped_end[:-1] + " "
continue
logical.append((start, pending + raw))
pending = ""
if pending:
logical.append((start, pending))
for number, raw in logical:
line = raw.strip()
if not line or line.startswith("#"):
continue
for script in non_exec:
if script not in line:
continue
# Position of the reference; everything before it must be prefix-y.
for match in re.finditer(re.escape(script), line):
head = line[:match.start()]
head_wo_leaders = LEADERS.sub("", head).lstrip("./")
if head_wo_leaders.strip():
continue # something real precedes it → an argument
if INTERPRETED.match(head.strip() + " "):
continue # bash/sh/source foo.sh → mode irrelevant
if head.strip() in {"", "-", "@"} or LEADERS.fullmatch(head):
rel = path.relative_to(root)
failures.append(
f"{rel}:{number}: executes {script} directly, but its "
f"committed mode is 100644\n"
f" fix EITHER side: "
f"'git update-index --chmod=+x {script}' or 'bash {script}'")
break
if failures:
for f in sorted(set(failures)):
print("FAIL: " + f, file=sys.stderr)
print(f"script exec-bit contract FAILED with {len(set(failures))} violation(s)",
file=sys.stderr)
sys.exit(1)
print("PASS: no non-executable script is invoked as a command")
PY