diff --git a/manager/core/release-manifest b/manager/core/release-manifest new file mode 100644 index 0000000..e855dd1 --- /dev/null +++ b/manager/core/release-manifest @@ -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, ` `: +# +# copy core-owned file from the repo — shipped, and replaced +# by every update +# tree directory shipped recursively from the repo, replaced +# WHOLESALE by every update (__pycache__/.DS_Store +# excluded at build) +# once shipped from the repo on install, then yours — updates +# never touch it +# keep shipped as an empty directory holding a .gitkeep — +# updates never touch it +# seed 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 diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..a4ef025 --- /dev/null +++ b/release.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Build and publish a bench release from this repo. +# +# ./release.sh # tag v and publish +# ./release.sh --tarball [--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 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 + 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 [--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." diff --git a/tests/test_release_artifact.py b/tests/test_release_artifact.py new file mode 100644 index 0000000..c6e39a8 --- /dev/null +++ b/tests/test_release_artifact.py @@ -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() diff --git a/tests/test_update_from_release.py b/tests/test_update_from_release.py new file mode 100644 index 0000000..df10ed8 --- /dev/null +++ b/tests/test_update_from_release.py @@ -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() diff --git a/update.sh b/update.sh index 9d5f237..c04fa8b 100755 --- a/update.sh +++ b/update.sh @@ -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= 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= 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 +}