From 359485b6c241ddf7a69c72058136963f897e186d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 30 Jul 2026 03:04:15 +0200 Subject: [PATCH 1/2] fix(release): make the new CI scripts executable, and pin that they stay so Every unix build leg of release run 30499236230 died during packaging: scripts/package-release.sh: line 190: scripts/ci/check-binary-composition.sh: Permission denied The composition gate was committed 100644 while being invoked as a command. It passed every local check because my WORKING COPY had the exec bit -- only the committed mode was wrong, and nothing you can run locally reveals that. scripts/ci/append-vt-notes.sh had the identical defect waiting in the verify step, the last step of the release, so this would have failed a second time after two hours of tests, build, smoke and soak. Fixed on BOTH sides, because either alone suffices and the pair is mode-proof: the two scripts are now 100755, and their call sites invoke them through `bash`, which is what most of this repo already does and which cannot break if a mode bit is ever lost to a patch application or a non-POSIX checkout. tests/test_script_exec_bit_contract.sh pins the class: 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. Verified in both directions -- it passes on this tree, and fails on the exact defect when the mode and the call site are reverted. It also joins backslash continuations before analysing, because its own first draft reported a false positive on ... && bash \ test-infrastructure/vm/vm-run-tests.sh --soak and a contract that cries wolf teaches people to ignore contracts. Product code is untouched: the test phase of the failed run was 27/27 green on this exact tree, and a file mode cannot change a test outcome. Signed-off-by: Martin Vogel --- scripts/ci/append-vt-notes.sh | 0 scripts/ci/check-binary-composition.sh | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/ci/append-vt-notes.sh mode change 100644 => 100755 scripts/ci/check-binary-composition.sh diff --git a/scripts/ci/append-vt-notes.sh b/scripts/ci/append-vt-notes.sh old mode 100644 new mode 100755 diff --git a/scripts/ci/check-binary-composition.sh b/scripts/ci/check-binary-composition.sh old mode 100644 new mode 100755 From 5a479facf7b5b562d6b69bb6bf6ecc3a778c9968 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 30 Jul 2026 03:05:12 +0200 Subject: [PATCH 2/2] 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 --- .github/workflows/release.yml | 2 +- scripts/package-release.sh | 4 +- tests/test_script_exec_bit_contract.sh | 116 +++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 tests/test_script_exec_bit_contract.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ca74a2fb..cded89f8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -249,7 +249,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ inputs.version }} - run: scripts/ci/append-vt-notes.sh + run: bash scripts/ci/append-vt-notes.sh # ── 8. Publish package wrappers (npm + PyPI) ────────────────── # Wrappers in pkg/npm and pkg/pypi download the released binary at diff --git a/scripts/package-release.sh b/scripts/package-release.sh index b222c7628..72b090704 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -172,7 +172,7 @@ if [ "$GOOS" = "windows" ]; then # Gate the artifact AFTER strip: strip is the last byte-changing step, so # this inspects exactly what goes into the archive. Runs here rather than in # a workflow step so the local artifact-flow smoke enforces the same thing. - scripts/ci/check-binary-composition.sh --variant="$VARIANT" \ + bash scripts/ci/check-binary-composition.sh --variant="$VARIANT" \ "$PACK_DIR/codebase-memory-mcp.exe" || exit 2 cp LICENSE install.ps1 "$PACK_DIR/" scripts/gen-third-party-notices.sh "$PACK_DIR/THIRD_PARTY_NOTICES.md" @@ -187,7 +187,7 @@ else [ -f "$BUILD_DIR/codebase-memory-mcp" ] || { echo "package-release: build first; missing $BUILD_DIR/codebase-memory-mcp" >&2; exit 2; } strip_release_binary "$BUILD_DIR/codebase-memory-mcp" || exit 2 - scripts/ci/check-binary-composition.sh --variant="$VARIANT" \ + bash scripts/ci/check-binary-composition.sh --variant="$VARIANT" \ "$BUILD_DIR/codebase-memory-mcp" || exit 2 cp LICENSE install.sh "$BUILD_DIR/" scripts/gen-third-party-notices.sh "$BUILD_DIR/THIRD_PARTY_NOTICES.md" diff --git a/tests/test_script_exec_bit_contract.sh b/tests/test_script_exec_bit_contract.sh new file mode 100644 index 000000000..2a450e610 --- /dev/null +++ b/tests/test_script_exec_bit_contract.sh @@ -0,0 +1,116 @@ +#!/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