Runtime identity resolved through hermes_cli.__version__ (a static 0.0.0 on source installs, rewritten by release stamping) leaked v0.0.0 into About, /api/health, User-Agents, and plugin compat, and source updates showed "couldn't reach update server" because identity and channel authority disagreed with the checkout. Now: get_version_info() resolves install stamp -> live git -> unknown, never pyproject metadata, never a package constant. Source checkouts derive identity from their reachable release tag; the completion tail of every successful install/update/historical takeover atomically rewrites install-stamp.json with that identity; a stale source stamp whose commit no longer matches HEAD defers to live git. ACP/TUI use derived_version for display and base_version for protocol fields; all ~44 runtime __version__ consumers migrated; hermes_cli.__version__ and generated _version.py are gone; release stamping only touches the native manifests external builders consume (nix/tauri/cargo) and passes release identity straight into write_install_stamp.py; pyproject.toml stays inert 0.0.0. Desktop no longer synthesizes a competing install-stamp.json: the checkout owns its stamp, and desktop-bootstrap classification keys on the bootstrap-complete marker. verify-bootstrap-version-stamp.py now cross-checks the checkout's stamp (baseVersion + commit == HEAD). Validation: 31-file focused suite green (version identity, stamping, adoption, providers, gateway, acp/tui runtime identity, api server via extras env, release graph); desktop tsc + 25 vitest green; real-repo probe: base=unknown derived=git.0635606.dirty source=git on this checkout; clean-env imports resolve entirely from this tree; windows footgun + compat-pointer scans clean.
264 lines
13 KiB
Bash
264 lines
13 KiB
Bash
#!/usr/bin/env bash
|
|
# Fat .deb assembly for the Termux hermes-agent bundle (Task 4 of
|
|
# .hermes/plans/2026-08-31_termux-deb.md). Runs AFTER termux_build.sh
|
|
# (wheelhouse) and build_cpython.sh / build_node.sh have populated the
|
|
# payload dir. No opt-out flags: a skipped step is a different artifact.
|
|
#
|
|
# Inputs (all required):
|
|
# --repo <dir> hermes-agent checkout (tag must exist; provenance)
|
|
# --tag <tag> immutable release tag (vX.Y.Z or vX.Y.Z+canary.<UTC timestamp>)
|
|
# --payload <dir> dir containing python/, node/, app/ (git archive of
|
|
# the tag) and wheelhouse/ (from termux_build.sh)
|
|
# --out <dir> output dir; <out>/hermes-agent_<v>_aarch64.deb lands here
|
|
#
|
|
# No opt-out flags: the .deb is ALWAYS installed into a fresh run of the
|
|
# pinned termux-docker image (digest pinned in pm/lock.json) and smoke-tested.
|
|
# docker must be available. The channel is derived from the tag by
|
|
# deb_version.py (--channel), not passed in.
|
|
#
|
|
# Staged payload layout: python/ and node/ are pm-staged termux .deb
|
|
# trees ($PREFIX-shaped: data/data/com.termux/files/usr/...). The
|
|
# installed layout is $PREFIX/lib/hermes-agent/{tools,app,venv,pm-runtime,bin} with
|
|
# exactly one leak: $PREFIX/bin/hermes -> lib/hermes-agent/bin/hermes.
|
|
|
|
set -Eeuo pipefail
|
|
|
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
# The container digest is a pm pin (the termux-docker package); read it
|
|
# from the single lock beside every other third-party artifact pin.
|
|
REPO_ROOT="$(cd "$HERE/../.." && pwd)"
|
|
|
|
|
|
REPO=""
|
|
TAG=""
|
|
COMMIT_MODE=""
|
|
RELEASE_COMMIT=""
|
|
PAYLOAD=""
|
|
OUT=""
|
|
TUI_PRODUCT=""
|
|
|
|
usage() { printf 'usage: build_deb.sh --repo <dir> (--tag <tag> | --commit <full-sha>) --payload <dir> --tui-product <dir> --out <dir>\n' >&2; exit 2; }
|
|
log() { printf '\n==> %s\n' "$*"; }
|
|
fail() { printf 'build_deb: FAILED: %s\n' "$*" >&2; exit 1; }
|
|
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--repo) REPO="${2:?}"; shift 2 ;;
|
|
--tag) TAG="${2:?}"; shift 2 ;;
|
|
--commit) COMMIT_MODE="${2:?}"; shift 2 ;;
|
|
--release-commit) RELEASE_COMMIT="${2:?}"; shift 2 ;;
|
|
--payload) PAYLOAD="${2:?}"; shift 2 ;;
|
|
--tui-product) TUI_PRODUCT="${2:?}"; shift 2 ;;
|
|
--out) OUT="${2:?}"; shift 2 ;;
|
|
*) usage ;;
|
|
esac
|
|
done
|
|
[ -n "$REPO" ] && [ -n "$PAYLOAD" ] && [ -n "$OUT" ] || usage
|
|
{ [ -n "$TAG" ] || [ -n "$COMMIT_MODE" ]; } || usage
|
|
{ [ -z "$TAG" ] || [ -z "$COMMIT_MODE" ]; } || usage
|
|
[ -z "$RELEASE_COMMIT" ] || { [ -n "$TAG" ] && [ -z "$COMMIT_MODE" ]; } || usage
|
|
|
|
for tool in python3 git docker dpkg-deb jq; do
|
|
command -v "$tool" >/dev/null || fail "missing tool: $tool"
|
|
done
|
|
|
|
DIGEST="$(cd "$REPO_ROOT" && python3 -c 'from pm.lock import termux_docker_digest; print(termux_docker_digest())')"
|
|
[ -n "$DIGEST" ] || fail "termux-docker digest missing from pm/lock.json"
|
|
IMAGE="${TERMUX_BUILDER_IMAGE:-termux/termux-docker@$DIGEST}"
|
|
|
|
REPO_ABS="$(cd "$REPO" && pwd)"
|
|
PAYLOAD_ABS="$(cd "$PAYLOAD" && pwd)"
|
|
|
|
# Resolve source identity before writing output or changing payload files.
|
|
if [ -n "$COMMIT_MODE" ] || [ -n "$RELEASE_COMMIT" ]; then
|
|
SELECTED_COMMIT="${RELEASE_COMMIT:-$COMMIT_MODE}"
|
|
[[ "$SELECTED_COMMIT" =~ ^[a-f0-9]{40}$ ]] || fail "commit identity requires an exact full 40-character SHA"
|
|
COMMIT="$(git -C "$REPO_ABS" rev-parse HEAD)" || fail "not a git checkout: $REPO_ABS"
|
|
[ "$COMMIT" = "$SELECTED_COMMIT" ] || fail "checkout HEAD $(echo "$COMMIT" | cut -c1-12) is not the requested commit"
|
|
PY_VERSION="$(python3 - "$REPO_ROOT" "$REPO_ABS" "$COMMIT" "$TAG" <<'PY'
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, sys.argv[1])
|
|
from scripts.releases.commit_build import version_at
|
|
version = sys.argv[4][1:] if sys.argv[4] else version_at(Path(sys.argv[2]), sys.argv[3])
|
|
print(version)
|
|
PY
|
|
)" || fail "commit version admission failed"
|
|
if [ -n "$RELEASE_COMMIT" ]; then
|
|
DEB_VERSION="$(python3 "$HERE/deb_version.py" "$TAG")" || fail "version derivation failed for tag $TAG"
|
|
export HERMES_PAYLOAD_TAG="$TAG"
|
|
unset HERMES_BUILD_COMMIT
|
|
else
|
|
DEB_VERSION="${PY_VERSION}+commit${COMMIT_MODE:0:12}"
|
|
export HERMES_PAYLOAD_TAG=""
|
|
export HERMES_BUILD_COMMIT="$COMMIT_MODE"
|
|
fi
|
|
else
|
|
unset HERMES_BUILD_COMMIT
|
|
COMMIT="$(git -C "$REPO_ABS" rev-parse --verify "refs/tags/$TAG^{commit}")" \
|
|
|| fail "tag $TAG not found in $REPO_ABS"
|
|
PY_VERSION="${TAG#v}"
|
|
fi
|
|
[ -n "$TUI_PRODUCT" ] || fail "--tui-product is required (run scripts/termux/build.py)"
|
|
TUI_PRODUCT="$(cd "$TUI_PRODUCT" && pwd)"
|
|
[ -f "$TUI_PRODUCT/dist/entry.js" ] && [ -f "$TUI_PRODUCT/package.json" ] || fail "incomplete TUI product"
|
|
for d in python node uv npm ffmpeg ripgrep runtime-libs app wheelhouse; do
|
|
[ -d "$PAYLOAD_ABS/$d" ] || fail "payload missing $d/ -- run termux_build.sh + build_cpython.sh + build_node.sh first"
|
|
done
|
|
PYBIN_REL="data/data/com.termux/files/usr/bin/python3.14"
|
|
[ -f "$PAYLOAD_ABS/python/$PYBIN_REL" ] || fail "payload python tree lacks $PYBIN_REL"
|
|
NODEBIN_REL="data/data/com.termux/files/usr/bin/node"
|
|
[ -f "$PAYLOAD_ABS/node/$NODEBIN_REL" ] || fail "payload node tree lacks $NODEBIN_REL"
|
|
|
|
PKG="hermes-agent"
|
|
|
|
# [1] Version derivation: tag mode uses the pure function in deb_version.py
|
|
# (tested separately). Commit mode derives it from pyproject above.
|
|
if [ -z "$COMMIT_MODE" ] && [ -z "$RELEASE_COMMIT" ]; then
|
|
log "Deriving Debian version from tag $TAG"
|
|
DEB_VERSION="$(python3 "$HERE/deb_version.py" "$TAG")" || fail "version derivation failed for tag $TAG"
|
|
fi
|
|
log "Package version: $DEB_VERSION"
|
|
OUT_ABS="$(mkdir -p "$OUT" && cd "$OUT" && pwd)"
|
|
|
|
# [2] Assemble the venv offline, INSIDE the pinned container: the staged
|
|
# interpreter is bionic/arm64 and cannot run on this host. Completeness is
|
|
# enforced by construction: --no-index means a missing wheel fails loudly.
|
|
# The container sees the payload at its real PREFIX path; the venv is built
|
|
# staged trees so the shipped venv's absolute shebangs point at the REAL
|
|
# $PREFIX path they will occupy on-device ($PREFIX is contractual).
|
|
log "Creating application and PM environments with the bundled CPython (inside the container)"
|
|
if [ -d "$PAYLOAD_ABS/venv" ]; then rm -rf "$PAYLOAD_ABS/venv"; fi
|
|
if [ -d "$PAYLOAD_ABS/pm-runtime" ]; then rm -rf "$PAYLOAD_ABS/pm-runtime"; fi
|
|
# The venv's dep list: the resolved graph with markers intact (the installer
|
|
# evaluates them on bionic) and documented android build misses skipped --
|
|
# uv pip check tolerates the app importing without them (its relay exporter
|
|
# is the only casualty). Generated host-side; consumed in-container.
|
|
python3 - "$HERE" "$PAYLOAD_ABS/.work/resolved.txt" "$PAYLOAD_ABS/.work/resolved-reqs.txt" <<'PYREQS' \
|
|
|| fail "deb-venv reqs generation failed"
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, sys.argv[1])
|
|
from build_wheels import write_reqs_file
|
|
write_reqs_file(Path(sys.argv[2]), Path(sys.argv[3]))
|
|
PYREQS
|
|
# shellcheck source=scripts/termux/assembly_permissions.sh
|
|
. "$HERE/assembly_permissions.sh"
|
|
prepare_assembly
|
|
# Restore host ownership on failure too, after Docker has removed input mounts.
|
|
trap restore_assembly_owner EXIT
|
|
# Mount the payload at its REAL on-device path: the venv records
|
|
# absolute paths (interpreter symlink, pyvenv.cfg) that must be correct
|
|
# on-device from birth -- a /payload alias would bake container paths in.
|
|
docker run --rm --platform linux/arm64 \
|
|
--user 1000:1000 --network none \
|
|
-v "$ASSEMBLY:/data/data/com.termux/files/usr/lib/hermes-agent" \
|
|
-v "$PAYLOAD_ABS/python:/data/data/com.termux/files/usr/lib/hermes-agent/tools/python" \
|
|
-v "$PAYLOAD_ABS/node:/data/data/com.termux/files/usr/lib/hermes-agent/tools/node" \
|
|
-v "$PAYLOAD_ABS/uv:/data/data/com.termux/files/usr/lib/hermes-agent/tools/uv" \
|
|
-v "$PAYLOAD_ABS/runtime-libs:/data/data/com.termux/files/usr/lib/hermes-agent/runtime-libs" \
|
|
-v "$PAYLOAD_ABS/wheelhouse:/data/data/com.termux/files/usr/lib/hermes-agent/wheelhouse" \
|
|
-v "$PAYLOAD_ABS/.work:/data/data/com.termux/files/usr/lib/hermes-agent/.work" \
|
|
-v "$PAYLOAD_ABS/app:/data/data/com.termux/files/usr/lib/hermes-agent/app:ro" \
|
|
"$IMAGE" bash -c '
|
|
set -euo pipefail
|
|
export PREFIX=/data/data/com.termux/files/usr
|
|
export PATH="$PREFIX/bin:${PATH:-/usr/bin:/bin}"
|
|
# The staged binary is dynamically linked against its OWN tree lib;
|
|
# the container linker needs to be told where it lives (same fix as
|
|
# the wheelhouse container half).
|
|
export LD_LIBRARY_PATH="$PREFIX/lib/hermes-agent/tools/python$PREFIX/lib:$PREFIX/lib/hermes-agent/tools/node$PREFIX/lib:$PREFIX/lib/hermes-agent/runtime-libs/lib:$PREFIX/lib"
|
|
# The staged tree is mounted at its REAL $PREFIX path so the venv
|
|
# recorded absolute paths are correct on-device from birth.
|
|
mkdir -p "$PREFIX" 2>/dev/null || true
|
|
PY="$PREFIX/lib/hermes-agent/tools/python$PREFIX/bin/python3.14"
|
|
ROOT="$PREFIX/lib/hermes-agent"
|
|
export HERMES_RUNTIME_DIR="$ROOT/tools"
|
|
mkdir -p "$PREFIX/tmp"
|
|
HERMES_HOME="$(mktemp -d "$PREFIX/tmp/hermes-pm-XXXXXX")"
|
|
export HERMES_HOME
|
|
"$PY" "$ROOT/app/scripts/termux/build_environment.py" assemble \
|
|
--root "$ROOT" --python "$PY" --requirements "$ROOT/.work/resolved-reqs.txt"
|
|
' || fail "venv assembly failed inside the container (offline wheelhouse install)"
|
|
restore_assembly_owner
|
|
trap - EXIT
|
|
# They were built at the final on-device paths, not at host scratch paths.
|
|
mv "$ASSEMBLY/venv" "$ASSEMBLY/pm-runtime" "$PAYLOAD_ABS/"
|
|
rm -rf "$ASSEMBLY"
|
|
|
|
# The install-method stamp (code-scoped, next to hermes_cli/): the deb IS
|
|
# the Termux apt distribution, and detect_install_method reads this marker
|
|
# to route hermes update -> pkg upgrade remediation.
|
|
printf 'apt\n' > "$PAYLOAD_ABS/app/.install_method"
|
|
|
|
# The shared stamp writer records the apt-termux update owner.
|
|
# Commit mode exports HERMES_BUILD_COMMIT and leaves the tag empty.
|
|
# 'runtime', not 'bundled': the deb ships a runtime but no Electron app,
|
|
# and 'bundled' readers (data cleanup) go looking for the enclosing app.
|
|
log "Writing app/install-stamp.json"
|
|
HERMES_PAYLOAD_TAG="$TAG" \
|
|
HERMES_DESKTOP_VARIANT=runtime \
|
|
python3 "$REPO_ABS/scripts/write_install_stamp.py" \
|
|
--output "$PAYLOAD_ABS/app/install-stamp.json" \
|
|
--commit "$COMMIT" \
|
|
--base-version "$PY_VERSION" \
|
|
--display-version "$PY_VERSION" \
|
|
--distance 0 \
|
|
--distribution apt-termux \
|
|
--update-mechanism external \
|
|
--source bundle \
|
|
|| fail "stamp write failed"
|
|
|
|
# [5]+[6] Staging dir: DEBIAN/ control + payload under lib/hermes-agent/.
|
|
log "Staging the package tree"
|
|
STAGE="$OUT_ABS/.stage-$DEB_VERSION"
|
|
rm -rf "$STAGE"
|
|
# Termux debs store their payload at the ANDROID-fs-rooted on-device
|
|
# path (data/data/com.termux/files/usr/...): on-device dpkg extracts
|
|
# at / and only /data/data is writable -- a ./lib staging would make
|
|
# dpkg try to create /lib and fail on the read-only root.
|
|
ROOT_IN_DEB=data/data/com.termux/files/usr
|
|
DEST="$STAGE/$ROOT_IN_DEB/lib/hermes-agent"
|
|
mkdir -p "$STAGE/DEBIAN" "$DEST/tools"
|
|
for tool in python node uv npm ffmpeg ripgrep; do
|
|
cp -a "$PAYLOAD_ABS/$tool" "$DEST/tools/"
|
|
done
|
|
cp -a "$PAYLOAD_ABS/runtime-libs" "$PAYLOAD_ABS/app" "$PAYLOAD_ABS/venv" "$PAYLOAD_ABS/pm-runtime" "$DEST/"
|
|
python3 "$HERE/payload_facts.py" "$DEST" "$PAYLOAD_ABS/.work/build_set.txt" --tui-product "$TUI_PRODUCT"
|
|
|
|
python3 "$HERE/launchers.py" --payload "$DEST" --control "$STAGE/DEBIAN"
|
|
|
|
cat > "$STAGE/DEBIAN/control" <<EOF
|
|
Package: $PKG
|
|
Version: $DEB_VERSION
|
|
Architecture: aarch64
|
|
Maintainer: Nous Research
|
|
Description: Hermes Agent CLI for Termux (self-contained bundled python/node/venv)
|
|
Installed-Size: $(du -sk "$STAGE/$ROOT_IN_DEB" | cut -f1)
|
|
EOF
|
|
# Self-contained: no Depends line at all. Our python, node and venv ship inside.
|
|
|
|
# [7] Validation hook: install into a FRESH container of the pinned image and
|
|
# smoke-test the exact binaries the phone will run. No opt-out.
|
|
log "Validating in a fresh pinned termux-docker container"
|
|
DEB="$OUT_ABS/${PKG}_${DEB_VERSION}_aarch64.deb"
|
|
rm -f "$DEB"
|
|
# Use xz for both archive members so Termux dpkg can extract the package.
|
|
dpkg-deb --build -Zxz --root-owner-group "$STAGE" "$DEB" || fail "dpkg-deb --build failed"
|
|
rm -rf "$STAGE"
|
|
[ -f "$DEB" ] || fail "dpkg-deb did not produce $DEB"
|
|
|
|
# The bare rootfs has no build toolchain to hide a missing payload library.
|
|
ctmp=/tmp # no-tmp: ok — mount point inside the arm64 test container, not host scratch
|
|
docker run --rm --platform linux/arm64 \
|
|
--user 1000:1000 --network none \
|
|
-v "$DEB:$ctmp/pkg.deb:ro" \
|
|
-v "$HERE/check_deb.sh:$ctmp/check.sh:ro" \
|
|
-v "$HERE/validate_installed.py:$ctmp/validate_installed.py:ro" \
|
|
"termux/termux-docker@$DIGEST" bash -c \
|
|
"source $ctmp/check.sh; \"\$root/venv/bin/python\" -m pm.cli status" \
|
|
|| fail "container validation failed"
|
|
|
|
log "Built $DEB (validated)"
|