#!/usr/bin/env bash set -Eeuo pipefail # Safe release-style deploy script for tiku-supabase. # # Server layout: # DEPLOY_ROOT/ # current -> releases/- # releases/ # shared/ # deploy.env # deploy settings, not committed # .env # API/worker production env, not committed # h5-student.runtime-config.json # h5-tenant-admin.runtime-config.json # h5-platform-admin.runtime-config.json # # Recommended first server run: # mkdir -p /opt/tiku-saas/shared # cp deploy.env.example /opt/tiku-saas/shared/deploy.env # vim /opt/tiku-saas/shared/deploy.env # vim /opt/tiku-saas/shared/.env # bash deploy.sh SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" log() { printf '[deploy] %s\n' "$*" } warn() { printf '[deploy][warn] %s\n' "$*" >&2 } die() { printf '[deploy][error] %s\n' "$*" >&2 exit 1 } truthy() { case "${1:-}" in 1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;; *) return 1 ;; esac } run() { log "+ $*" "$@" } run_shell() { log "+ $*" bash -lc "$*" } source_if_exists() { local file="$1" if [[ -f "$file" ]]; then # shellcheck source=/dev/null source "$file" fi } export_dotenv_if_exists() { local file="$1" local line key value [[ -f "$file" ]] || return 0 while IFS= read -r line || [[ -n "$line" ]]; do line="${line%$'\r'}" [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue line="${line#export }" [[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue key="${line%%=*}" value="${line#*=}" if [[ "$value" == \"*\" && "$value" == *\" ]]; then value="${value:1:${#value}-2}" elif [[ "$value" == \'*\' && "$value" == *\' ]]; then value="${value:1:${#value}-2}" fi if [[ -z "${!key+x}" ]]; then export "$key=$value" fi done < "$file" } if [[ -n "${DEPLOY_CONFIG:-}" ]]; then source_if_exists "$DEPLOY_CONFIG" else source_if_exists "$SCRIPT_DIR/deploy.env" source_if_exists "$SCRIPT_DIR/.deploy.env" fi : "${APP_NAME:=tiku-supabase}" : "${REPO_URL:=https://git.gongxue100.com/chenhaogxjy/tiku-supabase.git}" : "${BRANCH:=main}" : "${DEPLOY_ROOT:=/opt/tiku-saas}" source_if_exists "$DEPLOY_ROOT/shared/deploy.env" : "${RELEASES_DIR:=$DEPLOY_ROOT/releases}" : "${SHARED_DIR:=$DEPLOY_ROOT/shared}" : "${CURRENT_LINK:=$DEPLOY_ROOT/current}" : "${KEEP_RELEASES:=5}" : "${GIT_DEPTH:=1}" : "${NODE_ENV:=production}" : "${NPM_INSTALL_COMMAND:=npm ci --workspaces --include-workspace-root --include=dev}" : "${NPM_AUDIT_REGISTRY:=https://registry.npmjs.org/}" : "${RUN_CHECKS:=true}" : "${CHECK_COMMANDS:=npm run check:api npm run check:worker npm run check:taro}" : "${RUN_API_BUILD:=true}" : "${RUN_WORKER_BUILD:=true}" : "${RUN_TARO_H5_BUILD:=true}" : "${RUN_SECURITY_REPO_SCAN:=true}" : "${RUN_RUNTIME_AUDIT:=true}" : "${RUN_TARO_SUPPLY_CHAIN_AUDIT:=true}" : "${RUN_PRODUCTION_READINESS:=auto}" : "${RUN_DB_READINESS:=true}" : "${RUN_DB_MIGRATIONS:=false}" : "${DATABASE_MIGRATION_URL:=}" : "${DB_MIGRATION_COMMAND:=supabase db push --db-url \"\$DATABASE_MIGRATION_URL\"}" : "${STRICT_H5_RUNTIME_CONFIG:=true}" : "${RUN_H5_SMOKE:=true}" : "${RUN_LAUNCH_GATE:=true}" : "${PRODUCTION_LAUNCH_EVIDENCE:=$SHARED_DIR/production-launch-evidence.json}" : "${H5_STUDENT_RUNTIME_CONFIG:=$SHARED_DIR/h5-student.runtime-config.json}" : "${H5_TENANT_RUNTIME_CONFIG:=$SHARED_DIR/h5-tenant-admin.runtime-config.json}" : "${H5_PLATFORM_RUNTIME_CONFIG:=$SHARED_DIR/h5-platform-admin.runtime-config.json}" : "${WWW_ROOT:=/srv/tiku-saas/www}" : "${WWW_RELEASES_DIR:=${WWW_ROOT%/}-releases}" : "${WWW_CURRENT_LINK:=$WWW_ROOT}" : "${SERVICE_REPO_DIR:=/opt/tiku-saas/repo}" : "${SYNC_SERVICE_REPO:=true}" : "${SERVICE_MODE:=systemd}" : "${SYSTEMD_UNITS:=tiku-api.service tiku-workers.target}" : "${PM2_ECOSYSTEM:=}" : "${PM2_PROCESS_NAMES:=}" : "${COMPOSE_FILE:=}" : "${RESTART_COMMAND:=}" : "${HEALTHCHECK_URL:=http://127.0.0.1:8787/health}" : "${HEALTHCHECK_TIMEOUT_SECONDS:=60}" : "${HEALTHCHECK_INTERVAL_SECONDS:=2}" : "${GIT_TERMINAL_PROMPT:=0}" export GIT_TERMINAL_PROMPT export DATABASE_MIGRATION_URL LOCK_DIR="$DEPLOY_ROOT/.deploy.lock" LOCK_ACQUIRED=false PREVIOUS_RELEASE="" NEW_RELEASE="" PREVIOUS_WWW_RELEASE="" NEW_WWW_RELEASE="" PREVIOUS_SERVICE_BACKUP="" ROLLBACK_ARMED=false APP_SWITCHED=false SERVICE_SYNC_STARTED=false WWW_SWITCH_STARTED=false ASKPASS_FILE="" cleanup() { local status=$? if [[ "$status" -ne 0 && "$ROLLBACK_ARMED" == "true" ]]; then ROLLBACK_ARMED=false rollback || true fi if [[ -n "$ASKPASS_FILE" && -f "$ASKPASS_FILE" ]]; then rm -f "$ASKPASS_FILE" fi if [[ "$LOCK_ACQUIRED" == "true" && -d "$LOCK_DIR" ]]; then rmdir "$LOCK_DIR" 2>/dev/null || true fi } trap cleanup EXIT prepare_askpass() { if [[ -z "${GIT_TOKEN:-}" && -z "${GITEA_TOKEN:-}" ]]; then return 0 fi local token="${GIT_TOKEN:-${GITEA_TOKEN:-}}" local username="${GIT_USERNAME:-oauth2}" ASKPASS_FILE="$(mktemp "${TMPDIR:-/tmp}/tiku-git-askpass.XXXXXX")" chmod 700 "$ASKPASS_FILE" cat > "$ASKPASS_FILE" <<'EOF' #!/usr/bin/env bash case "$1" in *Username*) printf '%s\n' "$GIT_ASKPASS_USERNAME" ;; *Password*) printf '%s\n' "$GIT_ASKPASS_TOKEN" ;; *) printf '%s\n' "$GIT_ASKPASS_TOKEN" ;; esac EOF export GIT_ASKPASS="$ASKPASS_FILE" export GIT_ASKPASS_USERNAME="$username" export GIT_ASKPASS_TOKEN="$token" } require_command() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" } validate_deploy_contract() { [[ "$WWW_RELEASES_DIR" != "$WWW_ROOT" ]] || die "WWW_RELEASES_DIR must be outside WWW_ROOT" case "${WWW_RELEASES_DIR%/}/" in "${WWW_ROOT%/}/"*) die "WWW_RELEASES_DIR must not be nested under WWW_ROOT" ;; esac [[ "$SERVICE_REPO_DIR" != "$RELEASES_DIR" ]] || die "SERVICE_REPO_DIR must not equal RELEASES_DIR" if [[ "$NODE_ENV" != "production" ]]; then return 0 fi truthy "$RUN_TARO_H5_BUILD" || die "Production deployment requires RUN_TARO_H5_BUILD=true" truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT" \ || die "Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true" truthy "$STRICT_H5_RUNTIME_CONFIG" || die "Production deployment requires STRICT_H5_RUNTIME_CONFIG=true" truthy "$RUN_H5_SMOKE" || die "Production deployment requires RUN_H5_SMOKE=true" truthy "$RUN_LAUNCH_GATE" || die "Production deployment requires RUN_LAUNCH_GATE=true" truthy "$RUN_DB_READINESS" \ || die "Production deployment requires RUN_DB_READINESS=true before launch gate" if truthy "$RUN_DB_MIGRATIONS"; then [[ -n "$DATABASE_MIGRATION_URL" ]] \ || die "Production database migrations require a separate DATABASE_MIGRATION_URL" fi [[ -n "$RESTART_COMMAND" || "$SERVICE_MODE" != "none" ]] \ || die "Production deployment requires a service restart strategy" [[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL" if [[ "$SERVICE_MODE" == "systemd" ]]; then truthy "$SYNC_SERVICE_REPO" || die "Production systemd deployment requires SYNC_SERVICE_REPO=true" [[ -n "$SERVICE_REPO_DIR" ]] || die "Production systemd deployment requires SERVICE_REPO_DIR" fi } acquire_lock() { mkdir -p "$DEPLOY_ROOT" if ! mkdir "$LOCK_DIR" 2>/dev/null; then die "Another deployment appears to be running: $LOCK_DIR" fi LOCK_ACQUIRED=true } link_shared_env() { local release="$1" local env_file="$SHARED_DIR/.env" if [[ -f "$env_file" ]]; then ln -sfn "$env_file" "$release/.env" else warn "No $env_file found. Production readiness and runtime may fail until it exists." fi } load_runtime_env() { local env_file="$SHARED_DIR/.env" [[ -r "$env_file" ]] || die "Missing API runtime config: $env_file" export_dotenv_if_exists "$env_file" } link_runtime_config() { local source_file="$1" local target_dir="$2" local label="$3" if [[ ! -d "$target_dir" ]]; then warn "H5 dist directory missing for $label: $target_dir" return 0 fi if [[ -f "$source_file" ]]; then ln -sfn "$source_file" "$target_dir/runtime-config.json" elif truthy "$STRICT_H5_RUNTIME_CONFIG"; then die "Missing $label runtime config: $source_file" else warn "Missing $label runtime config: $source_file" fi } link_h5_runtime_configs() { local release="$1" link_runtime_config "$H5_STUDENT_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-student" "student" link_runtime_config "$H5_TENANT_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-tenant-admin" "tenant-admin" link_runtime_config "$H5_PLATFORM_RUNTIME_CONFIG" "$release/apps/taro/dist/h5-platform-admin" "platform-admin" } should_run_production_readiness() { case "$RUN_PRODUCTION_READINESS" in true|TRUE|1|yes|YES|on|ON) return 0 ;; false|FALSE|0|no|NO|off|OFF) return 1 ;; auto) if [[ "$NODE_ENV" == "production" ]]; then return 0 fi if [[ -f "$SHARED_DIR/.env" ]] && grep -Eq '^NODE_ENV=production($|[[:space:]]*)' "$SHARED_DIR/.env"; then return 0 fi return 1 ;; *) die "RUN_PRODUCTION_READINESS must be true, false, or auto" ;; esac } run_build_and_checks() { local release="$1" cd "$release" [[ "$NPM_INSTALL_COMMAND" == *"npm ci"* ]] \ || die "NPM_INSTALL_COMMAND must use npm ci for a locked production install" [[ "$NPM_INSTALL_COMMAND" == *"--include=dev"* ]] \ || die "NPM_INSTALL_COMMAND must include Taro build dependencies with --include=dev" [[ "$NPM_INSTALL_COMMAND" != *"--ignore-scripts"* ]] \ || die "NPM_INSTALL_COMMAND must allow the reviewed Taro workspace postinstall patches" run_shell "$NPM_INSTALL_COMMAND" if truthy "$RUN_CHECKS"; then while IFS= read -r command_line; do [[ -z "$command_line" ]] && continue run_shell "$command_line" done <<< "$CHECK_COMMANDS" fi if truthy "$RUN_API_BUILD"; then run npm run build:api fi if truthy "$RUN_WORKER_BUILD"; then run npm run build:worker fi if truthy "$RUN_TARO_H5_BUILD"; then if truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT"; then run npm run audit:taro:supply-chain fi run npm run build:taro:h5:student run npm run build:taro:h5:tenant run npm run build:taro:h5:platform link_h5_runtime_configs "$release" if truthy "$STRICT_H5_RUNTIME_CONFIG"; then run node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config run npm run manifest:taro:h5 -- --require-dist --require-runtime-config else run node scripts/taro-h5-release-guardrails-test.js --require-dist run npm run manifest:taro:h5 -- --require-dist fi if truthy "$RUN_H5_SMOKE"; then run npm run smoke:taro:h5 run npm run smoke:taro:h5:interaction fi fi if truthy "$RUN_SECURITY_REPO_SCAN"; then run npm run security:repo fi if truthy "$RUN_RUNTIME_AUDIT"; then run env NPM_AUDIT_REGISTRY="$NPM_AUDIT_REGISTRY" npm run audit:runtime fi link_shared_env "$release" load_runtime_env if should_run_production_readiness; then run npm run readiness:production fi if truthy "$RUN_DB_MIGRATIONS"; then run_shell "$DB_MIGRATION_COMMAND" fi if truthy "$RUN_DB_READINESS"; then log "Running production database readiness after the optional migration step" run npm run readiness:production:db fi if truthy "$RUN_LAUNCH_GATE"; then [[ -f "$PRODUCTION_LAUNCH_EVIDENCE" ]] || die "Missing production launch evidence: $PRODUCTION_LAUNCH_EVIDENCE" run env \ DEPLOY_COMMIT_SHA="$(git rev-parse HEAD)" \ DEPLOY_RELEASE_ROOT="$release" \ npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" fi } verify_live_h5_release() { local release="$1" truthy "$RUN_LAUNCH_GATE" || return 0 [[ -f "$PRODUCTION_LAUNCH_EVIDENCE" ]] || die "Missing production launch evidence: $PRODUCTION_LAUNCH_EVIDENCE" log "Verifying the activated H5 release against production URLs" ( cd "$release" env \ DEPLOY_COMMIT_SHA="$(git rev-parse HEAD)" \ DEPLOY_RELEASE_ROOT="$release" \ npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" --verify-live-h5 ) } stage_h5_release() { local release="$1" local release_name release_name="$(basename "$release")" local staging="$WWW_RELEASES_DIR/.tmp-$release_name" require_command rsync rm -rf "$staging" mkdir -p "$staging/student" "$staging/tenant-admin" "$staging/platform-admin" run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-student/" "$staging/student/" run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-tenant-admin/" "$staging/tenant-admin/" run rsync -a --delete --copy-links "$release/apps/taro/dist/h5-platform-admin/" "$staging/platform-admin/" NEW_WWW_RELEASE="$WWW_RELEASES_DIR/$release_name" rm -rf "$NEW_WWW_RELEASE" mv "$staging" "$NEW_WWW_RELEASE" } atomic_symlink() { local target="$1" local link="$2" local next_link="${link}.next.$$" rm -f "$next_link" ln -s "$target" "$next_link" if [[ -L "$link" || ! -e "$link" ]]; then mv -Tf "$next_link" "$link" return 0 fi rm -f "$next_link" return 1 } switch_www_release() { [[ -n "$NEW_WWW_RELEASE" ]] || return 0 WWW_SWITCH_STARTED=true if [[ -L "$WWW_CURRENT_LINK" ]]; then PREVIOUS_WWW_RELEASE="$(readlink -f "$WWW_CURRENT_LINK")" elif [[ -d "$WWW_CURRENT_LINK" ]]; then PREVIOUS_WWW_RELEASE="$WWW_RELEASES_DIR/bootstrap-$(date +%Y%m%d%H%M%S)" log "Moving existing Web root to $PREVIOUS_WWW_RELEASE" mv "$WWW_CURRENT_LINK" "$PREVIOUS_WWW_RELEASE" elif [[ -e "$WWW_CURRENT_LINK" ]]; then die "WWW root exists but is not a directory or symlink: $WWW_CURRENT_LINK" fi atomic_symlink "$NEW_WWW_RELEASE" "$WWW_CURRENT_LINK" \ || die "$WWW_CURRENT_LINK exists and cannot be replaced by the release symlink" } snapshot_service_repo_for_rollback() { if [[ -n "$PREVIOUS_RELEASE" || ! -d "$SERVICE_REPO_DIR" || -L "$SERVICE_REPO_DIR" ]]; then return 0 fi require_command rsync PREVIOUS_SERVICE_BACKUP="$RELEASES_DIR/bootstrap-service-$(date +%Y%m%d%H%M%S)" mkdir -p "$PREVIOUS_SERVICE_BACKUP" run rsync -a --delete --exclude .git "$SERVICE_REPO_DIR/" "$PREVIOUS_SERVICE_BACKUP/" } sync_service_repo() { local release="$1" truthy "$SYNC_SERVICE_REPO" || return 0 [[ -n "$release" && -d "$release" ]] || return 1 require_command rsync if [[ -L "$SERVICE_REPO_DIR" ]]; then [[ "$(readlink -f "$SERVICE_REPO_DIR")" == "$(readlink -f "$release")" ]] \ || die "SERVICE_REPO_DIR symlink must resolve to the selected current release" return 0 fi mkdir -p "$SERVICE_REPO_DIR" run rsync -a --delete --exclude .git "$release/" "$SERVICE_REPO_DIR/" } restart_services() { if [[ -d "$CURRENT_LINK" ]]; then cd "$CURRENT_LINK" elif [[ -d "$SERVICE_REPO_DIR" ]]; then cd "$SERVICE_REPO_DIR" else die "Neither CURRENT_LINK nor SERVICE_REPO_DIR is available for service restart" fi if [[ -n "$RESTART_COMMAND" ]]; then run_shell "$RESTART_COMMAND" || return 1 return 0 fi case "$SERVICE_MODE" in none) warn "SERVICE_MODE=none; release switched but no service was restarted." ;; systemd) [[ -n "$SYSTEMD_UNITS" ]] || die "SYSTEMD_UNITS is required when SERVICE_MODE=systemd" for unit in $SYSTEMD_UNITS; do run systemctl restart "$unit" || return 1 done for unit in $SYSTEMD_UNITS; do run systemctl is-active --quiet "$unit" || return 1 done ;; pm2) require_command pm2 if [[ -n "$PM2_ECOSYSTEM" ]]; then run pm2 startOrReload "$PM2_ECOSYSTEM" --update-env || return 1 elif [[ -n "$PM2_PROCESS_NAMES" ]]; then for name in $PM2_PROCESS_NAMES; do run pm2 restart "$name" --update-env || return 1 done else die "PM2_ECOSYSTEM or PM2_PROCESS_NAMES is required when SERVICE_MODE=pm2" fi ;; compose) require_command docker [[ -n "$COMPOSE_FILE" ]] || die "COMPOSE_FILE is required when SERVICE_MODE=compose" run docker compose -f "$COMPOSE_FILE" up -d --build || return 1 ;; *) die "Unknown SERVICE_MODE: $SERVICE_MODE" ;; esac } healthcheck() { if [[ -z "$HEALTHCHECK_URL" ]]; then return 0 fi require_command curl local deadline=$((SECONDS + HEALTHCHECK_TIMEOUT_SECONDS)) log "Waiting for healthcheck: $HEALTHCHECK_URL" while (( SECONDS < deadline )); do if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then log "Healthcheck passed." return 0 fi sleep "$HEALTHCHECK_INTERVAL_SECONDS" done return 1 } switch_current() { local release="$1" if [[ -e "$CURRENT_LINK" && ! -L "$CURRENT_LINK" ]]; then die "$CURRENT_LINK exists and is not a symlink; refusing to replace it" fi if [[ -L "$CURRENT_LINK" ]]; then PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK")" fi atomic_symlink "$release" "$CURRENT_LINK" || die "Failed to switch $CURRENT_LINK" } rollback() { local rollback_failed=false if [[ "$WWW_SWITCH_STARTED" == "true" && -n "$PREVIOUS_WWW_RELEASE" ]]; then warn "Rolling Web root back to $PREVIOUS_WWW_RELEASE" atomic_symlink "$PREVIOUS_WWW_RELEASE" "$WWW_CURRENT_LINK" || rollback_failed=true elif [[ "$WWW_SWITCH_STARTED" == "true" && -n "$NEW_WWW_RELEASE" ]]; then warn "No previous Web release recorded; restoring an absent Web root." rm -f "$WWW_CURRENT_LINK" || rollback_failed=true fi local service_rollback_release="$PREVIOUS_RELEASE" if [[ -z "$service_rollback_release" ]]; then service_rollback_release="$PREVIOUS_SERVICE_BACKUP" fi if [[ "$APP_SWITCHED" == "true" && -n "$PREVIOUS_RELEASE" ]]; then warn "Rolling application release back to $PREVIOUS_RELEASE" atomic_symlink "$PREVIOUS_RELEASE" "$CURRENT_LINK" || rollback_failed=true elif [[ "$APP_SWITCHED" == "true" && -n "$NEW_RELEASE" ]]; then warn "No previous application current release recorded; restoring an absent current link." rm -f "$CURRENT_LINK" || rollback_failed=true fi if [[ "$SERVICE_SYNC_STARTED" == "true" ]] && truthy "$SYNC_SERVICE_REPO" && [[ -n "$service_rollback_release" ]]; then sync_service_repo "$service_rollback_release" || rollback_failed=true fi if [[ "$SERVICE_SYNC_STARTED" == "true" ]]; then restart_services || rollback_failed=true healthcheck || rollback_failed=true fi [[ "$rollback_failed" == "false" ]] } prune_releases() { local keep="$1" local directory="${2:-$RELEASES_DIR}" [[ "$keep" =~ ^[0-9]+$ ]] || return 0 (( keep > 0 )) || return 0 find "$directory" -mindepth 1 -maxdepth 1 -type d ! -name '.*' ! -name 'bootstrap-*' -print \ | sort -r \ | tail -n +"$((keep + 1))" \ | while IFS= read -r old_release; do if [[ "$old_release" == "$NEW_RELEASE" || "$old_release" == "$PREVIOUS_RELEASE" \ || "$old_release" == "$NEW_WWW_RELEASE" || "$old_release" == "$PREVIOUS_WWW_RELEASE" ]]; then continue fi log "Pruning old release: $old_release" rm -rf "$old_release" done } main() { require_command git require_command npm acquire_lock validate_deploy_contract mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$WWW_RELEASES_DIR" prepare_askpass local timestamp timestamp="$(date +%Y%m%d%H%M%S)" local tmp_release="$RELEASES_DIR/.tmp-$timestamp" log "Cloning $REPO_URL#$BRANCH" run git clone --depth "$GIT_DEPTH" --branch "$BRANCH" "$REPO_URL" "$tmp_release" local commit commit="$(git -C "$tmp_release" rev-parse --short=12 HEAD)" NEW_RELEASE="$RELEASES_DIR/$timestamp-$commit" mv "$tmp_release" "$NEW_RELEASE" log "Building release $NEW_RELEASE" run_build_and_checks "$NEW_RELEASE" log "Staging H5 release for $WWW_ROOT" stage_h5_release "$NEW_RELEASE" if [[ -L "$CURRENT_LINK" ]]; then PREVIOUS_RELEASE="$(readlink -f "$CURRENT_LINK")" fi snapshot_service_repo_for_rollback ROLLBACK_ARMED=true log "Switching current release to $NEW_RELEASE" switch_current "$NEW_RELEASE" APP_SWITCHED=true log "Synchronizing service runtime to $SERVICE_REPO_DIR" SERVICE_SYNC_STARTED=true if ! sync_service_repo "$NEW_RELEASE"; then ROLLBACK_ARMED=false rollback || true die "Service runtime synchronization failed; rollback attempted." fi if ! restart_services; then ROLLBACK_ARMED=false rollback || true die "Service restart failed; rollback attempted." fi if ! healthcheck; then ROLLBACK_ARMED=false rollback || true die "Healthcheck failed; rollback attempted." fi log "Switching Web root to $NEW_WWW_RELEASE" switch_www_release verify_live_h5_release "$NEW_RELEASE" ROLLBACK_ARMED=false prune_releases "$KEEP_RELEASES" "$RELEASES_DIR" prune_releases "$KEEP_RELEASES" "$WWW_RELEASES_DIR" log "Deploy complete: $APP_NAME @ $commit" } main "$@"