A shebang means executable: test the invariant, cut the patch
install.py shipped mode 644 in v0.1-alpha, so the README one-liner's ./install.py was permission-denied on every install. The repo modes and update.sh's repair line were hotfixed already (all 14 shebang'd tracked files are 100755; update.sh:165 names install.py) — this is the guard that keeps them that way, and the patch release that heals the field. - tests/test_release_artifact.py: the invariant, read from the tar header rather than the repo — every member whose content starts `#!` must carry the exec bit, failing by name. No exception list: there is no shipped file that legitimately may not be run, and gaining one means editing the test with a reason. Two tests keep it honest: the guard is proven to bite by repacking the real artifact with install.py's mode stripped, and an unpacked release must run ./install.py as a program, not via python3. - tests/test_update_from_release.py: an install whose install.py is mode 644 — the shape v0.1-alpha left in the field — is executable again after any update. `cp` onto an existing file keeps the destination's mode, so the chmod line is the only thing healing it; removing that line fails this test. - manager/core/VERSION → 0.1-alpha.1: cutting the patch is the honest move over a release note telling users to work around it. - manager/core/release-manifest: the invariant, stated where the shipping list lives. Verified: python3 -m unittest discover -s tests (267 tests, OK). Both new assertions were watched failing first — a build-side `chmod -x` on the staged install.py, and update.sh with install.py dropped from its chmod list — then restored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
0.1-alpha
|
||||
0.1-alpha.1
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
#
|
||||
# Anything not listed here does not ship: bench's own task cards, its
|
||||
# manager/local/ content, local/state/, .claude/, tests/, release.sh.
|
||||
#
|
||||
# Modes ship too, and one invariant is absolute: any shipped file whose
|
||||
# first two bytes are `#!` carries the executable bit in the tarball.
|
||||
# The artifact test enforces it with no exception list — a shebang is a
|
||||
# promise the file can be run.
|
||||
copy AGENTS.md
|
||||
copy CLAUDE.md
|
||||
copy README.md
|
||||
|
||||
@@ -55,6 +55,32 @@ def expected_files() -> set:
|
||||
return files
|
||||
|
||||
|
||||
def shebang_members(tarball: Path) -> dict:
|
||||
"""{member name: tar-header mode} for every file in the artifact whose
|
||||
content starts `#!`.
|
||||
|
||||
The tar header is the truth here, not the repo: git records only the
|
||||
exec bit, and release.sh stages through a copy where a umask could
|
||||
still lose it (task 21's risk)."""
|
||||
modes = {}
|
||||
with tarfile.open(tarball) as tar:
|
||||
for member in tar.getmembers():
|
||||
if not member.isfile():
|
||||
continue
|
||||
stream = tar.extractfile(member)
|
||||
if stream is None or stream.read(2) != b"#!":
|
||||
continue
|
||||
modes[member.name.removeprefix("./")] = member.mode
|
||||
return modes
|
||||
|
||||
|
||||
def shebang_files_missing_exec(tarball: Path) -> list:
|
||||
"""The invariant, in one place: a shipped file that starts `#!` and
|
||||
cannot be run. Anything this names is a bug."""
|
||||
return sorted(name for name, mode in shebang_members(tarball).items()
|
||||
if not mode & 0o100)
|
||||
|
||||
|
||||
def build_artifact(out: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["bash", str(REPO / "release.sh"), "--tarball", str(out),
|
||||
@@ -136,12 +162,39 @@ class ArtifactContents(unittest.TestCase):
|
||||
self.assertIn('BENCH_SOURCE_DEFAULT=""',
|
||||
(REPO / "update.sh").read_text("utf-8"))
|
||||
|
||||
def test_scripts_are_executable_in_the_tarball(self):
|
||||
for name in ("start.sh", "stop.sh", "update.sh",
|
||||
def test_every_shipped_shebang_file_is_executable(self):
|
||||
"""A shebang is a promise the file can be run. v0.1-alpha shipped
|
||||
install.py mode 644, so the README one-liner's `./install.py` was
|
||||
permission-denied on every install. The invariant is absolute — no
|
||||
exception list: a file that may not be run must not claim it can,
|
||||
and adding an exception here means editing this test with a reason.
|
||||
"""
|
||||
self.assertEqual([], shebang_files_missing_exec(self.tarball))
|
||||
# The sweep must actually have reached the scripts — an artifact
|
||||
# whose members read as empty would pass vacuously.
|
||||
seen = shebang_members(self.tarball)
|
||||
for name in ("install.py", "start.sh", "stop.sh", "update.sh",
|
||||
"manager/core/board.py",
|
||||
"manager/core/adapters/claude/run",
|
||||
"manager/core/adapters/claude/wire"):
|
||||
mode = self.members[name].mode
|
||||
self.assertTrue(mode & 0o100, f"{name} lost its executable bit")
|
||||
self.assertIn(name, seen,
|
||||
f"{name} was not seen as a shebang file")
|
||||
|
||||
def test_the_executable_invariant_catches_a_stripped_mode(self):
|
||||
"""The guard itself, proven to bite: repack the real artifact with
|
||||
install.py's mode stripped — exactly the v0.1-alpha shape — and the
|
||||
check must name it. Without this, a sweep that silently stopped
|
||||
finding shebangs would read as a clean tarball forever."""
|
||||
stripped = self.scratch / "mode-stripped.tar.gz"
|
||||
with tarfile.open(self.tarball) as src, \
|
||||
tarfile.open(stripped, "w:gz") as out:
|
||||
for member in src.getmembers():
|
||||
if member.name.removeprefix("./") == "install.py":
|
||||
member.mode = 0o644
|
||||
out.addfile(member, src.extractfile(member)
|
||||
if member.isfile() else None)
|
||||
|
||||
self.assertEqual(["install.py"], shebang_files_missing_exec(stripped))
|
||||
|
||||
|
||||
class ReleaseRefusals(unittest.TestCase):
|
||||
@@ -222,6 +275,19 @@ class ArtifactInstalls(unittest.TestCase):
|
||||
self.assertTrue((tm / "tasks" / "task-template.md").is_file())
|
||||
self.assertTrue((tm / "manager" / "local" / "state").is_dir())
|
||||
|
||||
def test_install_py_runs_directly_from_an_unpacked_release(self):
|
||||
"""The README's next step after unpacking is `./install.py`.
|
||||
v0.1-alpha shipped it mode 644, so that step was permission-denied
|
||||
on every install. Run as a program — no interpreter in front of it
|
||||
— so the exec bit is what is under test, unpacked and in place."""
|
||||
tm = self.make_install("runnable")
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("BOARD_", "BENCH_"))}
|
||||
result = subprocess.run([str(tm / "install.py")],
|
||||
capture_output=True, text=True,
|
||||
cwd=tm.parent, env=env)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_board_serves_from_an_unpacked_artifact(self):
|
||||
tm = self.make_install("serving")
|
||||
|
||||
|
||||
@@ -140,6 +140,22 @@ class UpdateFromRelease(unittest.TestCase):
|
||||
self.assertEqual((tm / path).read_bytes(), content,
|
||||
f"{path} must survive byte-identical")
|
||||
|
||||
def test_update_heals_a_non_executable_install_py(self):
|
||||
# The v0.1-alpha field report: installs unpacked from an artifact
|
||||
# that carried mode 644 stay broken by themselves, because `cp`
|
||||
# onto an existing file keeps the destination's mode. update.sh's
|
||||
# chmod line is what heals them — existing victims, not only fresh
|
||||
# installs.
|
||||
tm = self.make_install()
|
||||
(tm / "install.py").chmod(0o644)
|
||||
|
||||
result = self.run_update(tm)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertTrue(
|
||||
os.access(tm / "install.py", os.X_OK),
|
||||
"update.sh must restore install.py's executable bit")
|
||||
|
||||
def test_agents_brief_replaces_an_old_vendor_named_copy(self):
|
||||
# Ported from the retired test_update_round_trip.py (whose harness
|
||||
# targeted the removed git-clone mechanism): an install from the
|
||||
|
||||
Reference in New Issue
Block a user