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>
190 lines
7.4 KiB
Python
190 lines
7.4 KiB
Python
"""update.sh consumes published releases: replace manager/core/ wholesale
|
|
plus the manifest's top-level files, touch nothing else, and refuse loudly
|
|
— changing nothing — when there is no release or the asset lies about its
|
|
version. Hermetic: gh and curl are PATH stubs, the "release" is a real
|
|
artifact built by release.sh, and the installed project is a real unpack
|
|
of it.
|
|
|
|
python3 -m unittest discover -s tests
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
|
|
GH_STUB = """\
|
|
#!/usr/bin/env bash
|
|
# gh stand-in: serves $STUB_TARBALL as the one release, tagged $STUB_TAG.
|
|
[ "${STUB_FAIL:-}" = "1" ] && exit 1
|
|
case "${1:-} ${2:-}" in
|
|
"release view")
|
|
printf '%s\\n' "${STUB_TAG:?}"
|
|
;;
|
|
"release download")
|
|
out=""
|
|
prev=""
|
|
for arg in "$@"; do
|
|
[ "$prev" = "--output" ] && out="$arg"
|
|
prev="$arg"
|
|
done
|
|
cp "${STUB_TARBALL:?}" "${out:?}"
|
|
;;
|
|
*) exit 1 ;;
|
|
esac
|
|
"""
|
|
|
|
CURL_STUB = """\
|
|
#!/usr/bin/env bash
|
|
exit 22
|
|
"""
|
|
|
|
|
|
def snapshot(root: Path) -> dict:
|
|
return {p.relative_to(root).as_posix(): p.read_bytes()
|
|
for p in root.rglob("*") if p.is_file()}
|
|
|
|
|
|
class UpdateFromRelease(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.scratch = Path(tempfile.mkdtemp(prefix="bench-update-")).resolve()
|
|
cls.tarball = cls.scratch / "bench.tar.gz"
|
|
result = subprocess.run(
|
|
["bash", str(REPO / "release.sh"), "--tarball", str(cls.tarball),
|
|
"--source", "example/bench"],
|
|
capture_output=True, text=True)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
cls.version = (REPO / "manager" / "core" / "VERSION").read_text().strip()
|
|
|
|
cls.stubs = cls.scratch / "bin"
|
|
cls.stubs.mkdir()
|
|
for name, body in (("gh", GH_STUB), ("curl", CURL_STUB)):
|
|
stub = cls.stubs / name
|
|
stub.write_text(body, encoding="utf-8")
|
|
stub.chmod(0o755)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
shutil.rmtree(cls.scratch, ignore_errors=True)
|
|
|
|
def make_install(self) -> Path:
|
|
"""A host project installed from the artifact, then lived in:
|
|
its own card, settings, checks, notes and state."""
|
|
host = Path(tempfile.mkdtemp(prefix="host-", dir=self.scratch))
|
|
tm = host / ".task-manager"
|
|
tm.mkdir()
|
|
with tarfile.open(self.tarball) as tar:
|
|
tar.extractall(tm)
|
|
(tm / "tasks" / "backlog" / "20-host-card.md").write_text(
|
|
"# The host's own\n", encoding="utf-8")
|
|
local = tm / "manager" / "local"
|
|
(local / ".env").write_text("BOARD_PORT=26071\n", encoding="utf-8")
|
|
(local / "checks").write_text("mine: \\bmine\\b\n", encoding="utf-8")
|
|
(local / "CLAUDE.md").write_text("# Host notes\n", encoding="utf-8")
|
|
(local / "state" / "sessions").mkdir(parents=True)
|
|
(local / "state" / "sessions" / "s1.jsonl").write_text(
|
|
'{"event":"kept"}\n', encoding="utf-8")
|
|
(tm / "tasks" / "task-template.md").write_text(
|
|
"# My own template\n", encoding="utf-8")
|
|
return tm
|
|
|
|
def run_update(self, tm: Path, **extra: str) -> subprocess.CompletedProcess:
|
|
env = {k: v for k, v in os.environ.items()
|
|
if not k.startswith(("BOARD_", "BENCH_", "STUB_"))}
|
|
env["PATH"] = f"{self.stubs}{os.pathsep}{env.get('PATH', '')}"
|
|
env.setdefault("STUB_TAG", f"v{self.version}")
|
|
env.setdefault("STUB_TARBALL", str(self.tarball))
|
|
env.update(extra)
|
|
return subprocess.run(["bash", str(tm / "update.sh")],
|
|
capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace",
|
|
cwd=tm.parent, env=env)
|
|
|
|
def test_update_replaces_core_and_top_level_and_nothing_else(self):
|
|
tm = self.make_install()
|
|
# An "older install": stale core content and doctored top-level
|
|
# files that the release must put back.
|
|
(tm / "manager" / "core" / "VERSION").write_text("0\n")
|
|
(tm / "manager" / "core" / "stale.py").write_text("gone = True\n")
|
|
(tm / "start.sh").write_text("#!/bin/sh\necho old\n")
|
|
survivors = {
|
|
path: (tm / path).read_bytes()
|
|
for path in ["tasks/backlog/20-host-card.md",
|
|
"manager/local/.env", "manager/local/checks",
|
|
"manager/local/CLAUDE.md",
|
|
"manager/local/state/sessions/s1.jsonl",
|
|
"tasks/task-template.md"]}
|
|
|
|
result = self.run_update(tm)
|
|
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn(f"version 0 → {self.version}", result.stdout)
|
|
self.assertFalse((tm / "manager" / "core" / "stale.py").exists(),
|
|
"core must be replaced wholesale")
|
|
with tarfile.open(self.tarball) as tar:
|
|
for name in ("manager/core/VERSION", "manager/core/board.py",
|
|
"start.sh", "update.sh", "CLAUDE.md"):
|
|
shipped = tar.extractfile(f"./{name}").read()
|
|
self.assertEqual((tm / name).read_bytes(), shipped,
|
|
f"{name} must match the release")
|
|
for path, content in survivors.items():
|
|
self.assertEqual((tm / path).read_bytes(), content,
|
|
f"{path} must survive byte-identical")
|
|
|
|
def test_no_published_release_changes_nothing(self):
|
|
tm = self.make_install()
|
|
before = snapshot(tm)
|
|
|
|
result = self.run_update(tm, STUB_FAIL="1")
|
|
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("No published release found for example/bench",
|
|
result.stderr)
|
|
self.assertIn("Nothing was changed", result.stderr)
|
|
self.assertEqual(snapshot(tm), before)
|
|
|
|
def test_version_tag_disagreement_is_refused(self):
|
|
tm = self.make_install()
|
|
before = snapshot(tm)
|
|
|
|
result = self.run_update(tm, BENCH_REF="v999")
|
|
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn(f"contains core VERSION {self.version}", result.stderr)
|
|
self.assertEqual(snapshot(tm), before)
|
|
|
|
def test_exact_tag_via_bench_ref(self):
|
|
tm = self.make_install()
|
|
result = self.run_update(tm, BENCH_REF=f"v{self.version}")
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertIn(f"Updated core from v{self.version}", result.stdout,
|
|
result.stdout + result.stderr)
|
|
|
|
def test_env_beats_stamp_and_dev_checkout_has_no_source(self):
|
|
# The repo's own update.sh is unstamped: with no BENCH_SOURCE
|
|
# anywhere it must refuse and say how to configure one.
|
|
tm = self.scratch / "dev-checkout"
|
|
tm.mkdir()
|
|
shutil.copy(REPO / "update.sh", tm / "update.sh")
|
|
result = self.run_update(tm)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("BENCH_SOURCE", result.stderr)
|
|
|
|
# BENCH_SOURCE (any GitHub spelling) resurrects it — the stub
|
|
# then serves the release as usual.
|
|
(tm / "manager" / "core").mkdir(parents=True)
|
|
result = self.run_update(
|
|
tm, BENCH_SOURCE="git@github.com:example/bench.git")
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
self.assertTrue((tm / "manager" / "core" / "board.py").is_file())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|