#!/usr/bin/env bash # update.sh — one-click update: pull latest + update LS binary + restart PM2 set -e cd "$(dirname "$0")" PORT="${PORT:-3003}" NAME="${PM2_NAME:-windsurf-api}" # Cross-process serialization for update.sh. mkdir is atomic on every platform # supported by this script (including macOS hosts without flock). A second # updater must fail before fetch/stash/reset rather than relying on git's index # lock after rollback metadata and service lifecycle have already raced. GIT_DIR_PATH="$(git rev-parse --git-dir)" UPDATE_LOCK_DIR="$GIT_DIR_PATH/windsurfapi-update.lock" UPDATE_LOCK_TOKEN="$$-$(date +%s)-${RANDOM}-${RANDOM}" UPDATE_TARGET_TREE_TMP="" UPDATE_TAG_LIST_TMP="" UPDATE_IGNORED_ROOTS_TMP="" UPDATE_IGNORED_PATHS_TMP="" OWNED_STASH_OID="" SERVICE_STOP_FAILED=0 lock_process_is_alive() { local candidate_pid="$1" if kill -0 "$candidate_pid" 2>/dev/null; then return 0 fi # kill -0 also fails with EPERM when another user owns a live process. Only # recover when a second read-only process-table check proves the PID absent; # if ps is unavailable or inconclusive, stay fail-closed. if command -v ps >/dev/null 2>&1; then [ -n "$(ps -p "$candidate_pid" -o pid= 2>/dev/null || true)" ] && return 0 return 1 fi return 0 } acquire_update_lock() { if mkdir "$UPDATE_LOCK_DIR" 2>/dev/null; then return 0 fi # Recover only a lock whose numeric owner PID is provably gone. Missing, # malformed, inaccessible, or still-live ownership stays fail-closed. local lock_pid="" lock_token="" legacy="0" if [ -r "$UPDATE_LOCK_DIR/owner" ]; then read -r lock_pid lock_token < "$UPDATE_LOCK_DIR/owner" || true [[ "$lock_pid" =~ ^[0-9]+$ ]] || return 1 [[ "$lock_token" =~ ^[A-Za-z0-9._-]+$ ]] || return 1 elif [ -r "$UPDATE_LOCK_DIR/pid" ]; then IFS= read -r lock_pid < "$UPDATE_LOCK_DIR/pid" || true legacy="1" lock_token="legacy" else return 1 fi [[ "$lock_pid" =~ ^[0-9]+$ ]] || return 1 lock_process_is_alive "$lock_pid" && return 1 # O_EXCL recovery claim closes the stale-lock ABA: exactly one contender may # rename the still-existing stale directory. A crash in this tiny recovery # window intentionally leaves a fail-closed claim for manual inspection. local claim_path="$UPDATE_LOCK_DIR/recovery" if ! ( set -o noclobber; printf '%s %s\n' "$$" "$UPDATE_LOCK_TOKEN" > "$claim_path" ) 2>/dev/null; then return 1 fi local verify_pid="" verify_token="" verify_legacy="0" if [ -r "$UPDATE_LOCK_DIR/owner" ]; then read -r verify_pid verify_token < "$UPDATE_LOCK_DIR/owner" || true elif [ -r "$UPDATE_LOCK_DIR/pid" ]; then IFS= read -r verify_pid < "$UPDATE_LOCK_DIR/pid" || true verify_token="legacy" verify_legacy="1" fi if [ "$verify_pid" != "$lock_pid" ] || [ "$verify_token" != "$lock_token" ] || [ "$verify_legacy" != "$legacy" ]; then rm -f "$claim_path" return 1 fi local quarantine="$UPDATE_LOCK_DIR.stale-$UPDATE_LOCK_TOKEN" if ! mv "$UPDATE_LOCK_DIR" "$quarantine" 2>/dev/null; then rm -f "$claim_path" return 1 fi rm -f "$quarantine/recovery" "$quarantine/owner" "$quarantine/pid" rmdir "$quarantine" 2>/dev/null || true if mkdir "$UPDATE_LOCK_DIR" 2>/dev/null; then return 0 fi return 1 } if ! acquire_update_lock; then echo "✗ Another WindsurfAPI update is already in progress ($UPDATE_LOCK_DIR)" exit 1 fi if ! ( set -o noclobber; printf '%s %s\n' "$$" "$UPDATE_LOCK_TOKEN" > "$UPDATE_LOCK_DIR/owner" ) 2>/dev/null; then rmdir "$UPDATE_LOCK_DIR" 2>/dev/null || true echo "✗ Could not initialize WindsurfAPI update lock ownership" exit 1 fi if ! ( set -o noclobber; printf '%s\n' "$$" > "$UPDATE_LOCK_DIR/pid" ) 2>/dev/null; then rm -f "$UPDATE_LOCK_DIR/owner" rmdir "$UPDATE_LOCK_DIR" 2>/dev/null || true echo "✗ Could not initialize WindsurfAPI update lock compatibility PID" exit 1 fi cleanup_update_lock() { local owner_pid="" owner_token="" [ -r "$UPDATE_LOCK_DIR/owner" ] || return 0 read -r owner_pid owner_token < "$UPDATE_LOCK_DIR/owner" || true [ "$owner_pid" = "$$" ] && [ "$owner_token" = "$UPDATE_LOCK_TOKEN" ] || return 0 rm -f "$UPDATE_LOCK_DIR/owner" "$UPDATE_LOCK_DIR/pid" rmdir "$UPDATE_LOCK_DIR" 2>/dev/null || true } cleanup_ignored_target_probe_files() { [ -n "$UPDATE_TARGET_TREE_TMP" ] && rm -f -- "$UPDATE_TARGET_TREE_TMP" [ -n "$UPDATE_IGNORED_ROOTS_TMP" ] && rm -f -- "$UPDATE_IGNORED_ROOTS_TMP" [ -n "$UPDATE_IGNORED_PATHS_TMP" ] && rm -f -- "$UPDATE_IGNORED_PATHS_TMP" UPDATE_TARGET_TREE_TMP="" UPDATE_IGNORED_ROOTS_TMP="" UPDATE_IGNORED_PATHS_TMP="" return 0 } cleanup_update_probe_files() { cleanup_ignored_target_probe_files [ -n "$UPDATE_TAG_LIST_TMP" ] && rm -f -- "$UPDATE_TAG_LIST_TMP" UPDATE_TAG_LIST_TMP="" # An empty final path is a normal no-op. Keep the EXIT trap successful so a # completed update does not return 1 (and skip lock cleanup) under `set -e`. return 0 } cleanup_update_state() { cleanup_update_probe_files cleanup_update_lock } trap cleanup_update_state EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM check_ignored_target_conflicts() { local target_commit="$1" local -a target_paths=() local -a target_paths_folded=() local -a roots_folded=() local -a local_roots=() local -a local_pathspecs=() local -a conflicts=() local path="" folded="" root="" root_folded="" existing="" ignored="" ignored_root="" left="" right="" duplicate="0" local i=0 cleanup_ignored_target_probe_files UPDATE_TARGET_TREE_TMP="$(mktemp "$GIT_DIR_PATH/windsurfapi-target-tree.XXXXXX")" || return 2 UPDATE_IGNORED_ROOTS_TMP="$(mktemp "$GIT_DIR_PATH/windsurfapi-ignored-roots.XXXXXX")" || return 2 UPDATE_IGNORED_PATHS_TMP="$(mktemp "$GIT_DIR_PATH/windsurfapi-ignored-paths.XXXXXX")" || return 2 if ! git ls-tree -r -z --name-only "$target_commit" > "$UPDATE_TARGET_TREE_TMP"; then echo " ! could not inspect the update target tree; refusing a destructive checkout" return 2 fi while IFS= read -r -d '' path; do target_paths+=("$path") folded="$(printf '%s' "$path" | LC_ALL=C tr '[:upper:]' '[:lower:]')" target_paths_folded+=("$folded") root="${path%%/*}" root_folded="$(printf '%s' "$root" | LC_ALL=C tr '[:upper:]' '[:lower:]')" duplicate="0" for existing in "${roots_folded[@]}"; do [ "$existing" = "$root_folded" ] && duplicate="1" && break done if [ "$duplicate" = "0" ]; then roots_folded+=("$root_folded") fi done < "$UPDATE_TARGET_TREE_TMP" [ "${#target_paths[@]}" -gt 0 ] || return 0 # Inventory ignored roots in collapsed form first. This avoids descending # into wholly ignored trees such as node_modules while discovering the real # on-disk spelling of roots whose case differs from the target. Do not trust # core.ignorecase here: it can be absent or stale on a case-insensitive FS. if ! git ls-files --others --ignored --exclude-standard --directory -z > "$UPDATE_IGNORED_ROOTS_TMP"; then echo " ! could not inventory ignored runtime roots; refusing a destructive checkout" return 2 fi while IFS= read -r -d '' ignored; do ignored="${ignored%/}" ignored_root="${ignored%%/*}" left="$(printf '%s' "$ignored_root" | LC_ALL=C tr '[:upper:]' '[:lower:]')" duplicate="0" for right in "${roots_folded[@]}"; do [ "$left" = "$right" ] || continue for existing in "${local_roots[@]}"; do [ "$existing" = "$ignored_root" ] && duplicate="1" && break done if [ "$duplicate" = "0" ]; then local_roots+=("$ignored_root") local_pathspecs+=(":(top,literal)$ignored_root") fi break done done < "$UPDATE_IGNORED_ROOTS_TMP" [ "${#local_roots[@]}" -gt 0 ] || return 0 if ! git ls-files --others --ignored --exclude-standard -z -- "${local_pathspecs[@]}" > "$UPDATE_IGNORED_PATHS_TMP"; then echo " ! could not inspect ignored runtime paths; refusing a destructive checkout" return 2 fi while IFS= read -r -d '' ignored; do left="$(printf '%s' "$ignored" | LC_ALL=C tr '[:upper:]' '[:lower:]')" for ((i = 0; i < ${#target_paths_folded[@]}; i++)); do right="${target_paths_folded[$i]}" if [ "$left" = "$right" ] || [[ "$left" == "$right/"* ]] || [[ "$right" == "$left/"* ]]; then conflicts+=("$ignored") break fi done done < "$UPDATE_IGNORED_PATHS_TMP" if [ "${#conflicts[@]}" -gt 0 ]; then echo " ! update target would overwrite ignored runtime data; move or back up these paths first:" local shown="0" for path in "${conflicts[@]}"; do echo " $path" shown=$((shown + 1)) [ "$shown" -ge 20 ] && break done return 1 fi return 0 } stable_tag_is_newer() { local left="${1#v}" right="${2#v}" local left_major="" left_minor="" left_patch="" local right_major="" right_minor="" right_patch="" IFS=. read -r left_major left_minor left_patch <<< "$left" IFS=. read -r right_major right_minor right_patch <<< "$right" local -a left_parts=("$left_major" "$left_minor" "$left_patch") local -a right_parts=("$right_major" "$right_minor" "$right_patch") local index="0" position="0" left_part="" right_part="" left_digit="" right_digit="" while [ "$index" -lt 3 ]; do left_part="${left_parts[$index]}" right_part="${right_parts[$index]}" if [ "${#left_part}" -ne "${#right_part}" ]; then [ "${#left_part}" -gt "${#right_part}" ] return fi if [ "$left_part" != "$right_part" ]; then position="0" while [ "$position" -lt "${#left_part}" ]; do left_digit="${left_part:$position:1}" right_digit="${right_part:$position:1}" if [ "$left_digit" != "$right_digit" ]; then [ "$left_digit" -gt "$right_digit" ] return fi position=$((position + 1)) done fi index=$((index + 1)) done return 1 } is_git_object_id() { local oid="$1" [[ "$oid" =~ ^[0-9a-f]+$ ]] || return 1 [ "${#oid}" -eq 40 ] || [ "${#oid}" -eq 64 ] } is_nonnegative_git_count() { local value="$1" [[ "$value" =~ ^(0|[1-9][0-9]*)$ ]] || return 1 # Bash's `[` accepts decimal strings syntactically but emits `integer # expected` and returns false for values outside its signed integer range. # Every caller branches on the result, so accepting an oversized count here # would turn an unknown count into zero and could allow a destructive reset. # Keep the bound below both Bash's range and JavaScript's safe-integer limit, # and reject before any arithmetic comparison is attempted. [ "${#value}" -le 16 ] || return 1 if [ "${#value}" -eq 16 ] && [ "$value" -gt 9007199254740991 ]; then return 1 fi return 0 } port_listener_probe() { local output="" rc=0 if command -v ss >/dev/null 2>&1; then if ! output="$(ss -ltn 2>/dev/null)"; then return 2 fi printf '%s\n' "$output" | grep -q ":$PORT " return $? fi if command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP:"$PORT" -sTCP:LISTEN -t >/dev/null 2>&1 rc=$? [ "$rc" -eq 0 ] && return 0 [ "$rc" -eq 1 ] && return 1 return 2 fi return 2 } pm2_process_is_live() { local pids="" pid="" found="" PM2_LIVE_PID="" if ! pids="$(pm2 pid "$NAME" 2>/dev/null)"; then return 1 fi while IFS= read -r pid; do [[ "$pid" =~ ^[1-9][0-9]*$ ]] || continue if kill -0 "$pid" 2>/dev/null; then [ -z "$found" ] || return 1 found="$pid" fi done <<< "$pids" [ -n "$found" ] || return 1 PM2_LIVE_PID="$found" return 0 } # A rollback must prove that the failed target is no longer represented by the # named PM2 app before Git moves the checkout back. Real PM2 exits successfully # and prints an empty result for a deleted name, while a stopped entry reports # `0`. Any positive PID is still a PM2 record even when that OS process has just # died: accepting it based on `kill -0` would race PM2's restart/accounting and # could move Git underneath a process that comes back from the failed target. pm2_named_process_is_absent() { local pids="" pid="" if ! pids="$(pm2 pid "$NAME" 2>/dev/null)"; then return 1 fi while IFS= read -r pid; do [ -z "$pid" ] && continue [ "$pid" = "0" ] && continue return 1 done <<< "$pids" return 0 } health_response_matches_commit() { local body="$1" expected="$2" expected_pid="$3" [[ "$expected_pid" =~ ^[1-9][0-9]*$ ]] || return 1 [[ "$expected" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]] || return 1 if command -v node >/dev/null 2>&1; then printf '%s' "$body" | node -e ' const fs = require("node:fs"); let body; try { body = JSON.parse(fs.readFileSync(0, "utf8")); } catch { process.exit(1); } const expectedCommit = process.argv[1].toLowerCase(); const expectedPid = Number(process.argv[2]); const actualCommit = typeof body?.commit === "string" ? body.commit.trim().toLowerCase() : ""; const commitMatches = /^[0-9a-f]{7,64}$/.test(actualCommit) && (actualCommit === expectedCommit || expectedCommit.startsWith(actualCommit)); if (!body || Array.isArray(body) || body.status !== "ok" || !commitMatches || !Number.isSafeInteger(body.pid) || body.pid !== expectedPid) process.exit(1); ' "$expected" "$expected_pid" >/dev/null 2>&1 return $? fi if command -v jq >/dev/null 2>&1; then printf '%s' "$body" | jq -e --arg expected "$expected" --argjson expectedPid "$expected_pid" \ 'type == "object" and .status == "ok" and (.commit | type == "string") and ((.commit | ascii_downcase) as $actual | ($expected | ascii_downcase) as $wanted | ($actual | test("^[0-9a-f]{7,64}$")) and ($actual == $wanted or ($wanted | startswith($actual)))) and (.pid | type == "number") and .pid == $expectedPid' \ >/dev/null 2>&1 return $? fi return 1 } if ! command -v pm2 >/dev/null 2>&1; then echo "✗ pm2 is required for a safe update; refusing to mutate Git or the LS installation" exit 1 fi echo "=== [1/5] Pull latest ===" # Fetch only the release branch here. Published-tag authority comes from # `ls-remote` below; importing/pruning the remote tag namespace would either keep # revoked local refs alive or destroy operator-owned local tags. git fetch --quiet --no-tags origin master BEFORE=$(git rev-parse HEAD) REMOTE_HEAD=$(git rev-parse origin/master) DIRTY=$(git status --porcelain) FORCE_RESET="${WINDSURFAPI_UPDATE_FORCE_RESET:-0}" FORCE_UPDATE="${WINDSURFAPI_UPDATE_FORCE:-0}" # 版本门禁(tag gate): normal OTA installs the newest published tag, not # origin/master HEAD. Post-tag release notes, generated assets, and work for # the next release must not make the published release impossible to install. # WINDSURFAPI_UPDATE_FORCE=1 explicitly opts into the untagged branch HEAD. # Filter exact stable semver before choosing. The remote ref namespace is the # publication authority: `git fetch --tags` never removes a revoked tag, so # selecting from `git tag --list` can reinstall a release deleted upstream. # Each candidate is fetched only into FETCH_HEAD, preserving all local tags. LATEST_TAG="" RELEASE_COMMIT="" UPDATE_TAG_LIST_TMP="$(mktemp "$GIT_DIR_PATH/windsurfapi-release-tags.XXXXXX")" if ! git ls-remote --tags origin 'refs/tags/v[0-9]*' > "$UPDATE_TAG_LIST_TMP"; then echo " ! could not verify published release tags; refusing update" exit 1 fi REMOTE_TAG_NAMES=() REMOTE_TAG_OBJECTS=() REMOTE_TAG_PEELED=() while IFS= read -r advertised_line || [ -n "$advertised_line" ]; do if [[ "$advertised_line" != *$'\t'* ]] || [[ "${advertised_line#*$'\t'}" == *$'\t'* ]]; then echo " ! remote tag enumeration returned a malformed record; refusing update" exit 1 fi advertised_oid="${advertised_line%%$'\t'*}" advertised_ref="${advertised_line#*$'\t'}" if [ -z "$advertised_oid" ] || [ -z "$advertised_ref" ] \ || ! is_git_object_id "$advertised_oid" || [[ "$advertised_ref" != refs/tags/* ]]; then echo " ! remote tag enumeration returned a malformed record; refusing update" exit 1 fi advertised_name="${advertised_ref#refs/tags/}" advertised_peeled="0" if [[ "$advertised_name" == *'^{}' ]]; then advertised_name="${advertised_name:0:${#advertised_name}-3}" advertised_peeled="1" fi if [ -z "$advertised_name" ] || [[ "$advertised_name" == .* ]] \ || [[ "$advertised_name" == *. ]] || [[ "$advertised_name" == */ ]] \ || [[ "$advertised_name" == *'..'* ]] || [[ "$advertised_name" == *'//'* ]] \ || [[ "$advertised_name" == *'@{'* ]] || [[ "$advertised_name" =~ [[:space:]~^:?*\[\\] ]]; then echo " ! remote tag enumeration returned an invalid ref name; refusing update" exit 1 fi [[ "$advertised_name" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || continue advertised_index="-1" for index in "${!REMOTE_TAG_NAMES[@]}"; do if [ "${REMOTE_TAG_NAMES[$index]}" = "$advertised_name" ]; then advertised_index="$index" break fi done if [ "$advertised_peeled" = "1" ]; then if [ "$advertised_index" -lt 0 ] || [ -n "${REMOTE_TAG_PEELED[$advertised_index]:-}" ]; then echo " ! remote tag enumeration returned an orphan/duplicate peeled ref; refusing update" exit 1 fi REMOTE_TAG_PEELED[$advertised_index]="$advertised_oid" else if [ "$advertised_index" -ge 0 ]; then echo " ! remote tag enumeration returned a duplicate tag ref; refusing update" exit 1 fi REMOTE_TAG_NAMES+=("$advertised_name") REMOTE_TAG_OBJECTS+=("$advertised_oid") REMOTE_TAG_PEELED+=("") fi done < "$UPDATE_TAG_LIST_TMP" # Consider stable versions from newest to oldest, selecting the first whose # exact remote object peels correctly and is reachable from origin/master. REMOTE_TAG_USED=() while true; do newest_index="-1" for index in "${!REMOTE_TAG_NAMES[@]}"; do [ "${REMOTE_TAG_USED[$index]:-0}" = "1" ] && continue if [ "$newest_index" -lt 0 ] || stable_tag_is_newer "${REMOTE_TAG_NAMES[$index]}" "${REMOTE_TAG_NAMES[$newest_index]}"; then newest_index="$index" fi done [ "$newest_index" -ge 0 ] || break REMOTE_TAG_USED[$newest_index]="1" candidate="${REMOTE_TAG_NAMES[$newest_index]}" expected_object="${REMOTE_TAG_OBJECTS[$newest_index]}" expected_peeled="${REMOTE_TAG_PEELED[$newest_index]:-}" if ! git fetch --quiet --no-tags origin "refs/tags/$candidate"; then echo " ! could not fetch remote release tag $candidate; refusing update" exit 1 fi if ! fetched_object=$(git rev-parse FETCH_HEAD 2>/dev/null) \ || ! is_git_object_id "$fetched_object" || [ "$fetched_object" != "$expected_object" ]; then echo " ! remote release tag $candidate changed during verification; refusing update" exit 1 fi if ! fetched_commit=$(git rev-parse 'FETCH_HEAD^{commit}' 2>/dev/null) \ || ! is_git_object_id "$fetched_commit" \ || ! fetched_type=$(git cat-file -t "$fetched_object" 2>/dev/null); then echo " ! remote release tag $candidate did not resolve to a verified commit; refusing update" exit 1 fi if [ -n "$expected_peeled" ]; then if [ "$fetched_type" != "tag" ] || [ "$fetched_commit" != "$expected_peeled" ]; then echo " ! annotated remote tag $candidate failed peeled-commit correspondence; refusing update" exit 1 fi elif [ "$fetched_type" != "commit" ] || [ "$fetched_commit" != "$fetched_object" ]; then echo " ! lightweight remote tag $candidate did not name one commit; refusing update" exit 1 fi if git merge-base --is-ancestor "$fetched_commit" origin/master; then LATEST_TAG="$candidate" RELEASE_COMMIT="$fetched_commit" break else ancestor_status=$? if [ "$ancestor_status" -ne 1 ]; then echo " ! could not verify whether $candidate is reachable from origin/master; refusing update" exit 1 fi fi done if [ "$FORCE_UPDATE" = "1" ]; then TARGET="$REMOTE_HEAD" elif [ -z "$LATEST_TAG" ]; then echo " ! no stable release tag is reachable from origin/master; refusing to follow an unverified branch HEAD" echo " set WINDSURFAPI_UPDATE_FORCE=1 only if you intentionally want origin/master" exit 1 else TARGET="$RELEASE_COMMIT" fi if [ -n "$LATEST_TAG" ]; then if ! UNRELEASED=$(git rev-list --count "${RELEASE_COMMIT}..${REMOTE_HEAD}" 2>/dev/null); then echo " ! could not verify commits after the latest release; refusing update" exit 1 fi if ! is_nonnegative_git_count "$UNRELEASED"; then echo " ! Git returned an invalid release-distance count; refusing update" exit 1 fi if [ "${UNRELEASED:-0}" -gt 0 ]; then echo " i 远端有 ${UNRELEASED} 个未发布提交;本次只安装 ${LATEST_TAG}" if [ "$FORCE_UPDATE" = "1" ]; then echo " WINDSURFAPI_UPDATE_FORCE=1:改为跟随 origin/master" fi fi fi if ! TO_TARGET=$(git rev-list --count "HEAD..${TARGET}" 2>/dev/null); then echo " ! could not verify whether the target is ahead; refusing update" exit 1 fi if ! is_nonnegative_git_count "$TO_TARGET"; then echo " ! Git returned an invalid target-ahead count; refusing update" exit 1 fi if ! PAST_TARGET=$(git rev-list --count "${TARGET}..HEAD" 2>/dev/null); then echo " ! could not verify whether this checkout is past the target; refusing update" exit 1 fi if ! is_nonnegative_git_count "$PAST_TARGET"; then echo " ! Git returned an invalid target-past count; refusing update" exit 1 fi if ! UNPUSHED=$(git rev-list --count "${REMOTE_HEAD}..HEAD" 2>/dev/null); then echo " ! could not verify local commits against origin/master; refusing update" exit 1 fi if ! is_nonnegative_git_count "$UNPUSHED"; then echo " ! Git returned an invalid local-commit count; refusing update" exit 1 fi if [ "${TO_TARGET:-0}" -gt 0 ] && [ "${PAST_TARGET:-0}" -gt 0 ]; then echo " ! 当前 HEAD 与更新目标 ${TARGET:0:7} 已分叉;拒绝非 fast-forward 更新" exit 1 fi RESET_TARGET="$TARGET" if [ "$FORCE_RESET" = "1" ] && [ "${TO_TARGET:-0}" -eq 0 ] && [ "${PAST_TARGET:-0}" -gt 0 ] && [ "${UNPUSHED:-0}" -eq 0 ]; then RESET_TARGET="$BEFORE" fi MUTATION_TARGET="" MUTATION_EXIT=0 if [ "$FORCE_RESET" = "1" ]; then MUTATION_TARGET="$RESET_TARGET" elif [ "${TO_TARGET:-0}" -gt 0 ]; then MUTATION_TARGET="$TARGET" fi if [ -n "$MUTATION_TARGET" ]; then if ! check_ignored_target_conflicts "$MUTATION_TARGET"; then exit 1 fi fi # ── D9 source-OTA attestation ───────────────────────────────────────── # The selected release tag must carry a release-attestation.json whose commit # equals the peeled tag commit, signed by the release workflow identity. This # runs before any stash/reset/merge and before the service restart. # warn (default): missing attestation/bundle/cosign logs a warning and the # update proceeds, so existing installs keep working; # required: missing material refuses the update. # A present but mismatched or invalid attestation is always fatal. ATTESTATION_MODE="${WINDSURFAPI_UPDATE_ATTESTATION:-warn}" case "$ATTESTATION_MODE" in warn|required) ;; *) echo " ! invalid WINDSURFAPI_UPDATE_ATTESTATION='$ATTESTATION_MODE' (expected warn or required); refusing update" exit 1 ;; esac if [ -n "$LATEST_TAG" ] && [ "$FORCE_UPDATE" != "1" ] \ && { [ -z "$MUTATION_TARGET" ] || [ "$MUTATION_TARGET" = "$TARGET" ]; }; then ATTESTATION_REPOSITORY="" ORIGIN_URL="$(git remote get-url origin 2>/dev/null || true)" case "$ORIGIN_URL" in https://github.com/dwgx/WindsurfAPI|https://github.com/dwgx/WindsurfAPI.git|git@github.com:dwgx/WindsurfAPI.git|ssh://git@github.com/dwgx/WindsurfAPI.git) ATTESTATION_REPOSITORY="dwgx/WindsurfAPI" ;; esac ATTESTATION_ARGS=(--tag "$LATEST_TAG" --commit "$RELEASE_COMMIT" --mode "$ATTESTATION_MODE") [ -n "$ATTESTATION_REPOSITORY" ] && ATTESTATION_ARGS+=(--repository "$ATTESTATION_REPOSITORY") [ -n "${WINDSURFAPI_ATTESTATION_DIR:-}" ] && ATTESTATION_ARGS+=(--dir "$WINDSURFAPI_ATTESTATION_DIR") [ -n "${COSIGN:-}" ] && ATTESTATION_ARGS+=(--cosign "$COSIGN") if command -v node >/dev/null 2>&1 && [ -f scripts/verify-release-attestation.mjs ]; then if ! node scripts/verify-release-attestation.mjs "${ATTESTATION_ARGS[@]}"; then echo " ! release attestation verification failed for $LATEST_TAG; refusing update before any Git mutation" exit 1 fi elif [ "$ATTESTATION_MODE" = "required" ]; then echo " ! node or scripts/verify-release-attestation.mjs is missing; WINDSURFAPI_UPDATE_ATTESTATION=required refuses update" exit 1 else echo " ! node or the release attestation verifier is missing; continuing because WINDSURFAPI_UPDATE_ATTESTATION=warn" fi fi if [ "$FORCE_RESET" = "1" ]; then if [ -n "$DIRTY" ]; then echo " ! preserving local changes in a stash before forced reset" STASH_BEFORE=$(git for-each-ref --format='%(objectname)' refs/stash) STASH_MARKER="windsurfapi-pre-update-${UPDATE_LOCK_TOKEN}" git stash push --include-untracked -m "$STASH_MARKER" STASH_LISTING=$(git stash list --format='%H%x09%gs') OWNED_STASH_OID=$(printf '%s\n' "$STASH_LISTING" | awk -v marker="$STASH_MARKER" ' index($0, marker) { count++; oid=$1 } END { if (count > 1) exit 2; if (count == 1) print oid } ') if [ -n "$OWNED_STASH_OID" ] && [[ ! "$OWNED_STASH_OID" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]; then echo " ! protective stash resolved to an invalid object ID; refusing reset" exit 1 fi if [ -z "$OWNED_STASH_OID" ]; then STASH_AFTER=$(git for-each-ref --format='%(objectname)' refs/stash) if [ "$STASH_AFTER" != "$STASH_BEFORE" ]; then echo " ! refs/stash changed but this updater could not identify its own stash; refusing reset" exit 1 fi echo " i no protective stash was created; the working tree was cleaned before stash" fi fi # force-reset normally means "clean my working tree and continue updating". # If this checkout already contains the release target and is itself on the # remote branch, resetting to the tag would be an accidental downgrade. echo " ! forced reset to ${RESET_TARGET:0:7}" if git reset --hard "$RESET_TARGET"; then : else MUTATION_EXIT=$? fi else if [ -n "$DIRTY" ] || [ "${UNPUSHED:-0}" -gt 0 ]; then echo " ! local changes or commits detected; refusing destructive update" echo " review them first, or set WINDSURFAPI_UPDATE_FORCE_RESET=1" exit 1 fi if [ "${TO_TARGET:-0}" -gt 0 ]; then if git merge --ff-only --quiet "$TARGET"; then : else MUTATION_EXIT=$? fi fi fi if ! AFTER=$(git rev-parse HEAD); then echo " ! could not verify HEAD after the Git mutation; refusing to continue" exit 1 fi if [ -n "$MUTATION_TARGET" ]; then if ! TRACKED_AFTER=$(git status --porcelain --untracked-files=no --ignore-submodules=none); then echo " ! could not verify tracked state after the Git mutation; refusing to continue" exit 1 fi if [ "$AFTER" != "$MUTATION_TARGET" ] || [ -n "$TRACKED_AFTER" ]; then echo " ! Git did not reach the expected clean target; refusing to report update success" echo " expected: $MUTATION_TARGET" echo " actual: $AFTER" if [ -n "$TRACKED_AFTER" ]; then echo " tracked changes remain:" printf '%s\n' "$TRACKED_AFTER" | head -20 fi if [ -n "$OWNED_STASH_OID" ]; then echo " protective stash remains at $OWNED_STASH_OID" fi exit 1 fi if [ "$MUTATION_EXIT" -ne 0 ]; then echo " ! Git returned exit $MUTATION_EXIT after reaching the verified clean target; continuing" fi elif [ "$MUTATION_EXIT" -ne 0 ]; then echo " ! Git mutation failed with exit $MUTATION_EXIT" exit "$MUTATION_EXIT" fi if [ "$BEFORE" = "$AFTER" ]; then echo " 已是最新 / Already up to date" else echo " $BEFORE → $AFTER" git log --oneline "$BEFORE..$AFTER" 2>/dev/null | head -10 || true fi echo "" echo "=== [2/5] Update LS binary ===" LS_PATH="${LS_BINARY_PATH:-/opt/windsurf/language_server_linux_x64}" if [ -f .env ]; then _lp="$(awk ' /^[[:space:]]*(export[[:space:]]+)?LS_BINARY_PATH[[:space:]]*=/ { sub(/^[[:space:]]*(export[[:space:]]+)?LS_BINARY_PATH[[:space:]]*=[[:space:]]*/, "") if (substr($0, 1, 1) != "\"" && substr($0, 1, 1) != "'\''") { sub(/[[:space:]]+#.*/, "") } sub(/[[:space:]]*$/, "") if ((substr($0, 1, 1) == "\"" && substr($0, length($0), 1) == "\"") || (substr($0, 1, 1) == "'\''" && substr($0, length($0), 1) == "'\''")) { $0 = substr($0, 2, length($0) - 2) } print $0 exit } ' .env 2>/dev/null || true)" [ -n "$_lp" ] && LS_PATH="$_lp" fi if [ ! -f install-ls.sh ]; then echo " ! install-ls.sh not found; cannot update LS binary" exit 1 fi echo " Updating via install-ls.sh -> $LS_PATH" if LS_INSTALL_PATH="$LS_PATH" bash install-ls.sh; then echo " LS binary update finished" else _ls_rc=$? if [ -s "$LS_PATH" ]; then echo " ! LS binary update failed (exit $_ls_rc); keeping existing binary at $LS_PATH" else echo " ! LS binary update failed and no existing binary exists at $LS_PATH" exit "$_ls_rc" fi fi echo "" echo "=== [3/5] Stop service ===" pm2 stop "$NAME" >/dev/null 2>&1 || true pm2 delete "$NAME" >/dev/null 2>&1 || true # Never fall back to a command-line substring kill here: another checkout or # an unrelated service can also contain `src/index.js`. PM2 owns the named app; # the remaining fallback is bounded to this deployment's configured port. fuser -k "$PORT"/tcp >/dev/null 2>&1 || true PORT_RELEASED=0 for i in $(seq 1 30); do # `set -e` must not turn the probe's meaningful 1 (free) or 2 (unknown) # result into an unverified process exit before the state machine handles it. if port_listener_probe; then probe_rc=0 else probe_rc=$? fi if [ "$probe_rc" -eq 1 ]; then PORT_RELEASED=1 break fi if [ "$probe_rc" -ne 0 ]; then echo " ! could not verify whether service port $PORT is free; entering verified rollback" SERVICE_STOP_FAILED=1 break fi sleep 1 done if [ "$PORT_RELEASED" -ne 1 ]; then if [ "$SERVICE_STOP_FAILED" -eq 0 ]; then echo " ! service port $PORT remains occupied after stop; entering verified rollback" fi SERVICE_STOP_FAILED=1 fi echo "" echo "=== [4/5] Start service ===" PM2_START_EXIT=0 if [ "$SERVICE_STOP_FAILED" -eq 1 ]; then PM2_START_EXIT=125 else if pm2 start src/index.js --name "$NAME" --cwd "$(pwd)"; then pm2 save >/dev/null 2>&1 || true if ! pm2_process_is_live; then PM2_START_EXIT=126 echo " ! pm2 reported success but the named app has no live process; entering verified rollback" fi else PM2_START_EXIT=$? echo " ! pm2 start failed with exit $PM2_START_EXIT; entering verified rollback" fi fi echo "" echo "=== [5/5] Health check + rollback on failure ===" # audit #5: `curl -sf ... | head` returned head's exit code (always 0), so a 500 # /health or an unbound port passed the check. Capture first — curl -sf's own # exit code (nonzero on HTTP >=400 / connection failure) is now authoritative. HEALTH_OUT="" HEALTH_OK=0 if [ "$PM2_START_EXIT" -eq 0 ]; then sleep 3 if HEALTH_OUT="$(curl -sf --connect-timeout 5 --max-time 15 "http://localhost:$PORT/health")" \ && health_response_matches_commit "$HEALTH_OUT" "$AFTER" "$PM2_LIVE_PID"; then HEALTH_OK=1 else HEALTH_OK=0 echo " ! /health did not identify the expected running commit ${AFTER:0:12}" fi fi if [ "$HEALTH_OK" = 1 ]; then echo "$HEALTH_OUT" | head -200 echo "" echo "" echo "✓ Update complete. Dashboard: http://\$YOUR_IP:$PORT/dashboard" else echo "" echo "✗ Health check failed after update. Auto-rolling back to $BEFORE ..." # Stop/delete the failed target before changing the checkout. A bare # `pm2 start --name` retry is not a rollback: real PM2 can reject a duplicate # name, reuse the old process identity, or leave the target code resident in # memory after Git has moved back. Keep the proof separate from the initial # stop gate so a health failure is audited again on its own path. ROLLBACK_SERVICE_STOP_FAILED=0 pm2 stop "$NAME" >/dev/null 2>&1 || true pm2 delete "$NAME" >/dev/null 2>&1 || true if ! pm2_named_process_is_absent; then echo " ! could not prove that the failed PM2 app $NAME is absent" ROLLBACK_SERVICE_STOP_FAILED=1 fi fuser -k "$PORT"/tcp >/dev/null 2>&1 || true ROLLBACK_PORT_RELEASED=0 for i in $(seq 1 30); do if port_listener_probe; then rollback_probe_rc=0 else rollback_probe_rc=$? fi if [ "$rollback_probe_rc" -eq 1 ]; then ROLLBACK_PORT_RELEASED=1 break fi if [ "$rollback_probe_rc" -ne 0 ]; then echo " ! could not verify whether service port $PORT is free during rollback" ROLLBACK_SERVICE_STOP_FAILED=1 break fi sleep 1 done if [ "$ROLLBACK_PORT_RELEASED" -ne 1 ]; then if [ "$ROLLBACK_SERVICE_STOP_FAILED" -eq 0 ]; then echo " ! service port $PORT remains occupied during rollback" fi ROLLBACK_SERVICE_STOP_FAILED=1 fi if [ "$ROLLBACK_SERVICE_STOP_FAILED" -eq 0 ] && ! pm2_named_process_is_absent; then echo " ! the failed PM2 app $NAME reappeared while waiting for its listener to stop" ROLLBACK_SERVICE_STOP_FAILED=1 fi if [ "$ROLLBACK_SERVICE_STOP_FAILED" -eq 1 ]; then echo "✗ Automatic rollback refused before changing Git because the failed service could not be proven stopped." echo " The failed target remains checked out at $AFTER; no stash was restored." if [ -n "$OWNED_STASH_OID" ]; then echo " Protective stash remains at $OWNED_STASH_OID." fi exit 1 fi if ! check_ignored_target_conflicts "$BEFORE"; then echo "✗ Automatic rollback refused because checking out $BEFORE could overwrite ignored owner data." echo " The failed target remains checked out; preserve the reported paths and recover manually." if [ -n "$OWNED_STASH_OID" ]; then echo " Protective stash remains at $OWNED_STASH_OID." fi exit 1 fi ROLLBACK_MUTATION_EXIT=0 if git reset --hard "$BEFORE"; then : else ROLLBACK_MUTATION_EXIT=$? fi if ! ROLLBACK_HEAD=$(git rev-parse HEAD); then echo "✗ Could not verify HEAD after automatic rollback; manual recovery required." [ -n "$OWNED_STASH_OID" ] && echo " Protective stash remains at $OWNED_STASH_OID." exit 1 fi if ! ROLLBACK_TRACKED=$(git status --porcelain --untracked-files=no --ignore-submodules=none); then echo "✗ Could not verify tracked state after automatic rollback; manual recovery required." [ -n "$OWNED_STASH_OID" ] && echo " Protective stash remains at $OWNED_STASH_OID." exit 1 fi if [ "$ROLLBACK_HEAD" != "$BEFORE" ] || [ -n "$ROLLBACK_TRACKED" ]; then echo "✗ Automatic rollback did not reach the expected clean target; manual recovery required." echo " expected: $BEFORE" echo " actual: $ROLLBACK_HEAD" if [ -n "$ROLLBACK_TRACKED" ]; then echo " tracked changes remain:" printf '%s\n' "$ROLLBACK_TRACKED" | head -20 fi [ -n "$OWNED_STASH_OID" ] && echo " Protective stash remains at $OWNED_STASH_OID." exit 1 fi if [ "$ROLLBACK_MUTATION_EXIT" -ne 0 ]; then echo " ! Git returned exit $ROLLBACK_MUTATION_EXIT after reaching the verified clean rollback target; continuing" fi STASH_RESTORE_OK=1 if [ -n "$OWNED_STASH_OID" ]; then echo " restoring exact protective stash ${OWNED_STASH_OID:0:12} (backup remains in git stash)" if ! git stash apply "$OWNED_STASH_OID" 2>/dev/null; then STASH_RESTORE_OK=0 echo " ! local changes were NOT restored; backup remains at $OWNED_STASH_OID" fi fi if pm2 start src/index.js --name "$NAME" --cwd "$(pwd)" >/dev/null 2>&1; then pm2 save >/dev/null 2>&1 || true if ! pm2_process_is_live; then ROLLBACK_PM2_START_EXIT=126 echo "✗ Code rolled back to $BEFORE, but the named PM2 app has no live process; manual recovery required." exit 1 fi else ROLLBACK_PM2_START_EXIT=$? echo "✗ Code rolled back to $BEFORE, but PM2 restart failed with exit $ROLLBACK_PM2_START_EXIT; manual recovery required." exit 1 fi sleep 3 if ROLLBACK_OUT="$(curl -sf --connect-timeout 5 --max-time 15 "http://localhost:$PORT/health")" \ && health_response_matches_commit "$ROLLBACK_OUT" "$BEFORE" "$PM2_LIVE_PID"; then ROLLBACK_OK=1 else ROLLBACK_OK=0 echo " ! rollback /health did not identify the expected commit ${BEFORE:0:12}" fi if [ "$ROLLBACK_OK" = 1 ]; then echo "$ROLLBACK_OUT" | head -200 echo "" if [ "$STASH_RESTORE_OK" != 1 ]; then echo "✗ Code rolled back to $BEFORE and the service is healthy, but local changes still require manual recovery from $OWNED_STASH_OID." exit 1 fi echo "✓ Rolled back to $BEFORE — service healthy. Check 'pm2 logs $NAME' for the original failure." exit 0 fi echo "✗ Rollback also failed. Manual intervention required. Check 'pm2 logs $NAME'." exit 1 fi