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
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:
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user