Build bench releases from a manifest; update.sh consumes them
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>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# What a bench release ships — the whole list, one place. release.sh
|
||||
# builds the artifact from it; update.sh reads the copy of this file
|
||||
# inside the downloaded artifact to know which top-level files to
|
||||
# replace; the artifact test asserts the tarball matches it exactly.
|
||||
#
|
||||
# One entry per line, `<class> <path>`:
|
||||
#
|
||||
# copy <file> core-owned file from the repo — shipped, and replaced
|
||||
# by every update
|
||||
# tree <dir> directory shipped recursively from the repo, replaced
|
||||
# WHOLESALE by every update (__pycache__/.DS_Store
|
||||
# excluded at build)
|
||||
# once <file> shipped from the repo on install, then yours — updates
|
||||
# never touch it
|
||||
# keep <dir> shipped as an empty directory holding a .gitkeep —
|
||||
# updates never touch it
|
||||
# seed <file> starter content generated by release.sh (never present
|
||||
# in bench's own repo, which has its real one) — updates
|
||||
# never touch it
|
||||
#
|
||||
# Anything not listed here does not ship: bench's own task cards, its
|
||||
# manager/local/ content, local/state/, .claude/, tests/, release.sh.
|
||||
copy CLAUDE.md
|
||||
copy README.md
|
||||
copy install.py
|
||||
copy start.sh
|
||||
copy stop.sh
|
||||
copy update.sh
|
||||
tree manager/core
|
||||
once .gitignore
|
||||
once tasks/task-template.md
|
||||
keep tasks/backlog
|
||||
keep tasks/to-do
|
||||
keep tasks/in-progress
|
||||
keep tasks/review
|
||||
keep tasks/done
|
||||
keep tasks/archive
|
||||
keep plans
|
||||
keep reference
|
||||
keep manager/local/adapters
|
||||
keep manager/local/commands
|
||||
keep manager/local/driver
|
||||
keep manager/local/prompts
|
||||
seed manager/local/CLAUDE.md
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and publish a bench release from this repo.
|
||||
#
|
||||
# ./release.sh # tag v<VERSION> and publish
|
||||
# ./release.sh --tarball <out> [--source o/r] # build the artifact only
|
||||
#
|
||||
# The artifact is built from manager/core/release-manifest — a curated
|
||||
# tarball that never contained bench's own cards, local/ content, state,
|
||||
# tests or .claude/ in the first place. Its contents sit at the tarball
|
||||
# root and the asset name is stable (bench.tar.gz): both are what the
|
||||
# README's tokenless releases/latest/download/ one-liner depends on.
|
||||
#
|
||||
# Publishing refuses on a dirty tree and on an already-existing tag, so
|
||||
# tag v<VERSION> always names exactly one committed tree. Notes default
|
||||
# to the core version; set BENCH_RELEASE_NOTES to say more.
|
||||
#
|
||||
# This script is bench-repo tooling — the manifest deliberately leaves it
|
||||
# out of the artifact, so installs never carry it.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MANIFEST="$ROOT/manager/core/release-manifest"
|
||||
ASSET="bench.tar.gz"
|
||||
|
||||
# The starter manager/local/CLAUDE.md a fresh install unpacks — bench's
|
||||
# own repo has its real one, so this is generated, never copied.
|
||||
seed_local_claude_md() {
|
||||
cat <<'MD'
|
||||
# Project-specific workflow notes
|
||||
|
||||
This file is yours — updates never touch manager/local/. Put here what an
|
||||
agent or teammate needs that the core doc cannot know: post-merge chores,
|
||||
what the driver assumes, what each local command is for.
|
||||
|
||||
Settings live in manager/local/.env (gitignored); every option and its
|
||||
default is documented in manager/core/.env.example. A `checks` file here
|
||||
replaces core/checks as the Focus view's definition-of-done panel.
|
||||
MD
|
||||
}
|
||||
|
||||
# origin's URL as "owner/repo" — what gets stamped into the shipped
|
||||
# update.sh so a fresh install can update with zero configuration.
|
||||
source_repo() {
|
||||
local url
|
||||
url="$(git -C "$ROOT" remote get-url origin 2>/dev/null)" || return 1
|
||||
url="${url#ssh://}"
|
||||
url="${url#git@github.com:}"
|
||||
url="${url#https://github.com/}"
|
||||
url="${url#http://github.com/}"
|
||||
url="${url#github.com/}"
|
||||
url="${url%.git}"
|
||||
url="${url%/}"
|
||||
[ -n "$url" ] || return 1
|
||||
printf '%s\n' "$url"
|
||||
}
|
||||
|
||||
build_tarball() { # build_tarball <out.tar.gz> <source-repo-or-empty>
|
||||
local out="$1" source="$2" stage kind path
|
||||
stage="$(mktemp -d)"
|
||||
# shellcheck disable=SC2064 — expand $stage now, it is gone by EXIT
|
||||
trap "rm -rf '$stage'" RETURN
|
||||
|
||||
while read -r kind path _; do
|
||||
case "$kind" in
|
||||
copy|once)
|
||||
[ -f "$ROOT/$path" ] || { echo "manifest names a missing file: $path" >&2; return 1; }
|
||||
mkdir -p "$stage/$(dirname "$path")"
|
||||
cp "$ROOT/$path" "$stage/$path"
|
||||
;;
|
||||
tree)
|
||||
[ -d "$ROOT/$path" ] || { echo "manifest names a missing directory: $path" >&2; return 1; }
|
||||
mkdir -p "$stage/$path"
|
||||
rsync -a --exclude='__pycache__/' --exclude='.DS_Store' \
|
||||
"$ROOT/$path/" "$stage/$path/"
|
||||
;;
|
||||
keep)
|
||||
mkdir -p "$stage/$path"
|
||||
touch "$stage/$path/.gitkeep"
|
||||
;;
|
||||
seed)
|
||||
case "$path" in
|
||||
manager/local/CLAUDE.md)
|
||||
mkdir -p "$stage/$(dirname "$path")"
|
||||
seed_local_claude_md > "$stage/$path"
|
||||
;;
|
||||
*) echo "manifest seeds a file this script cannot generate: $path" >&2; return 1;;
|
||||
esac
|
||||
;;
|
||||
''|'#'*) ;;
|
||||
*) echo "manifest has an unknown entry class: $kind $path" >&2; return 1;;
|
||||
esac
|
||||
done < "$MANIFEST"
|
||||
|
||||
if [ -n "$source" ]; then
|
||||
sed "s|^BENCH_SOURCE_DEFAULT=.*|BENCH_SOURCE_DEFAULT=\"$source\"|" \
|
||||
"$stage/update.sh" > "$stage/update.sh.tmp"
|
||||
mv "$stage/update.sh.tmp" "$stage/update.sh"
|
||||
fi
|
||||
chmod +x "$stage"/start.sh "$stage"/stop.sh "$stage"/update.sh
|
||||
find "$stage/manager/core/adapters" \( -name run -o -name wire \) \
|
||||
-exec chmod +x {} + 2>/dev/null || true
|
||||
|
||||
# Contents at the tarball root — extraction into .task-manager/ must
|
||||
# not land one directory too deep. COPYFILE_DISABLE keeps macOS tar
|
||||
# from smuggling AppleDouble ._* entries into the artifact.
|
||||
COPYFILE_DISABLE=1 tar -czf "$out" -C "$stage" .
|
||||
}
|
||||
|
||||
# --tarball mode: just build, no git/gh — for tests and inspection.
|
||||
if [ "${1:-}" = "--tarball" ]; then
|
||||
out="${2:?usage: ./release.sh --tarball <out.tar.gz> [--source owner/repo]}"
|
||||
source=""
|
||||
[ "${3:-}" = "--source" ] && source="${4:?--source needs owner/repo}"
|
||||
[ -n "$source" ] || source="$(source_repo || true)"
|
||||
build_tarball "$out" "$source"
|
||||
echo "Built $out (source stamp: ${source:-none})."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Publish mode from here on: clean committed tree, fresh tag, gh.
|
||||
version="$(cat "$ROOT/manager/core/VERSION")"
|
||||
tag="v$version"
|
||||
|
||||
if [ -n "$(git -C "$ROOT" status --porcelain)" ]; then
|
||||
echo "Working tree is dirty — commit (or stash) first; a release must be reproducible from its tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
if git -C "$ROOT" rev-parse -q --verify "refs/tags/$tag" >/dev/null \
|
||||
|| [ -n "$(git -C "$ROOT" ls-remote --tags origin "refs/tags/$tag" 2>/dev/null)" ]; then
|
||||
echo "Tag $tag already exists — bump manager/core/VERSION before releasing." >&2
|
||||
exit 1
|
||||
fi
|
||||
source="$(source_repo)" || { echo "No origin remote to publish to." >&2; exit 1; }
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
build_tarball "$tmp/$ASSET" "$source"
|
||||
|
||||
git -C "$ROOT" tag "$tag"
|
||||
git -C "$ROOT" push origin "$tag"
|
||||
gh release create "$tag" --repo "$source" --verify-tag \
|
||||
--title "bench $tag" \
|
||||
--notes "${BENCH_RELEASE_NOTES:-bench core version $version}" \
|
||||
"$tmp/$ASSET"
|
||||
echo "Published $tag ($source): asset $ASSET."
|
||||
@@ -0,0 +1,221 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,189 @@
|
||||
"""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()
|
||||
@@ -1,49 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
# Update the task manager from its distribution repo.
|
||||
# Update the task manager from its published releases.
|
||||
#
|
||||
# ./.task-manager/update.sh
|
||||
# ./.task-manager/update.sh # latest release
|
||||
# BENCH_REF=v3 ./.task-manager/update.sh # an exact release tag
|
||||
#
|
||||
# Replaces manager/core/ WHOLESALE plus the top-level core-owned files
|
||||
# (CLAUDE.md, install.py, start.sh, stop.sh, update.sh). Never touches
|
||||
# Downloads the bench.tar.gz asset of a GitHub Release — a curated
|
||||
# artifact that never contained the distribution's own cards or local/
|
||||
# content — then replaces manager/core/ WHOLESALE plus the top-level
|
||||
# core-owned files named by the artifact's own manifest. Never touches
|
||||
# tasks/, plans/, reference/, or manager/local/ — your project's tasks,
|
||||
# driver, adapters, prompt overrides, .env and state survive every update.
|
||||
#
|
||||
# Source repo: BENCH_SOURCE in manager/local/.env (a git URL).
|
||||
# Source repo: BENCH_SOURCE (environment, then manager/local/.env),
|
||||
# falling back to the default below. No published release → this script
|
||||
# says so and changes nothing; there is no git fallback. Developers
|
||||
# working on bench itself should clone the repo and pull instead.
|
||||
set -euo pipefail
|
||||
{
|
||||
|
||||
# Stamped by release.sh at build time with the repo the artifact was
|
||||
# built from ("owner/repo"), so a fresh install updates with zero
|
||||
# configuration. Empty in a git checkout — set BENCH_SOURCE to override.
|
||||
BENCH_SOURCE_DEFAULT=""
|
||||
|
||||
ASSET="bench.tar.gz"
|
||||
TM="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="$TM/manager/local/.env"
|
||||
|
||||
src="${BENCH_SOURCE:-}"
|
||||
if [ -z "$src" ] && [ -f "$ENV_FILE" ]; then
|
||||
src="$(sed -n 's/^[[:space:]]*BENCH_SOURCE[[:space:]]*=[[:space:]]*//p' "$ENV_FILE" | tail -1 | tr -d "'\"")"
|
||||
fi
|
||||
read_env() { # read_env KEY — last value in local/.env, quotes stripped
|
||||
[ -f "$ENV_FILE" ] || return 0
|
||||
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//p" "$ENV_FILE" \
|
||||
| tail -1 | tr -d "'\"" || true
|
||||
}
|
||||
|
||||
src="${BENCH_SOURCE:-$(read_env BENCH_SOURCE)}"
|
||||
src="${src:-$BENCH_SOURCE_DEFAULT}"
|
||||
if [ -z "$src" ]; then
|
||||
echo "No source repo configured — set BENCH_SOURCE=<git url> in manager/local/.env" >&2
|
||||
echo "No release source known: this update.sh carries no build stamp (a git" >&2
|
||||
echo "checkout rather than a release?) and BENCH_SOURCE is not set. Either" >&2
|
||||
echo "set BENCH_SOURCE=<owner/repo> in manager/local/.env, or — if you are" >&2
|
||||
echo "developing bench itself — update the clone with git instead." >&2
|
||||
exit 1
|
||||
fi
|
||||
# A specific release: BENCH_REF=v3 ./update.sh (any tag or branch; default = latest main)
|
||||
ref="${BENCH_REF:-}"
|
||||
|
||||
before="$(cat "$TM/manager/core/VERSION" 2>/dev/null || echo '?')"
|
||||
# Normalize any GitHub remote spelling to "owner/repo" — releases are a
|
||||
# GitHub mechanism, so that is the one shape both gh and curl need.
|
||||
repo="$src"
|
||||
repo="${repo#ssh://}"
|
||||
repo="${repo#git@github.com:}"
|
||||
repo="${repo#https://github.com/}"
|
||||
repo="${repo#http://github.com/}"
|
||||
repo="${repo#github.com/}"
|
||||
repo="${repo%.git}"
|
||||
repo="${repo%/}"
|
||||
case "$repo" in
|
||||
*://*|*@*|*:*)
|
||||
echo "BENCH_SOURCE=$src is not a GitHub repo — releases need an owner/repo on github.com." >&2
|
||||
exit 1;;
|
||||
*/*) ;;
|
||||
*)
|
||||
echo "BENCH_SOURCE=$src is not a GitHub repo — expected the owner/repo form." >&2
|
||||
exit 1;;
|
||||
esac
|
||||
|
||||
ref="${BENCH_REF:-}"
|
||||
gh_bin="${BOARD_GH_BIN:-$(read_env BOARD_GH_BIN)}"
|
||||
gh_bin="${gh_bin:-gh}"
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
echo "Fetching $src ${ref:+(ref $ref) }…"
|
||||
git clone --quiet --depth 1 ${ref:+--branch "$ref"} "$src" "$tmp/dist"
|
||||
asset="$tmp/$ASSET"
|
||||
tag=""
|
||||
|
||||
if [ ! -d "$tmp/dist/manager/core" ]; then
|
||||
echo "That repo does not look like a task-manager distribution (no manager/core/)." >&2
|
||||
# The tokenless path: GitHub redirects releases/latest to the tag page,
|
||||
# which is how the latest tag is learned without the API's rate limits.
|
||||
latest_tag_curl() {
|
||||
local final
|
||||
final="$(curl -fsSLo /dev/null -w '%{url_effective}' \
|
||||
"https://github.com/$repo/releases/latest" 2>/dev/null)" || return 1
|
||||
case "$final" in
|
||||
*/releases/tag/*) printf '%s\n' "${final##*/releases/tag/}";;
|
||||
*) return 1;;
|
||||
esac
|
||||
}
|
||||
|
||||
fetch_release() { # sets $tag and downloads $asset; 1 = nothing published
|
||||
if command -v "$gh_bin" >/dev/null 2>&1; then
|
||||
if [ -n "$ref" ]; then tag="$ref"; else
|
||||
tag="$("$gh_bin" release view --repo "$repo" --json tagName \
|
||||
--jq .tagName 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -n "$tag" ] && "$gh_bin" release download "$tag" --repo "$repo" \
|
||||
--pattern "$ASSET" --output "$asset" --clobber 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
tag=""
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if [ -n "$ref" ]; then tag="$ref"; else
|
||||
tag="$(latest_tag_curl || true)"
|
||||
fi
|
||||
if [ -n "$tag" ] && curl -fsSL -o "$asset" \
|
||||
"https://github.com/$repo/releases/download/$tag/$ASSET" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "Fetching ${ref:-the latest release} of ${repo}…"
|
||||
if ! fetch_release; then
|
||||
echo "No published release found for $repo${ref:+ at tag $ref}." >&2
|
||||
echo "The repo has no release yet (or none reachable from here — private" >&2
|
||||
echo "repos need gh auth). Ask its maintainer to run ./release.sh; if you" >&2
|
||||
echo "are developing bench itself, clone the repo and use git instead." >&2
|
||||
echo "Nothing was changed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rsync -a --delete "$tmp/dist/manager/core/" "$TM/manager/core/"
|
||||
for f in CLAUDE.md README.md install.py start.sh stop.sh update.sh; do
|
||||
[ -f "$tmp/dist/$f" ] && cp "$tmp/dist/$f" "$TM/$f"
|
||||
done
|
||||
mkdir "$tmp/dist"
|
||||
tar -xzf "$asset" -C "$tmp/dist"
|
||||
dist="$tmp/dist"
|
||||
manifest="$dist/manager/core/release-manifest"
|
||||
if [ ! -d "$dist/manager/core" ] || [ ! -f "$manifest" ]; then
|
||||
echo "The $tag asset does not look like a bench release (no manager/core/release-manifest). Nothing was changed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Tag and contents must agree — a mismatch means a mislabeled asset, and
|
||||
# installing it would leave a version number that lies about the code.
|
||||
artifact_version="$(cat "$dist/manager/core/VERSION" 2>/dev/null || echo '?')"
|
||||
if [ "v$artifact_version" != "$tag" ]; then
|
||||
echo "Release $tag contains core VERSION $artifact_version — the asset disagrees with its tag. Refusing to install it; nothing was changed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
before="$(cat "$TM/manager/core/VERSION" 2>/dev/null || echo '?')"
|
||||
rsync -a --delete "$dist/manager/core/" "$TM/manager/core/"
|
||||
# The artifact's manifest names the top-level core-owned files (class
|
||||
# `copy`) — read the new list, so a release adding a script updates it.
|
||||
while read -r kind path _; do
|
||||
[ "$kind" = "copy" ] || continue
|
||||
[ -f "$dist/$path" ] && cp "$dist/$path" "$TM/$path"
|
||||
done < "$manifest"
|
||||
chmod +x "$TM"/start.sh "$TM"/stop.sh "$TM"/update.sh 2>/dev/null || true
|
||||
find "$TM/manager/core/adapters" -name run -o -name wire | xargs chmod +x 2>/dev/null || true
|
||||
|
||||
after="$(cat "$TM/manager/core/VERSION" 2>/dev/null || echo '?')"
|
||||
echo "Updated core: version $before → $after."
|
||||
echo "Updated core from $tag: version $before → $after."
|
||||
echo "Now run: python3 $TM/install.py (re-wires the project; idempotent)"
|
||||
echo "Then restart the board: $TM/stop.sh && $TM/start.sh"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user