#!/usr/bin/env bash # Validate a fetched candidate, fast-forward the live checkout, then reinstall. set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CHECK_ONLY=0 INSTALL_ARGS=() usage() { cat <<'EOF' Usage: ./update.sh [--check] [install.sh options] --check Fetch and report only. Exit 0 when current/ahead, 10 when an update is available, 1 when histories diverged, and 2 on an invalid repository, missing upstream, or fetch failure. The fetched commit is checked in a temporary detached worktree before the live checkout is changed. Only a valid candidate is fast-forwarded, then install.sh is reapplied. Remaining options are forwarded to install.sh, for example: ./update.sh --copy --force. EOF } while (($#)); do case "$1" in --check) CHECK_ONLY=1 ;; -h|--help) usage; exit 0 ;; *) INSTALL_ARGS+=("$1") ;; esac shift done git_repo() { git -C "$REPO_DIR" "$@" } if ! git_repo rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "error: update requires a Git working tree: $REPO_DIR" >&2 exit 2 fi if [[ ! -f "$REPO_DIR/VERSION" ]]; then echo "error: VERSION file is missing" >&2 exit 2 fi OLD_VERSION="$(tr -d '[:space:]' < "$REPO_DIR/VERSION")" if ! UPSTREAM="$(git_repo rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)"; then echo "error: current branch has no configured upstream; set one before updating" >&2 exit 2 fi REMOTE_NAME="${UPSTREAM%%/*}" echo "checking updates from $UPSTREAM ..." if ! git_repo fetch --quiet "$REMOTE_NAME"; then echo "error: git fetch failed for remote $REMOTE_NAME" >&2 exit 2 fi LOCAL_REV="$(git_repo rev-parse HEAD)" REMOTE_REV="$(git_repo rev-parse "$UPSTREAM")" if ! MERGE_BASE="$(git_repo merge-base HEAD "$UPSTREAM" 2>/dev/null)"; then echo "error: local and upstream have no merge base; automatic update stopped" >&2 exit 1 fi if [[ "$LOCAL_REV" == "$REMOTE_REV" ]]; then echo "already current: v$OLD_VERSION ($(git_repo rev-parse --short HEAD))" exit 0 fi if [[ "$MERGE_BASE" == "$REMOTE_REV" ]]; then echo "local branch is ahead of $UPSTREAM; no update applied" exit 0 fi if [[ "$MERGE_BASE" != "$LOCAL_REV" ]]; then echo "error: local and upstream histories diverged; automatic update stopped" >&2 echo "local=$(git_repo rev-parse --short HEAD) upstream=$(git_repo rev-parse --short "$UPSTREAM")" >&2 exit 1 fi BEHIND="$(git_repo rev-list --count "HEAD..$UPSTREAM")" echo "update available: $BEHIND commit(s)" git_repo --no-pager log --oneline "HEAD..$UPSTREAM" -n 10 if ((CHECK_ONLY)); then echo "check only; run ./update.sh to apply" exit 10 fi if [[ -n "$(git_repo status --porcelain --untracked-files=normal)" ]]; then echo "error: working tree has local changes; commit, stash, or remove them before updating" >&2 exit 1 fi if ! command -v python3 >/dev/null 2>&1; then echo "error: python3 is required for candidate contract checks" >&2 exit 2 fi CANDIDATE_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/technical-doc-flow-update.XXXXXX")" CANDIDATE_DIR="$CANDIDATE_ROOT/candidate" cleanup_candidate() { local status=$? trap - EXIT if [[ -n "${CANDIDATE_DIR:-}" ]]; then git_repo worktree remove --force "$CANDIDATE_DIR" >/dev/null 2>&1 || true fi if [[ -n "${CANDIDATE_ROOT:-}" && -d "$CANDIDATE_ROOT" ]]; then rm -rf -- "$CANDIDATE_ROOT" fi exit "$status" } trap cleanup_candidate EXIT echo "preparing isolated candidate $REMOTE_REV ..." if ! git_repo worktree add --quiet --detach "$CANDIDATE_DIR" "$REMOTE_REV"; then echo "error: could not create the candidate validation worktree" >&2 exit 2 fi for check_script in \ "$CANDIDATE_DIR/scripts/check_release_sync.py" \ "$CANDIDATE_DIR/scripts/build_quick_rules.py"; do [[ -f "$check_script" ]] || { echo "error: required candidate check is missing: $check_script; live checkout was not changed" >&2 exit 2 } done for shell_script in \ "$CANDIDATE_DIR/install.sh" \ "$CANDIDATE_DIR/uninstall.sh" \ "$CANDIDATE_DIR/update.sh"; do [[ -f "$shell_script" ]] || { echo "error: required candidate script is missing: $shell_script; live checkout was not changed" >&2 exit 2 } bash -n "$shell_script" || { echo "error: candidate shell validation failed: $shell_script; live checkout was not changed" >&2 exit 1 } done [[ -x "$CANDIDATE_DIR/install.sh" ]] || { echo "error: candidate install.sh is not executable; live checkout was not changed" >&2 exit 2 } echo "validating candidate release and path contracts ..." python3 "$CANDIDATE_DIR/scripts/check_release_sync.py" || { echo "error: candidate release contract validation failed; live checkout was not changed" >&2 exit 1 } echo "validating candidate generated quick rules ..." python3 "$CANDIDATE_DIR/scripts/build_quick_rules.py" --check || { echo "error: candidate generated quick-rules validation failed; live checkout was not changed" >&2 exit 1 } INSTALL_PREFLIGHT_ROOT="$CANDIDATE_ROOT/install-preflight" echo "preflighting forwarded installer arguments against the candidate ..." set +e HOME="$INSTALL_PREFLIGHT_ROOT/home" \ TECH_DOC_FLOW_CLAUDE_HOME="$INSTALL_PREFLIGHT_ROOT/claude" \ TECH_DOC_FLOW_CODEX_HOME="$INSTALL_PREFLIGHT_ROOT/codex" \ TECH_DOC_FLOW_GEMINI_HOME="$INSTALL_PREFLIGHT_ROOT/gemini" \ "$CANDIDATE_DIR/install.sh" "${INSTALL_ARGS[@]}" --dry-run INSTALL_PREFLIGHT_RC=$? set -e if [[ -e "$INSTALL_PREFLIGHT_ROOT" || -L "$INSTALL_PREFLIGHT_ROOT" ]]; then echo "error: candidate install.sh --dry-run changed an isolated target; live checkout was not changed" >&2 exit 1 fi if ((INSTALL_PREFLIGHT_RC != 0)); then echo "error: candidate installer option preflight failed; live checkout was not changed" >&2 exit 2 fi if [[ "$(git_repo rev-parse HEAD)" != "$LOCAL_REV" ]]; then echo "error: live HEAD changed during candidate validation; update stopped" >&2 exit 1 fi if [[ -n "$(git_repo status --porcelain --untracked-files=normal)" ]]; then echo "error: working tree changed during candidate validation; update stopped" >&2 exit 1 fi echo "candidate valid; applying pinned fast-forward update ..." if ! git_repo merge --ff-only "$REMOTE_REV"; then echo "error: fast-forward failed after candidate validation" >&2 exit 1 fi [[ -x "$REPO_DIR/install.sh" ]] || { echo "error: validated install.sh is missing or not executable after fast-forward" >&2 exit 2 } "$REPO_DIR/install.sh" "${INSTALL_ARGS[@]}" NEW_VERSION="$(tr -d '[:space:]' < "$REPO_DIR/VERSION")" echo "update complete: v$OLD_VERSION -> v$NEW_VERSION"