Files
hermes-agent/scripts/termux/build_deb.sh
ethernet c13ea774e6 refactor: make install-stamp.json the single runtime version identity
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.
2026-09-23 11:41:01 -04:00

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)"