The repo is two things at once — bench-the-project and bench-the- distribution — and install/update conflated them by cloning the repo and subtracting what should not have come along. Invert it: one curated artifact, correct by construction. - manager/core/release-manifest: the whole shipping list in one place (copy/tree/once/keep/seed classes). update.sh's hardcoded top-level file list, promoted. - release.sh: builds bench.tar.gz from the manifest (contents at the tarball root, stable asset name — the tokenless latest-release URL depends on both), stamps the source repo into the shipped update.sh, refuses on dirty tree or existing tag, tags v<VERSION> and publishes via gh release create. Never ships in the artifact. - update.sh: downloads the latest release (BENCH_REF pins a tag) via gh with an anonymous curl fallback; refuses when the asset's VERSION disagrees with its tag; replaces manager/core/ wholesale plus the artifact manifest's `copy` files; touches nothing else. No release published -> says so and changes nothing; no silent git fallback. - tests: the tarball equals exactly the manifest (no cards, no local/ content beyond the generated starter, no state/tests/.claude), the artifact installs pristine and boots the board, and update.sh's replace/survive/refuse paths run hermetically against PATH-stubbed gh and curl. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
222 lines
8.7 KiB
Python
222 lines
8.7 KiB
Python
"""release.sh builds the distribution artifact from the manifest at
|
|
manager/core/release-manifest — and from nothing else. The tarball must
|
|
contain exactly what the manifest names (correct by construction: bench's
|
|
own cards, local/ content, state, tests and .claude/ were never in it),
|
|
sit at the tarball root, and unpack into a working, pristine install.
|
|
|
|
python3 -m unittest discover -s tests
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
MANIFEST = REPO / "manager" / "core" / "release-manifest"
|
|
STAMP_SOURCE = "example/bench"
|
|
|
|
|
|
def manifest_entries() -> list:
|
|
entries = []
|
|
for line in MANIFEST.read_text(encoding="utf-8").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
kind, path = line.split(None, 1)
|
|
entries.append((kind, path))
|
|
return entries
|
|
|
|
|
|
def expected_files() -> set:
|
|
"""The artifact's exact file list, derived from the manifest the same
|
|
way release.sh builds it — the tripwire for manifest drift."""
|
|
files = set()
|
|
for kind, path in manifest_entries():
|
|
if kind in ("copy", "once", "seed"):
|
|
files.add(path)
|
|
elif kind == "keep":
|
|
files.add(f"{path}/.gitkeep")
|
|
elif kind == "tree":
|
|
for p in (REPO / path).rglob("*"):
|
|
if (p.is_file() and "__pycache__" not in p.parts
|
|
and p.name != ".DS_Store"):
|
|
files.add(p.relative_to(REPO).as_posix())
|
|
else:
|
|
raise AssertionError(f"unknown manifest class {kind}")
|
|
return files
|
|
|
|
|
|
def build_artifact(out: Path) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
["bash", str(REPO / "release.sh"), "--tarball", str(out),
|
|
"--source", STAMP_SOURCE],
|
|
capture_output=True, text=True)
|
|
|
|
|
|
class ArtifactContents(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.scratch = Path(tempfile.mkdtemp(prefix="bench-artifact-")).resolve()
|
|
cls.tarball = cls.scratch / "bench.tar.gz"
|
|
result = build_artifact(cls.tarball)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
with tarfile.open(cls.tarball) as tar:
|
|
cls.members = {m.name.removeprefix("./"): m
|
|
for m in tar.getmembers()}
|
|
cls.files = {name for name, m in cls.members.items() if m.isfile()}
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
shutil.rmtree(cls.scratch, ignore_errors=True)
|
|
|
|
def read_member(self, name: str) -> str:
|
|
with tarfile.open(self.tarball) as tar:
|
|
member = tar.extractfile(f"./{name}") or tar.extractfile(name)
|
|
return member.read().decode("utf-8")
|
|
|
|
def test_tarball_is_exactly_the_manifest(self):
|
|
self.assertEqual(self.files, expected_files())
|
|
|
|
def test_contents_sit_at_the_tarball_root(self):
|
|
"""The README one-liner pipes into `tar -xz -C .task-manager` —
|
|
a version-named wrapper directory would land it one level deep."""
|
|
self.assertIn("manager/core/VERSION", self.files)
|
|
self.assertIn("start.sh", self.files)
|
|
|
|
def test_none_of_benchs_own_state_ships(self):
|
|
stages = ["backlog", "to-do", "in-progress", "review", "done",
|
|
"archive"]
|
|
for name in self.files:
|
|
for stage in stages:
|
|
if name.startswith(f"tasks/{stage}/"):
|
|
self.assertEqual(name, f"tasks/{stage}/.gitkeep",
|
|
f"a task card shipped: {name}")
|
|
for top in ("plans/", "reference/"):
|
|
if name.startswith(top):
|
|
self.assertEqual(name, f"{top}.gitkeep",
|
|
f"content shipped under {top}: {name}")
|
|
for forbidden in ("tests/", ".claude/", ".git/", ".worktrees/",
|
|
"manager/local/state"):
|
|
self.assertFalse(name.startswith(forbidden),
|
|
f"{forbidden} leaked into the artifact: {name}")
|
|
self.assertNotIn("release.sh", self.files)
|
|
self.assertNotIn("manager/local/checks", self.files)
|
|
self.assertNotIn("manager/local/.env", self.files)
|
|
|
|
def test_local_is_the_generated_starter_not_benchs_own(self):
|
|
seeded = self.read_member("manager/local/CLAUDE.md")
|
|
self.assertIn("This file is yours", seeded)
|
|
self.assertNotEqual(
|
|
seeded,
|
|
(REPO / "manager" / "local" / "CLAUDE.md").read_text("utf-8"),
|
|
"bench's own local notes must never ship")
|
|
for sub in ("adapters", "commands", "driver", "prompts"):
|
|
self.assertIn(f"manager/local/{sub}/.gitkeep", self.files)
|
|
|
|
def test_shipped_update_sh_is_stamped_with_the_source(self):
|
|
self.assertIn(f'BENCH_SOURCE_DEFAULT="{STAMP_SOURCE}"',
|
|
self.read_member("update.sh"))
|
|
# The repo's own copy stays unstamped — dev clones must not
|
|
# silently update from anywhere.
|
|
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",
|
|
"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")
|
|
|
|
|
|
class ArtifactInstalls(unittest.TestCase):
|
|
"""Unpacking a release as .task-manager/ is the install: first boot
|
|
has nothing to scrub, and the board serves from it."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.scratch = Path(tempfile.mkdtemp(prefix="bench-install-")).resolve()
|
|
cls.tarball = cls.scratch / "bench.tar.gz"
|
|
result = build_artifact(cls.tarball)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
shutil.rmtree(cls.scratch, ignore_errors=True)
|
|
|
|
def make_install(self, name: str) -> Path:
|
|
host = self.scratch / name
|
|
(host / ".claude").mkdir(parents=True)
|
|
tm = host / ".task-manager"
|
|
tm.mkdir()
|
|
with tarfile.open(self.tarball) as tar:
|
|
tar.extractall(tm)
|
|
return tm
|
|
|
|
def test_first_boot_finds_nothing_to_clean(self):
|
|
tm = self.make_install("pristine")
|
|
env = {k: v for k, v in os.environ.items()
|
|
if not k.startswith(("BOARD_", "BENCH_"))}
|
|
result = subprocess.run(
|
|
[sys.executable, str(tm / "install.py")],
|
|
capture_output=True, text=True, cwd=tm.parent, env=env)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertNotIn("removed", result.stdout)
|
|
self.assertTrue((tm / "tasks" / "task-template.md").is_file())
|
|
self.assertTrue((tm / "manager" / "local" / "state").is_dir())
|
|
|
|
def test_board_serves_from_an_unpacked_artifact(self):
|
|
tm = self.make_install("serving")
|
|
|
|
probe = socket.socket()
|
|
probe.bind(("127.0.0.1", 0))
|
|
port = probe.getsockname()[1]
|
|
probe.close()
|
|
|
|
env = {k: v for k, v in os.environ.items()
|
|
if not k.startswith(("BOARD_", "BENCH_"))}
|
|
proc = subprocess.Popen(
|
|
[sys.executable, str(tm / "manager" / "core" / "board.py"),
|
|
"--port", str(port), "--no-open"],
|
|
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
try:
|
|
state = None
|
|
for _ in range(50):
|
|
if proc.poll() is not None:
|
|
break
|
|
try:
|
|
with urllib.request.urlopen(
|
|
f"http://127.0.0.1:{port}/api/state",
|
|
timeout=1) as response:
|
|
state = json.load(response)
|
|
break
|
|
except OSError:
|
|
time.sleep(0.2)
|
|
if proc.poll() is not None:
|
|
out, err = proc.communicate()
|
|
self.fail(f"board died (rc={proc.returncode}):\n{out}\n{err}")
|
|
self.assertIsNotNone(state, "board never answered /api/state")
|
|
# Whatever the payload shape, the response must not mention
|
|
# bench's own shipped cards.
|
|
self.assertNotIn("install-ships-pristine-board",
|
|
json.dumps(state))
|
|
finally:
|
|
proc.terminate()
|
|
proc.wait(timeout=10)
|
|
for stream in (proc.stdout, proc.stderr):
|
|
if stream:
|
|
stream.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|