#!/usr/bin/env bash # deploy/updater.sh — FROZEN bootstrap (contract v1). See # plans/2026-09-27-updater-bootstrap-adr.md. Installed to ${RUN_DIR}/scripts/updater.sh. # Resolves the newest stable release, stages that release's deploy/update.sh + # deploy/lib/common.sh (+ installed app.env) in mktemp -d, and runs update.sh. # # Usage: bash updater.sh [--version TAG] [--yes|-y] [--help|-h] # --version TAG Update to a specific stable tag (skips the menu) # --yes, -y Non-interactive: pick the newest stable, forward --yes # --help, -h Show this help # # FROZEN: name/location, flags --version/--yes/--help, exit codes, stable-only policy. set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd -P)" || SCRIPT_DIR="" # Reuse the installed helpers (logging, app.env, url_get, version read/compare, resolve_port). load_common() { local cand for cand in "${SCRIPT_DIR}/lib/common.sh" "${SCRIPT_DIR}/common.sh"; do [ -f "$cand" ] && { . "$cand"; return 0; } done echo "[app] ERROR: cannot find lib/common.sh next to $0" >&2; exit 1 } load_common STAGE_ROOT="" # The trap must always return 0: under `set -e` a failing last command would # override the pending status (e.g. `exit 0` from --help) and make the script # exit 1. Stage cleanup is best-effort and never changes the exit code. # # STAGE_ROOT keeps its own `rm -rf` here rather than joining the shared # cleanup_add registry (mktemp_owned, lib/common.sh), unlike every other temp # path in deploy/. That is deliberate: this file is the FROZEN bootstrap, and # its own fixture (updater.sh.test.ts MINIMAL_COMMON_SH) provides a cleanup_run # that removes FILES only — folding a directory in would make this script's # stage cleanup depend on an `rm -rf` the contract it is tested against does not # promise. This path never leaked (the trap removes it, success and failure # alike), so there is nothing here to fix; consolidating it would be a # refactor of a frozen contract for no leak removed. _on_exit() { cleanup_run; if [ -n "$STAGE_ROOT" ]; then rm -rf -- "$STAGE_ROOT"; fi; return 0; } trap _on_exit EXIT ARG_VERSION=""; ASSUME_YES="${ASSUME_YES:-false}" TAG_RE='^v[0-9][0-9A-Za-z.+-]*$' # validates remote tag strings (no injection) parse_args() { while [ $# -gt 0 ]; do case "$1" in --version) [ $# -ge 2 ] || error "--version requires a tag"; ARG_VERSION="$2"; shift 2 ;; --yes|-y) ASSUME_YES=true; shift ;; --help|-h) sed -n '2,12p' "$0" 2>/dev/null || echo "See updater.sh header"; exit 0 ;; *) error "unknown option: $1 (try --help)" ;; esac done } # Newest-first list of stable tags from the remote (no local clone, no fetch). resolve_stable_tags() { mapfile -t TAGS < <( git ls-remote --tags --refs "$REPO_URL" 2>/dev/null \ | awk '{print $2}' | sed 's#^refs/tags/##' \ | grep -E '^v[0-9]' | grep -v -- '-' | sort -Vr || true ) [ "${#TAGS[@]}" -gt 0 ] || error "no stable release tags found in ${REPO_URL}" } current_version() { local v="" # Binary install: the artefact itself is authoritative — offline, no HTTP, and # exact. app_version() returns a v-PREFIXED string (it strips only the app-name # prefix), so the consumers below normalise with ${CURRENT#v}. if [ "${DIST:-source}" = "binary" ] && [ -x "${INSTALL_DIR}/${APP_NAME}" ]; then v="$(app_version "${INSTALL_DIR}/${APP_NAME}")" fi [ -n "$v" ] || v="$(read_package_version "${INSTALL_DIR}/package.json" 2>/dev/null || true)" # Health is the fallback: it reports the bare VERSION (src/services/health.service.ts), # so it is the only source that works before a binary install exists. [ -n "$v" ] || v="$(url_get "$HEALTH_URL" 2>/dev/null | parse_json_version || true)" printf '%s' "${v:-unknown}" } select_target() { # sets $CHOSEN if [ -n "$ARG_VERSION" ]; then [[ "$ARG_VERSION" =~ $TAG_RE ]] || error "invalid version: ${ARG_VERSION}" CHOSEN="$(normalize_v "$ARG_VERSION")" printf '%s\n' "${TAGS[@]}" | grep -qxF -- "$CHOSEN" \ || error "version ${CHOSEN} is not a known stable release" return fi if [ "$ASSUME_YES" = true ]; then CHOSEN="${TAGS[0]}"; return; fi if [ ! -t 0 ]; then error "non-interactive shell — re-run with --yes or --version"; fi local n=${#TAGS[@]} i sel; [ "$n" -gt 10 ] && n=10 for ((i=0; i/dev/null && [ "$sel" -le "$n" ] 2>/dev/null; } || error "invalid selection" CHOSEN="${TAGS[$((sel-1))]}" } # Shallow-clone the tag and stage update.sh + lib/common.sh + app.env together. stage_release() { local tag="$1" clone STAGE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/synaptomind-updater.XXXXXX")" \ || error "cannot create staging dir" clone="${STAGE_ROOT}/src" git clone --depth 1 --branch "$tag" --quiet "$REPO_URL" "$clone" \ || error "cannot fetch release ${tag} from ${REPO_URL}" mkdir -p "${STAGE_ROOT}/run/lib" # Tolerate a tag without deploy/ (pre-v0.8.0): assert_contract reports it clearly. cp -f "${clone}/deploy/update.sh" "${STAGE_ROOT}/run/update.sh" 2>/dev/null || true cp -f "${clone}/deploy/lib/common.sh" "${STAGE_ROOT}/run/lib/common.sh" 2>/dev/null || true cp -f "${RUN_DIR}/scripts/app.env" "${STAGE_ROOT}/run/app.env" chmod +x "${STAGE_ROOT}/run/update.sh" 2>/dev/null || true STAGE_DIR="${STAGE_ROOT}/run" } assert_contract() { [ -s "${STAGE_DIR}/update.sh" ] && [ -s "${STAGE_DIR}/lib/common.sh" ] \ || error "release ${CHOSEN} has no deploy/update.sh (stable releases before v0.8.0 lack deploy/)" bash "${STAGE_DIR}/update.sh" --version 0.0.0 --yes --help >/dev/null 2>&1 \ || error "frozen contract violated: release update.sh no longer accepts --version/--yes/--help" } run_update() { local rc=0 info "Running release update.sh for ${CHOSEN}..." if [ "$ASSUME_YES" = true ]; then bash "${STAGE_DIR}/update.sh" --version "$CHOSEN" --yes || rc=$? else bash "${STAGE_DIR}/update.sh" --version "$CHOSEN" || rc=$? fi exit "$rc" } main() { parse_args "$@" load_app_env resolve_target_user INSTALL_DIR="${INSTALL_DIR:-/opt/${APP_NAME}}" PORT="${PORT:-3000}" HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:$(resolve_port)/health}" # Binary mode is accepted: REPO_URL stays required because this bootstrap # shallow-clones the tag to stage that release's own deploy/update.sh. A # binary host therefore needs git and network access to the repository, but # neither the repository on disk nor a checkout in INSTALL_DIR (ADR 0001 §2.9). case "${DIST:-source}" in source|binary) ;; *) error "updater supports DIST=source or DIST=binary (got '${DIST}')" ;; esac [ -n "${REPO_URL:-}" ] || error "REPO_URL is empty (required for the updater bootstrap in both modes)" need_cmd git export RUN_DIR CURRENT="$(current_version)" resolve_stable_tags info "Current: ${CURRENT#v} Latest stable: ${TAGS[0]#v}" select_target confirm "Update ${CURRENT#v} -> ${CHOSEN#v}?" || { info "Aborted."; exit 0; } stage_release "$CHOSEN" assert_contract run_update } main "$@"