feat(installer): select immutable release bundles
prd-number-check / require-numbered-prds (pull_request) Successful in 11s
lint / lint (push) Successful in 1m2s
test / unit (pull_request) Successful in 58s
test / integration-docker (pull_request) Successful in 1m3s
test / image-input-builds (pull_request) Successful in 1m0s
tracker-policy-pr / check-pr (pull_request) Successful in 12s
test / coverage (pull_request) Failing after 20s

This commit is contained in:
2026-07-27 17:10:19 +00:00
parent 249eaff53d
commit efa1c8ee4b
3 changed files with 137 additions and 6 deletions
+11 -2
View File
@@ -75,7 +75,15 @@ When the agent exits, `cli.py` tears down every gateway and both networks; nothi
curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh
```
The installer is a bootstrapper: it finds a suitable Python, installs bot-bottle with `pipx` (falling back to `pip --user`), creates `~/.bot-bottle`, and runs `bot-bottle doctor`. It is idempotent and never uses `sudo`. Python-native users can skip it entirely with `pipx install bot-bottle` or `uv tool install bot-bottle`.
The installer is a bootstrapper: it finds a suitable Python, resolves the
latest qualified production bundle, verifies its wheel checksum, installs with
`pipx` (falling back to a private venv), creates `~/.bot-bottle`, and runs
`bot-bottle doctor`. It is idempotent and never uses `sudo`.
Select staging with `BOT_BOTTLE_CHANNEL=staging`, an exact qualified release
with `BOT_BOTTLE_VERSION=vX.Y.Z[-rc.N]`, or an exact published commit with
`BOT_BOTTLE_REF=<40-character-sha>`. Commit installs print an explicit warning
because snapshots may not have passed release qualification.
### Requirements
@@ -83,7 +91,8 @@ The installer is a bootstrapper: it finds a suitable Python, installs bot-bottle
**No `pipx` required.** If `pipx` is present the installer uses it and stays out of the way. If it isn't, bot-bottle installs into a private venv at `~/.bot-bottle/venv` (override with `BOT_BOTTLE_VENV`) and symlinks the entry point into `~/.local/bin`. There is deliberately no `pip install --user` path: Homebrew, python.org and Debian/Ubuntu interpreters are all externally managed (PEP 668), which blocks `--user` outright — so on a Mac it is never the fallback it appears to be. A venv is exempt from PEP 668, and `venv` is stdlib, so unlike `pipx` there is nothing to bootstrap first.
**`git`**, because the default install spec is a `git+` URL. Set `BOT_BOTTLE_INSTALL_SPEC` to a wheel path or index name to avoid it.
**`git`** is needed only when `BOT_BOTTLE_INSTALL_SPEC` explicitly selects a
`git+` URL. The normal published-wheel path does not require it.
**A backend**, which the installer deliberately does *not* install for you — `doctor` reports what's missing afterwards. On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The Firecracker backend (Linux) requires Docker on the host for the gateway plus the `firecracker` binary and KVM. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
+111 -4
View File
@@ -17,10 +17,13 @@
# Env:
# BOT_BOTTLE_PYTHON interpreter to install with (skips the search)
# BOT_BOTTLE_INSTALL_SPEC pip/git spec to install instead of the default
# BOT_BOTTLE_CHANNEL production (default) or staging
# BOT_BOTTLE_VERSION exact qualified vX.Y.Z[-rc.N] release
# BOT_BOTTLE_REF exact 40-character commit snapshot (warns untested)
# BOT_BOTTLE_VENV where the non-pipx install lives (~/.bot-bottle/venv)
set -eu
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-git+https://gitea.dideric.is/didericis/bot-bottle.git}"
PACKAGE_SPEC="${BOT_BOTTLE_INSTALL_SPEC:-}"
VENV="${BOT_BOTTLE_VENV:-${HOME}/.bot-bottle/venv}"
MIN_PYTHON_MAJOR=3
MIN_PYTHON_MINOR=11
@@ -126,9 +129,113 @@ if [ "${PYTHON}" != "${path_python}" ]; then
fi
fi
# Installing a `git+` spec (the default) shells out to git under the hood,
# whether via pipx or pip. Fail early with a clear message rather than deep
# inside the installer's output.
# --- resolve an immutable published wheel -----------------------------------
if [ -z "${PACKAGE_SPEC}" ]; then
selectors=0
[ -n "${BOT_BOTTLE_REF:-}" ] && selectors=$((selectors + 1))
[ -n "${BOT_BOTTLE_VERSION:-}" ] && selectors=$((selectors + 1))
[ -n "${BOT_BOTTLE_CHANNEL:-}" ] && selectors=$((selectors + 1))
[ "${selectors}" -le 1 ] || die \
"BOT_BOTTLE_REF, BOT_BOTTLE_VERSION, and BOT_BOTTLE_CHANNEL are mutually exclusive."
downloads="${HOME}/.bot-bottle/downloads"
mkdir -p "${downloads}"
PACKAGE_SPEC="$("${PYTHON}" - \
"${BOT_BOTTLE_REF:-}" \
"${BOT_BOTTLE_VERSION:-}" \
"${BOT_BOTTLE_CHANNEL:-production}" \
"${downloads}" <<'PY'
import hashlib
import json
import re
import sys
import urllib.request
from pathlib import Path
ref, version, channel, download_root = sys.argv[1:]
base = "https://gitea.dideric.is/api/packages/didericis/generic"
def fetch_json(url):
with urllib.request.urlopen(url, timeout=30) as response:
return json.load(response)
if ref:
if re.fullmatch(r"[0-9a-f]{40}", ref) is None:
raise SystemExit("bot-bottle install: error: BOT_BOTTLE_REF must be 40 lowercase hex")
index_url = f"{base}/bot-bottle-builds/{ref}/bundle-index.json"
print(
"bot-bottle install: WARNING: installing an exact commit snapshot; "
"it may not have passed release qualification.",
file=sys.stderr,
)
selector = f"commit {ref}"
else:
if version:
if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-rc\.[0-9]+)?", version) is None:
raise SystemExit("bot-bottle install: error: invalid BOT_BOTTLE_VERSION")
pointer_url = f"{base}/bot-bottle-releases/{version}/release.json"
selector = f"release {version}"
else:
if channel not in ("production", "staging"):
raise SystemExit(
"bot-bottle install: error: BOT_BOTTLE_CHANNEL must be production or staging")
pointer_url = f"{base}/bot-bottle-channels/{channel}/channel.json"
selector = f"{channel} channel"
pointer = fetch_json(pointer_url)
if pointer.get("schema") != 1 or pointer.get("qualified") is not True:
raise SystemExit("bot-bottle install: error: release pointer is not qualified")
index_url = pointer.get("bundle_index_url", "")
if not isinstance(index_url, str) or not index_url.startswith("https://"):
raise SystemExit("bot-bottle install: error: release pointer has no immutable bundle")
index = fetch_json(index_url)
source_commit = index.get("source_commit", "")
if re.fullmatch(r"[0-9a-f]{40}", source_commit) is None:
raise SystemExit("bot-bottle install: error: bundle has an invalid source commit")
if ref and source_commit != ref:
raise SystemExit("bot-bottle install: error: bundle does not match BOT_BOTTLE_REF")
wheel = index.get("wheel")
if not isinstance(wheel, dict):
raise SystemExit("bot-bottle install: error: bundle has no wheel")
filename, url, expected = (
wheel.get("filename"), wheel.get("url"), wheel.get("sha256"))
if (
not isinstance(filename, str)
or re.fullmatch(r"[A-Za-z0-9_.-]+\.whl", filename) is None
or not isinstance(url, str)
or not url.startswith("https://")
or not url.endswith("/" + filename)
or not isinstance(expected, str)
or re.fullmatch(r"[0-9a-f]{64}", expected) is None
):
raise SystemExit("bot-bottle install: error: bundle wheel metadata is invalid")
destination = Path(download_root) / source_commit / filename
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(destination.suffix + ".part")
digest = hashlib.sha256()
with urllib.request.urlopen(url, timeout=60) as response, temporary.open("wb") as output:
while chunk := response.read(1 << 20):
digest.update(chunk)
output.write(chunk)
if digest.hexdigest() != expected:
temporary.unlink(missing_ok=True)
raise SystemExit("bot-bottle install: error: downloaded wheel checksum mismatch")
temporary.replace(destination)
print(
f"bot-bottle install: selected {selector} "
f"(source {source_commit}, sha256 {expected[:16]}…)",
file=sys.stderr,
)
print(destination)
PY
)"
fi
# An explicit `git+` override shells out to git under the hood, whether via
# pipx or pip. Fail early with a clear message rather than deep inside the
# installer's output.
case "${PACKAGE_SPEC}" in
git+*|*.git)
command -v git >/dev/null 2>&1 || die \
+15
View File
@@ -95,6 +95,21 @@ class TestInstallScript(unittest.TestCase):
# Tests / local installs point BOT_BOTTLE_INSTALL_SPEC at a checkout.
self.assertIn("BOT_BOTTLE_INSTALL_SPEC", self.text)
def test_published_install_selectors_are_mutually_exclusive(self):
self.assertIn("BOT_BOTTLE_CHANNEL", self.text)
self.assertIn("BOT_BOTTLE_VERSION", self.text)
self.assertIn("BOT_BOTTLE_REF", self.text)
self.assertIn("are mutually exclusive", self.text)
def test_commit_snapshot_warns_and_wheel_is_checksum_verified(self):
self.assertIn("may not have passed release qualification", self.text)
self.assertIn("downloaded wheel checksum mismatch", self.text)
self.assertIn("hashlib.sha256()", self.text)
def test_default_install_uses_production_channel(self):
self.assertIn('${BOT_BOTTLE_CHANNEL:-production}', self.text)
self.assertIn("bot-bottle-channels", self.text)
def test_requires_git_for_git_specs(self):
# A git+ / .git spec (the default) shells out to git; the script must
# gate on it rather than failing opaquely inside pipx/pip.