#Requires -Version 5.1 <# .SYNOPSIS Git-based incremental deployment for gongxiangp2. .DESCRIPTION Source of truth is the Git repository. The build server fetches only Git deltas, builds a Docker image with cache, pushes commit-tagged images to the internal registry, and the production server pulls the selected image. This script intentionally does not upload a source tarball from the local workstation and does not build on the production server. #> param( [ValidateSet('production', 'shadow')] [string]$Target = 'production', [string]$RepoUrl = 'http://git.1080p.hk/WuYongJie/gongxiangp2.git', [string]$Branch = 'main', [string]$Commit = '', [string]$GitUsername = $env:GIT_DEPLOY_USERNAME, [string]$GitPassword = $env:GIT_DEPLOY_PASSWORD, [string]$GitAuthHeader = $env:GIT_AUTH_HEADER, [string]$BuildServerIP = '192.168.40.206', [string]$BuildServerUser = 'root', [string]$BuildServerPassword = $env:BUILD_SERVER_PASSWORD, [string]$BuildServerProxyCommand = $env:BUILD_SERVER_PROXY_COMMAND, [string]$RemoteBuildDir = '/root/youyidian-3d-git-build', [string]$ProdServerIP = '192.168.58.20', [string]$ProdServerUser = 'root', [int]$RegistryPort = 5000, [string]$ImageName = 'youyidian-3d-material', [string]$DownloadTokenSecret = $env:DOWNLOAD_TOKEN_SECRET, [switch]$DryRun, [switch]$NoCache, [switch]$SkipBuild, [switch]$SkipPush, [switch]$SkipDeploy, [switch]$KeepOldImages, [switch]$AggressiveBuildCacheCleanup, [switch]$NoRollback ) $ErrorActionPreference = 'Stop' $targetConfig = @{ production = @{ Port = 4320 ContainerName = 'youyidian-3d-material' EnvFile = '.env' PublicAppUrl = 'https://sc.jiansun.vip' CorsAllowedOrigins = 'https://sc.jiansun.vip,http://192.168.58.20:4320' DingtalkCallbackOrigin = 'https://sc.jiansun.vip' DingtalkInternalReturnOrigins = 'http://192.168.58.20:4320' S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan' } shadow = @{ Port = 5320 ContainerName = 'youyidian-3d-shadow' EnvFile = '.env.shadow' PublicAppUrl = 'http://192.168.58.20:5320' CorsAllowedOrigins = 'http://192.168.58.20:5320' DingtalkCallbackOrigin = 'http://192.168.58.20:5320' DingtalkInternalReturnOrigins = 'http://192.168.58.20:5320' S3PublicDomain = 'https://s3.jiansun.vip/gongxiangpan' } } function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } function Write-Warning-Custom { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } function Write-Error-Custom { param([string]$Message) Write-Host "[ERR] $Message" -ForegroundColor Red } function Write-Step { param([string]$Message) Write-Host "" Write-Host "==================================================" -ForegroundColor Cyan Write-Host $Message -ForegroundColor Magenta Write-Host "==================================================" -ForegroundColor Cyan } function ConvertTo-ShellSingleQuoted { param([AllowNull()][string]$Value) if ($null -eq $Value) { return "''" } return "'" + $Value.Replace("'", "'\''") + "'" } function Get-PlinkPath { $candidates = @( 'C:\Program Files\PuTTY\plink.exe', 'C:\Program Files (x86)\PuTTY\plink.exe' ) foreach ($candidate in $candidates) { if (Test-Path $candidate) { return $candidate } } $fromPath = Get-Command plink.exe -ErrorAction SilentlyContinue if ($fromPath) { return $fromPath.Source } throw 'Cannot find plink.exe. Please install PuTTY or add plink.exe to PATH.' } function Get-PscpPath { $candidates = @( 'C:\Program Files\PuTTY\pscp.exe', 'C:\Program Files (x86)\PuTTY\pscp.exe' ) foreach ($candidate in $candidates) { if (Test-Path $candidate) { return $candidate } } $fromPath = Get-Command pscp.exe -ErrorAction SilentlyContinue if ($fromPath) { return $fromPath.Source } throw 'Cannot find pscp.exe. Please install PuTTY or add pscp.exe to PATH.' } function Protect-LogLine { param([string]$Line) $masked = $Line foreach ($secret in @($GitPassword, $BuildServerPassword, $DownloadTokenSecret, $GitAuthHeader)) { if (-not [string]::IsNullOrEmpty($secret)) { $masked = $masked.Replace($secret, '***') } } return $masked } function Invoke-BuildServerScript { param([string]$ScriptText) $usePasswordAuth = -not [string]::IsNullOrWhiteSpace($BuildServerPassword) $plinkPath = $null $pscpPath = $null $sshPath = $null $scpPath = $null $sshCommonArgs = @() $scpCommonArgs = @() if ($usePasswordAuth) { if (-not [string]::IsNullOrWhiteSpace($BuildServerProxyCommand)) { throw 'BuildServerProxyCommand is supported only with OpenSSH key authentication.' } $plinkPath = Get-PlinkPath $pscpPath = Get-PscpPath } else { $sshCommand = Get-Command ssh.exe -ErrorAction SilentlyContinue $scpCommand = Get-Command scp.exe -ErrorAction SilentlyContinue if (-not $sshCommand -or -not $scpCommand) { throw 'Build server password is empty, and OpenSSH ssh.exe/scp.exe were not found for key-based deployment.' } $sshPath = $sshCommand.Source $scpPath = $scpCommand.Source $sshCommonArgs = @('-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=no', '-o', 'ConnectTimeout=10') $scpCommonArgs = @('-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=no', '-o', 'ConnectTimeout=10') if (-not [string]::IsNullOrWhiteSpace($BuildServerProxyCommand)) { $sshCommonArgs += @('-o', "ProxyCommand=$BuildServerProxyCommand") $scpCommonArgs += @('-o', "ProxyCommand=$BuildServerProxyCommand") } } $scriptId = [guid]::NewGuid().ToString('N') $localTemp = Join-Path ([System.IO.Path]::GetTempPath()) "git-incremental-deploy-$scriptId.sh" $remoteTemp = "/tmp/git-incremental-deploy-$scriptId.sh" # PowerShell here-string 在 Windows 下使用 CRLF;远端 Bash 会把行尾的 # `\r` 当作参数内容,导致 `set -euo pipefail` 在脚本第一行就失败。 # 上传前统一为 LF,保证脚本在 Linux 构建机上可直接执行。 $normalizedScriptText = $ScriptText.Replace("`r`n", "`n").Replace("`r", "`n") [System.IO.File]::WriteAllText($localTemp, $normalizedScriptText, [System.Text.UTF8Encoding]::new($false)) $authLabel = if ($usePasswordAuth) { 'password auth' } elseif (-not [string]::IsNullOrWhiteSpace($BuildServerProxyCommand)) { 'SSH key auth via proxy command' } else { 'SSH key auth' } Write-Info "Connecting to build server $BuildServerUser@$BuildServerIP ($authLabel)" # 原生程序(plink/pscp)会把 git 等子进程的提示信息写到 stderr。 # 在 ErrorActionPreference=Stop 下,PowerShell 会把这些非致命的 stderr # 当成 NativeCommandError 抛出,导致部署在 Git Sync 等阶段误中断。 # 这里在调用原生程序的局部作用域内改为 Continue,让 stderr 通过 2>&1 # 合并进管道正常显示;真实失败仍由 $LASTEXITCODE 捕获,不会掩盖错误。 $previousErrorAction = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $remoteSpec = "${BuildServerUser}@${BuildServerIP}:$remoteTemp" $copyOutput = $null if ($usePasswordAuth) { $copyOutput = & $pscpPath -batch -pw $BuildServerPassword $localTemp $remoteSpec 2>&1 } else { $copyOutput = & $scpPath @scpCommonArgs $localTemp $remoteSpec 2>&1 } $copyOutput | ForEach-Object { $line = Protect-LogLine -Line ($_.ToString()) if ($line.Trim()) { Write-Host $line -ForegroundColor Gray } } if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE } $command = "bash '$remoteTemp'; rc=`$?; rm -f '$remoteTemp'; exit `$rc" $runOutput = $null if ($usePasswordAuth) { $runOutput = & $plinkPath -batch -pw $BuildServerPassword "$BuildServerUser@$BuildServerIP" $command 2>&1 } else { $runOutput = & $sshPath @sshCommonArgs "$BuildServerUser@$BuildServerIP" $command 2>&1 } $runOutput | ForEach-Object { $line = Protect-LogLine -Line ($_.ToString()) if ($line -match '^\[OK\]') { Write-Host $line -ForegroundColor Green } elseif ($line -match '^\[WARN\]') { Write-Host $line -ForegroundColor Yellow } elseif ($line -match '^\[ERR\]|error|failed|fatal' ) { Write-Host $line -ForegroundColor Red } else { Write-Host $line -ForegroundColor Gray } } return $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorAction Remove-Item $localTemp -Force -ErrorAction SilentlyContinue } } if ([string]::IsNullOrWhiteSpace($GitAuthHeader) -and -not [string]::IsNullOrWhiteSpace($GitUsername) -and -not [string]::IsNullOrWhiteSpace($GitPassword)) { $pair = "${GitUsername}:${GitPassword}" $basic = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair)) $GitAuthHeader = "Authorization: Basic $basic" } if ($RepoUrl -match '^https?://' -and [string]::IsNullOrWhiteSpace($GitAuthHeader)) { Write-Warning-Custom 'HTTP Git repository has no auth header. This is OK only if the repository is public or credentials are already configured on the build server.' } $cfg = $targetConfig[$Target] $rollbackFlag = if ($NoRollback) { '0' } else { '1' } $proxyReconcilerPath = Join-Path $PSScriptRoot 'local-proxy-reconcile.sh' if (-not (Test-Path -LiteralPath $proxyReconcilerPath)) { throw "Local proxy reconciler is missing: $proxyReconcilerPath" } $proxyReconcilerText = [System.IO.File]::ReadAllText($proxyReconcilerPath) $proxyReconcilerText = $proxyReconcilerText.Replace("`r`n", "`n").Replace("`r", "`n") $proxyReconcilerBytes = [System.Text.UTF8Encoding]::new($false).GetBytes($proxyReconcilerText) $proxyReconcilerBase64 = [Convert]::ToBase64String($proxyReconcilerBytes) $tokens = @{ '__REPO_URL__' = ConvertTo-ShellSingleQuoted $RepoUrl '__BRANCH__' = ConvertTo-ShellSingleQuoted $Branch '__COMMIT__' = ConvertTo-ShellSingleQuoted $Commit '__GIT_AUTH_HEADER__' = ConvertTo-ShellSingleQuoted $GitAuthHeader '__REMOTE_BUILD_DIR__' = ConvertTo-ShellSingleQuoted $RemoteBuildDir '__TARGET__' = ConvertTo-ShellSingleQuoted $Target '__BUILD_SERVER_IP__' = ConvertTo-ShellSingleQuoted $BuildServerIP '__PROD_SERVER_IP__' = ConvertTo-ShellSingleQuoted $ProdServerIP '__PROD_SERVER_USER__' = ConvertTo-ShellSingleQuoted $ProdServerUser '__REGISTRY_PORT__' = ConvertTo-ShellSingleQuoted ([string]$RegistryPort) '__IMAGE_NAME__' = ConvertTo-ShellSingleQuoted $ImageName '__CONTAINER_NAME__' = ConvertTo-ShellSingleQuoted $cfg.ContainerName '__PORT__' = ConvertTo-ShellSingleQuoted ([string]$cfg.Port) '__ENV_FILE__' = ConvertTo-ShellSingleQuoted $cfg.EnvFile '__PUBLIC_APP_URL__' = ConvertTo-ShellSingleQuoted $cfg.PublicAppUrl '__CORS_ALLOWED_ORIGINS__' = ConvertTo-ShellSingleQuoted $cfg.CorsAllowedOrigins '__DINGTALK_CALLBACK_ORIGIN__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkCallbackOrigin '__DINGTALK_INTERNAL_RETURN_ORIGINS__' = ConvertTo-ShellSingleQuoted $cfg.DingtalkInternalReturnOrigins '__S3_PUBLIC_DOMAIN__' = ConvertTo-ShellSingleQuoted $cfg.S3PublicDomain '__DOWNLOAD_TOKEN_SECRET__' = ConvertTo-ShellSingleQuoted $DownloadTokenSecret '__PROXY_RECONCILE_SCRIPT_B64__' = ConvertTo-ShellSingleQuoted $proxyReconcilerBase64 '__DRY_RUN__' = ConvertTo-ShellSingleQuoted ($(if ($DryRun) { '1' } else { '0' })) '__NO_CACHE__' = ConvertTo-ShellSingleQuoted ($(if ($NoCache) { '1' } else { '0' })) '__SKIP_BUILD__' = ConvertTo-ShellSingleQuoted ($(if ($SkipBuild) { '1' } else { '0' })) '__SKIP_PUSH__' = ConvertTo-ShellSingleQuoted ($(if ($SkipPush) { '1' } else { '0' })) '__SKIP_DEPLOY__' = ConvertTo-ShellSingleQuoted ($(if ($SkipDeploy) { '1' } else { '0' })) '__KEEP_OLD_IMAGES__' = ConvertTo-ShellSingleQuoted ($(if ($KeepOldImages) { '1' } else { '0' })) '__AGGRESSIVE_CLEANUP__' = ConvertTo-ShellSingleQuoted ($(if ($AggressiveBuildCacheCleanup) { '1' } else { '0' })) '__ROLLBACK_ON_FAILURE__' = ConvertTo-ShellSingleQuoted $rollbackFlag } $remoteTemplate = @' set -Eeuo pipefail REPO_URL=__REPO_URL__ BRANCH=__BRANCH__ COMMIT_INPUT=__COMMIT__ GIT_AUTH_HEADER=__GIT_AUTH_HEADER__ REMOTE_BUILD_DIR=__REMOTE_BUILD_DIR__ TARGET=__TARGET__ BUILD_SERVER_IP=__BUILD_SERVER_IP__ PROD_SERVER_IP=__PROD_SERVER_IP__ PROD_SERVER_USER=__PROD_SERVER_USER__ REGISTRY_PORT=__REGISTRY_PORT__ IMAGE_NAME=__IMAGE_NAME__ CONTAINER_NAME=__CONTAINER_NAME__ PORT=__PORT__ ENV_FILE=__ENV_FILE__ PUBLIC_APP_URL=__PUBLIC_APP_URL__ CORS_ALLOWED_ORIGINS=__CORS_ALLOWED_ORIGINS__ DINGTALK_CALLBACK_ORIGIN=__DINGTALK_CALLBACK_ORIGIN__ DINGTALK_INTERNAL_RETURN_ORIGINS=__DINGTALK_INTERNAL_RETURN_ORIGINS__ S3_PUBLIC_DOMAIN=__S3_PUBLIC_DOMAIN__ DOWNLOAD_TOKEN_SECRET=__DOWNLOAD_TOKEN_SECRET__ PROXY_RECONCILE_SCRIPT_B64=__PROXY_RECONCILE_SCRIPT_B64__ DRY_RUN=__DRY_RUN__ NO_CACHE=__NO_CACHE__ SKIP_BUILD=__SKIP_BUILD__ SKIP_PUSH=__SKIP_PUSH__ SKIP_DEPLOY=__SKIP_DEPLOY__ KEEP_OLD_IMAGES=__KEEP_OLD_IMAGES__ AGGRESSIVE_CLEANUP=__AGGRESSIVE_CLEANUP__ ROLLBACK_ON_FAILURE=__ROLLBACK_ON_FAILURE__ log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } warn() { printf '[WARN] %s\n' "$*"; } fail() { printf '[ERR] %s\n' "$*" >&2; exit 1; } git_cmd() { if [ -n "$GIT_AUTH_HEADER" ]; then git -c "http.extraHeader=$GIT_AUTH_HEADER" "$@" else git "$@" fi } require_cmd() { command -v "$1" >/dev/null 2>&1 || fail "Missing command on build server: $1" } start_remote_step() { printf '\n==================================================\n' printf '%s\n' "$1" printf '==================================================\n' } start_remote_step "Preflight" require_cmd git require_cmd docker require_cmd curl require_cmd ssh ok "Build server tools are available" ssh_prod_command() { local log_file="$1" shift ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=8 "$PROD_SERVER_USER@$PROD_SERVER_IP" "$@" >"$log_file" 2>&1 } retry_prod_ssh() { local log_file="$1" shift local max_attempts=5 local attempt for attempt in $(seq 1 "$max_attempts"); do if ssh_prod_command "$log_file" "$@"; then return 0 fi warn "Production SSH check failed, retrying (${attempt}/${max_attempts})" cat "$log_file" >&2 || true sleep $((attempt * 2)) done return 1 } REMOTE_HEAD="$(git_cmd ls-remote --heads "$REPO_URL" "$BRANCH" | awk '{print $1}' | head -n 1 || true)" if [ -z "$REMOTE_HEAD" ]; then fail "Cannot read Git branch $BRANCH from $REPO_URL" fi ok "Git branch $BRANCH is reachable: ${REMOTE_HEAD:0:12}" retry_prod_ssh /tmp/git-deploy-prod-check.log "echo OK" || { cat /tmp/git-deploy-prod-check.log >&2 || true fail "Cannot reach production server from build server" } ok "Production server is reachable from build server" curl -fsS "http://localhost:${REGISTRY_PORT}/v2/" >/dev/null 2>&1 || { fail "Docker registry is not reachable on build server port ${REGISTRY_PORT}" } ok "Docker registry is reachable" if [ "$DRY_RUN" = "1" ]; then log "Dry run completed. No code, image, or container was changed." exit 0 fi start_remote_step "Git Sync" if [ ! -d "$REMOTE_BUILD_DIR/.git" ]; then warn "Git worktree not found. Creating a fresh clone at $REMOTE_BUILD_DIR" rm -rf "$REMOTE_BUILD_DIR" mkdir -p "$(dirname "$REMOTE_BUILD_DIR")" git_cmd clone "$REPO_URL" "$REMOTE_BUILD_DIR" else log "Using existing Git worktree: $REMOTE_BUILD_DIR" fi cd "$REMOTE_BUILD_DIR" git remote set-url origin "$REPO_URL" git_cmd fetch --prune origin "$BRANCH" TARGET_REF="origin/$BRANCH" if [ -n "$COMMIT_INPUT" ]; then TARGET_REF="$COMMIT_INPUT" fi git checkout -B "$BRANCH" "origin/$BRANCH" >/dev/null 2>&1 || git checkout "$BRANCH" git reset --hard "$TARGET_REF" git clean -fdx COMMIT_SHA="$(git rev-parse HEAD)" SHORT_SHA="$(git rev-parse --short=12 HEAD)" COMMIT_TAG="${TARGET}-${SHORT_SHA}" MOVING_TAG="${TARGET}-latest" REGISTRY_COMMIT="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}:${COMMIT_TAG}" REGISTRY_MOVING="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}:${MOVING_TAG}" LOCAL_REGISTRY_COMMIT="localhost:${REGISTRY_PORT}/${IMAGE_NAME}:${COMMIT_TAG}" LOCAL_REGISTRY_MOVING="localhost:${REGISTRY_PORT}/${IMAGE_NAME}:${MOVING_TAG}" test -f Dockerfile || fail "Dockerfile missing after Git sync" test -f package.json || fail "package.json missing after Git sync" test -f "app/api/uploads/[...path]/route.ts" || fail "app/api/uploads fallback route missing" test -f "app/uploads/[...path]/route.ts" || fail "app/uploads fallback route missing" ok "Synced to commit $COMMIT_SHA" log "Image tag: $COMMIT_TAG" if [ "$SKIP_BUILD" != "1" ]; then start_remote_step "Docker Build" docker pull "$LOCAL_REGISTRY_MOVING" >/dev/null 2>&1 || true build_args=() if [ "$NO_CACHE" = "1" ]; then build_args+=(--no-cache) elif docker image inspect "$LOCAL_REGISTRY_MOVING" >/dev/null 2>&1; then build_args+=(--cache-from "$LOCAL_REGISTRY_MOVING") fi DOCKER_BUILDKIT=1 docker build "${build_args[@]}" \ -t "$IMAGE_NAME:$COMMIT_TAG" \ -t "$IMAGE_NAME:$MOVING_TAG" \ -t "$LOCAL_REGISTRY_COMMIT" \ -t "$LOCAL_REGISTRY_MOVING" \ -t "$REGISTRY_COMMIT" \ -t "$REGISTRY_MOVING" \ . ok "Docker image built: $REGISTRY_COMMIT" else warn "Skipping Docker build by request" fi if [ "$SKIP_PUSH" != "1" ]; then start_remote_step "Push Image" docker push "$LOCAL_REGISTRY_COMMIT" docker push "$LOCAL_REGISTRY_MOVING" ok "Pushed image tags: $COMMIT_TAG, $MOVING_TAG" else warn "Skipping image push by request" fi if [ "$SKIP_DEPLOY" != "1" ]; then start_remote_step "Deploy on Production Server" log "Ensuring production server can access image $REGISTRY_COMMIT" if ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" "docker pull '$REGISTRY_COMMIT'" >/tmp/git-deploy-prod-pull.log 2>&1; then ok "Production server pulled image from registry" else warn "Production server could not pull image directly; streaming image from build server" cat /tmp/git-deploy-prod-pull.log >&2 || true docker save "$REGISTRY_COMMIT" | ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" "docker load" ok "Image loaded on production server via SSH stream" fi PROD_PAYLOAD="$(cat <<'PROD_SCRIPT' set -Eeuo pipefail log() { printf '[INFO] %s\n' "$*"; } ok() { printf '[OK] %s\n' "$*"; } warn() { printf '[WARN] %s\n' "$*"; } fail() { printf '[ERR] %s\n' "$*" >&2; exit 1; } start_container() { local name="$1" local image="$2" local publish_host_port="${3:-0}" local deploy_version deploy_version="$(date +%s)" run_args=( docker run -d --name "$name" --network "$NETWORK_NAME" -v "${UPLOADS_MOUNT}:/app/uploads" -v "/mnt/PoolOne/apps/youyidian-3d/public-traffic-logs:/var/log/nginx:ro" --env-file "$ENV_FILE_PATH" -e "USE_REDIS=true" -e "REDIS_URL=redis://gongxiangpan-redis:6379" -e "NEXTAUTH_URL=$PUBLIC_APP_URL" -e "APP_PUBLIC_URL=$PUBLIC_APP_URL" -e "CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS" -e "NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN=$DINGTALK_CALLBACK_ORIGIN" -e "DINGTALK_INTERNAL_RETURN_ORIGINS=$DINGTALK_INTERNAL_RETURN_ORIGINS" -e "S3_PUBLIC_DOMAIN=$S3_PUBLIC_DOMAIN" -e "DISABLE_CACHE=true" -e "DEPLOYMENT_VERSION=$deploy_version" -e "DEPLOYMENT_COMMIT=$COMMIT_SHA" --memory=6g --memory-reservation=512m --restart=unless-stopped ) if [ "$publish_host_port" = "1" ]; then run_args+=(-p "${PORT}:3000") fi if [ -n "$DOWNLOAD_TOKEN_SECRET" ]; then run_args+=(-e "DOWNLOAD_TOKEN_SECRET=$DOWNLOAD_TOKEN_SECRET") fi if [ -n "${PUBLIC_TRAFFIC_ROLLUP_TOKEN:-}" ]; then run_args+=(-e "PUBLIC_TRAFFIC_ROLLUP_TOKEN=$PUBLIC_TRAFFIC_ROLLUP_TOKEN") fi "${run_args[@]}" "$image" } require_file() { [ -f "$1" ] || fail "Missing required file: $1" } command -v docker >/dev/null 2>&1 || fail "Missing docker on production server" command -v curl >/dev/null 2>&1 || fail "Missing curl on production server" command -v flock >/dev/null 2>&1 || fail "Missing flock on production server" command -v ss >/dev/null 2>&1 || fail "Missing ss on production server; safe connection draining cannot be verified" command -v base64 >/dev/null 2>&1 || fail "Missing base64 on production server" command -v nginx >/dev/null 2>&1 || fail "Missing nginx on production server" command -v systemctl >/dev/null 2>&1 || fail "Missing systemctl on production server" require_file "$ENV_FILE_PATH" [ -d "$UPLOADS_MOUNT" ] || fail "Uploads mount does not exist: $UPLOADS_MOUNT" docker network inspect "$NETWORK_NAME" >/dev/null 2>&1 || fail "Docker network missing: $NETWORK_NAME" command -v nginx >/dev/null 2>&1 || fail "Missing nginx on production server" log "Checking image $REGISTRY_COMMIT" if docker image inspect "$REGISTRY_COMMIT" >/dev/null 2>&1; then ok "Image already available on production server" else docker pull "$REGISTRY_COMMIT" fi PROXY_ROOT="/mnt/PoolOne/apps/youyidian-3d/local-proxy/${CONTAINER_NAME}" PROXY_CONF="${PROXY_ROOT}/nginx.conf" PROXY_PID="${PROXY_ROOT}/nginx.pid" PROXY_SERVICE="${CONTAINER_NAME}-local-proxy.service" CURRENT_FILE="${PROXY_ROOT}/current-container" PROXY_RECONCILE_SCRIPT="${PROXY_ROOT}/reconcile-upstream.sh" PROXY_RECONCILE_SERVICE="${CONTAINER_NAME}-local-proxy-reconcile.service" PROXY_RECONCILE_TIMER="${CONTAINER_NAME}-local-proxy-reconcile.timer" PROXY_RECONCILE_LOCK="/run/${CONTAINER_NAME}-local-proxy-reconcile.lock" BLUE_CONTAINER="${CONTAINER_NAME}-blue" GREEN_CONTAINER="${CONTAINER_NAME}-green" PUBLIC_TRAFFIC_SYNC_ROOT="/mnt/PoolOne/apps/youyidian-3d/public-traffic-sync" PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE="${PUBLIC_TRAFFIC_SYNC_ROOT}/rollup.token" PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT="${PUBLIC_TRAFFIC_SYNC_ROOT}/public-traffic-log-sync.sh" PUBLIC_TRAFFIC_ROLLUP_SCRIPT="${PUBLIC_TRAFFIC_SYNC_ROOT}/public-traffic-rollup-sync.sh" PUBLIC_TRAFFIC_ROLLUP_SERVICE="public-traffic-rollup-sync.service" PUBLIC_TRAFFIC_ROLLUP_TIMER="public-traffic-rollup-sync.timer" PUBLIC_TRAFFIC_ROLLUP_TOKEN="" ensure_public_traffic_rollup_token() { if [ "$TARGET" != "production" ]; then return 0 fi mkdir -p "$PUBLIC_TRAFFIC_SYNC_ROOT" chmod 700 "$PUBLIC_TRAFFIC_SYNC_ROOT" if [ ! -s "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" ]; then if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 > "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" else tr -dc 'A-Za-z0-9' "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" fi fi chmod 600 "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE" PUBLIC_TRAFFIC_ROLLUP_TOKEN="$(tr -d '\r\n' < "$PUBLIC_TRAFFIC_ROLLUP_TOKEN_FILE")" } install_public_traffic_rollup_timers() { if [ "$TARGET" != "production" ]; then return 0 fi mkdir -p "$PUBLIC_TRAFFIC_SYNC_ROOT" cat > "$PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT" <<'EOF' #!/usr/bin/env bash set -euo pipefail remote='trafficlog@218.93.9.38' key='/root/.ssh/public_traffic_log_sync_ed25519' dest='/mnt/PoolOne/apps/youyidian-3d/public-traffic-logs' ssh_opts="ssh -i ${key} -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10" domains=( sc.jiansun.vip s3.jiansun.vip cp.jiansun.vip ) mkdir -p "$dest" chmod 755 "$dest" status=0 for domain in "${domains[@]}"; do if ! rsync -rtz --timeout=45 --chmod=F644,D755 -e "$ssh_opts" "${remote}:/var/log/nginx/${domain}.access.log*" "$dest/"; then echo "WARN: failed to sync ${domain}.access.log*" >&2 status=1 fi done find "$dest" -maxdepth 1 -type f -name '*.access.log*' -exec chmod 0644 {} + exit "$status" EOF chmod 700 "$PUBLIC_TRAFFIC_LOG_SYNC_SCRIPT" cat > "/etc/systemd/system/public-traffic-log-sync.service" < "/etc/systemd/system/public-traffic-log-sync.timer" < "$PUBLIC_TRAFFIC_ROLLUP_SCRIPT" </dev/null || true)" if [ -z "\$container" ] || ! docker inspect "\$container" >/dev/null 2>&1; then echo "No active material container found" >&2 exit 1 fi systemctl start public-traffic-log-sync.service >/dev/null 2>&1 || true docker exec "\$container" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity 5m yesterday="\$(TZ=Asia/Shanghai date -d 'yesterday' +%F)" docker exec "\$container" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity day --start-date "\$yesterday" --end-date "\$yesterday" EOF chmod 700 "$PUBLIC_TRAFFIC_ROLLUP_SCRIPT" cat > "/etc/systemd/system/${PUBLIC_TRAFFIC_ROLLUP_SERVICE}" < "/etc/systemd/system/${PUBLIC_TRAFFIC_ROLLUP_TIMER}" </dev/null 2>&1 || true systemctl enable "$PUBLIC_TRAFFIC_ROLLUP_TIMER" >/dev/null 2>&1 || true systemctl restart public-traffic-log-sync.timer >/dev/null 2>&1 || true systemctl restart "$PUBLIC_TRAFFIC_ROLLUP_TIMER" >/dev/null 2>&1 || true } is_proxy_running() { systemctl is-active --quiet "$PROXY_SERVICE" 2>/dev/null || { [ -f "$PROXY_PID" ] && kill -0 "$(cat "$PROXY_PID")" >/dev/null 2>&1 } } container_ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1" 2>/dev/null || true } write_proxy_config() { local upstream="$1" local target_container="$2" local temp_conf="" local backup_conf="" case "$target_container" in "$BLUE_CONTAINER"|"$GREEN_CONTAINER") ;; *) warn "Refusing to write proxy config for invalid container: ${target_container}" return 1 ;; esac mkdir -p "$PROXY_ROOT" if ! temp_conf="$(mktemp "${PROXY_CONF}.tmp.XXXXXX")"; then return 1 fi cat > "$temp_conf" < "$service_temp" </dev/null 2>&1 || return 1 } switch_proxy() { ensure_proxy_service || return 1 if is_proxy_running; then systemctl reload "$PROXY_SERVICE" else systemctl start "$PROXY_SERVICE" fi } runtime_proxy_container() { local target="" target="$( curl -sS --max-time 5 -D - -o /dev/null "http://127.0.0.1:${PORT}/api/health" 2>/dev/null | tr -d '\r' | sed -nE 's/^X-Material-Proxy-Target:[[:space:]]*(.*)$/\1/Ip' | tail -n 1 || true )" case "$target" in "$BLUE_CONTAINER"|"$GREEN_CONTAINER") printf '%s\n' "$target" ;; *) printf '\n' ;; esac } verify_proxy_target() { local expected_container="$1" local observed_container="" for _ in $(seq 1 5); do observed_container="$(runtime_proxy_container)" if [ "$observed_container" = "$expected_container" ] && \ curl -fsS --max-time 8 "http://127.0.0.1:${PORT}/api/health" >/tmp/git-deploy-entry-health.json 2>/tmp/git-deploy-entry-health.err; then return 0 fi sleep 1 done warn "Proxy target verification failed: expected=${expected_container}, observed=${observed_container:-missing}" return 1 } install_proxy_reconciler() { local script_temp="" local service_file="/etc/systemd/system/${PROXY_RECONCILE_SERVICE}" local service_temp="" local timer_file="/etc/systemd/system/${PROXY_RECONCILE_TIMER}" local timer_temp="" mkdir -p "$PROXY_ROOT" || return 1 script_temp="$(mktemp "${PROXY_RECONCILE_SCRIPT}.tmp.XXXXXX")" printf '%s' "$PROXY_RECONCILE_SCRIPT_B64" | base64 -d > "$script_temp" bash -n "$script_temp" chmod 700 "$script_temp" mv "$script_temp" "$PROXY_RECONCILE_SCRIPT" service_temp="$(mktemp "${service_file}.tmp.XXXXXX")" cat > "$service_temp" < "$timer_temp" </dev/null 2>&1 || return 1 systemctl restart "$PROXY_RECONCILE_TIMER" } container_running() { [ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true)" = "true" ] } read_recorded_container() { local name="" if [ -s "$CURRENT_FILE" ]; then name="$(tr -d '\r\n' < "$CURRENT_FILE")" fi case "$name" in "$BLUE_CONTAINER"|"$GREEN_CONTAINER") printf '%s\n' "$name" ;; *) printf '\n' ;; esac } write_current_container() { local name="$1" local temp_file="" mkdir -p "$PROXY_ROOT" temp_file="$(mktemp "${CURRENT_FILE}.tmp.XXXXXX")" printf '%s\n' "$name" > "$temp_file" chmod 0644 "$temp_file" mv "$temp_file" "$CURRENT_FILE" } discover_current_container() { local recorded_container="" local runtime_container="" local running_containers=() local name="" recorded_container="$(read_recorded_container)" runtime_container="$(runtime_proxy_container)" if [ -n "$runtime_container" ] && container_running "$runtime_container"; then printf '%s\n' "$runtime_container" return 0 fi for name in "$BLUE_CONTAINER" "$GREEN_CONTAINER"; do if container_running "$name"; then running_containers+=("$name") fi done if [ "${#running_containers[@]}" -eq 1 ]; then printf '%s\n' "${running_containers[0]}" return 0 fi if [ "${#running_containers[@]}" -gt 1 ]; then warn "Multiple running material containers found without a verified runtime proxy target (recorded=${recorded_container:-missing})" return 1 fi printf '\n' } stop_proxy() { systemctl stop "$PROXY_SERVICE" >/dev/null 2>&1 || true } wait_for_container_health() { local name="$1" local ip="" for i in $(seq 1 24); do running="$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null || echo false)" ip="$(container_ip "$name")" if [ "$running" = "true" ] && [ -n "$ip" ]; then if curl -fsS --max-time 8 "http://${ip}:3000/api/health" >/tmp/git-deploy-health.json 2>/tmp/git-deploy-health.err; then printf '%s\n' "$ip" return 0 fi fi sleep 5 done return 1 } container_healthy() { local name="$1" local ip="" container_running "$name" || return 1 ip="$(container_ip "$name")" [ -n "$ip" ] || return 1 curl -fsS --max-time 8 "http://${ip}:3000/api/health" >/dev/null 2>&1 } container_upstream_connection_count() { local name="$1" local ip="" command -v ss >/dev/null 2>&1 || return 2 container_running "$name" || { printf '0\n' return 0 } ip="$(container_ip "$name")" [ -n "$ip" ] || return 2 # Let ss filter the peer endpoint itself; column positions differ between # iproute2 versions when the State column is omitted by -H. ss -Htan state established dst "${ip}:3000" 2>/dev/null | awk 'END { print NR + 0 }' } wait_for_container_drain() { local name="$1" local max_checks="${2:-15}" local count="" local zero_samples=0 for _ in $(seq 1 "$max_checks"); do if ! count="$(container_upstream_connection_count "$name")"; then warn "Cannot prove that upstream connections drained for ${name}; leaving it running" return 1 fi if [ "$count" = "0" ]; then zero_samples=$((zero_samples + 1)) if [ "$zero_samples" -ge 2 ]; then return 0 fi else zero_samples=0 log "Waiting for ${count} active upstream connection(s) to drain from ${name}" fi sleep 2 done warn "Active upstream connections did not drain from ${name}; leaving it running" return 1 } rollback_to_previous_entry() { local previous_ip="" [ "$ROLLBACK_ON_FAILURE" = "1" ] || return 1 if [ "$PROXY_WAS_RUNNING" = "1" ] && [ -n "$CURRENT_CONTAINER" ]; then if ! container_running "$CURRENT_CONTAINER"; then if ! docker start "$CURRENT_CONTAINER" >/dev/null; then warn "Failed to restart previous container $CURRENT_CONTAINER" return 1 fi if ! wait_for_container_health "$CURRENT_CONTAINER" >/dev/null; then warn "Previous container did not become healthy: $CURRENT_CONTAINER" return 1 fi elif ! container_healthy "$CURRENT_CONTAINER"; then warn "Previous container is not healthy: $CURRENT_CONTAINER" return 1 fi previous_ip="$(container_ip "$CURRENT_CONTAINER")" [ -n "$previous_ip" ] || return 1 if ! write_proxy_config "${previous_ip}:3000" "$CURRENT_CONTAINER"; then return 1 fi if ! switch_proxy; then return 1 fi if ! verify_proxy_target "$CURRENT_CONTAINER"; then return 1 fi write_current_container "$CURRENT_CONTAINER" return 0 fi if [ "$PROXY_WAS_RUNNING" != "1" ] && [ -n "$LEGACY_IMAGE" ]; then if ! wait_for_container_drain "$NEXT_CONTAINER" 15; then warn "Candidate still has an in-flight request; direct-container rollback was not attempted" return 1 fi stop_proxy >/dev/null 2>&1 || true docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true if ! start_container "$CONTAINER_NAME" "$LEGACY_IMAGE" 1 >/tmp/git-deploy-rollback-container-id.txt; then return 1 fi cat /tmp/git-deploy-rollback-container-id.txt for _ in $(seq 1 12); do if ! is_proxy_running && curl -fsS --max-time 8 "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then return 0 fi sleep 2 done fi return 1 } abort_after_uncertain_switch() { local reason="$1" warn "$reason" if rollback_to_previous_entry; then ok "Previous entry was restored and verified" if wait_for_container_drain "$NEXT_CONTAINER" 15; then docker rm -f "$NEXT_CONTAINER" >/dev/null 2>&1 || true else warn "Rolled-back candidate was retained because an in-flight request is still using it" fi fail "$reason; deployment was rolled back" fi warn "Rollback could not be verified; candidate container is being kept for recovery" fail "$reason; both containers were preserved" } mkdir -p "$PROXY_ROOT" exec 8>"$PROXY_RECONCILE_LOCK" flock -w 30 8 || fail "Another proxy switch or reconciliation is already running" install_proxy_reconciler RECORDED_CONTAINER="$(read_recorded_container)" if ! CURRENT_CONTAINER="$(discover_current_container)"; then fail "Cannot safely determine the active material container" fi if [ -n "$CURRENT_CONTAINER" ] && [ "$CURRENT_CONTAINER" != "$RECORDED_CONTAINER" ]; then warn "Repairing stale current-container state: ${RECORDED_CONTAINER:-missing} -> ${CURRENT_CONTAINER}" write_current_container "$CURRENT_CONTAINER" fi if [ "$CURRENT_CONTAINER" = "$BLUE_CONTAINER" ]; then NEXT_CONTAINER="$GREEN_CONTAINER" else NEXT_CONTAINER="$BLUE_CONTAINER" fi LEGACY_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || true)" if [ -n "$LEGACY_IMAGE" ]; then log "Legacy direct container detected: $LEGACY_IMAGE" fi PROXY_WAS_RUNNING=0 if is_proxy_running; then PROXY_WAS_RUNNING=1 fi ensure_public_traffic_rollup_token log "Starting candidate container $NEXT_CONTAINER" if container_running "$NEXT_CONTAINER" && ! wait_for_container_drain "$NEXT_CONTAINER" 15; then fail "Inactive deployment slot $NEXT_CONTAINER is still serving requests; retry after those requests finish" fi docker rm -f "$NEXT_CONTAINER" >/dev/null 2>&1 || true start_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT" 0 >/tmp/git-deploy-new-container-id.txt cat /tmp/git-deploy-new-container-id.txt if ! NEXT_IP="$(wait_for_container_health "$NEXT_CONTAINER")"; then warn "Candidate container failed health check" docker logs --tail 120 "$NEXT_CONTAINER" 2>&1 || true docker rm -f "$NEXT_CONTAINER" >/dev/null 2>&1 || true fail "Deployment failed before traffic switch; existing entry was kept" fi ok "Candidate container is healthy at ${NEXT_IP}:3000" if ! write_proxy_config "${NEXT_IP}:3000" "$NEXT_CONTAINER"; then docker rm -f "$NEXT_CONTAINER" >/dev/null 2>&1 || true fail "Candidate proxy configuration could not be prepared; existing entry was kept" fi if [ "$PROXY_WAS_RUNNING" != "1" ]; then warn "Stable local proxy is not running; migrating public entry from direct container to proxy" if [ -n "$LEGACY_IMAGE" ]; then docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true fi fi SWITCH_REPORTED_FAILURE=0 if ! switch_proxy; then SWITCH_REPORTED_FAILURE=1 fi if ! verify_proxy_target "$NEXT_CONTAINER"; then cat /tmp/git-deploy-entry-health.err >&2 2>/dev/null || true abort_after_uncertain_switch "Stable local entry did not verify the candidate target" fi if [ "$SWITCH_REPORTED_FAILURE" = "1" ]; then warn "Proxy switch command reported failure, but the runtime candidate target was independently verified" fi write_current_container "$NEXT_CONTAINER" sleep 3 if ! verify_proxy_target "$NEXT_CONTAINER"; then abort_after_uncertain_switch "Candidate target did not remain healthy during the post-switch check" fi if [ -n "$CURRENT_CONTAINER" ] && [ "$CURRENT_CONTAINER" != "$NEXT_CONTAINER" ]; then if [ "$(runtime_proxy_container)" != "$NEXT_CONTAINER" ] || \ [ "$(read_recorded_container)" != "$NEXT_CONTAINER" ] || \ ! container_healthy "$NEXT_CONTAINER"; then abort_after_uncertain_switch "Proxy state changed before the previous container could be drained" fi if wait_for_container_drain "$CURRENT_CONTAINER" 15; then if docker stop --time 30 "$CURRENT_CONTAINER" >/dev/null 2>&1; then ok "Previous container stopped and retained for rollback: $CURRENT_CONTAINER" else warn "Previous container could not be stopped and was retained: $CURRENT_CONTAINER" fi else warn "Previous container is still serving an in-flight request and was retained: $CURRENT_CONTAINER" fi fi if ! verify_proxy_target "$NEXT_CONTAINER"; then abort_after_uncertain_switch "Candidate target failed after the previous container drain" fi flock -u 8 if ! systemctl start "$PROXY_RECONCILE_SERVICE"; then fail "Proxy reconciler failed its post-deployment verification" fi ok "Stable local entry is healthy on port $PORT" docker logs --tail 20 "$NEXT_CONTAINER" 2>&1 || true if install_public_traffic_rollup_timers; then if [ "$TARGET" = "production" ]; then if systemctl start "$PUBLIC_TRAFFIC_ROLLUP_SERVICE" >/tmp/public-traffic-rollup-sync.log 2>/tmp/public-traffic-rollup-sync.err; then ok "Public traffic statistics rollups synced" else warn "Public traffic statistics rollup sync failed; page remains available, check /tmp/public-traffic-rollup-sync.err" cat /tmp/public-traffic-rollup-sync.err >&2 || true fi backfill_start="$(TZ=Asia/Shanghai date -d '7 days ago' +%F)" backfill_end="$(TZ=Asia/Shanghai date -d 'yesterday' +%F)" if docker exec "$NEXT_CONTAINER" python3 /app/scripts/public-traffic-rollup-from-nginx.py --granularity day --start-date "$backfill_start" --end-date "$backfill_end" >/tmp/public-traffic-rollup-backfill.log 2>/tmp/public-traffic-rollup-backfill.err; then ok "Public traffic historical day rollups backfilled from ${backfill_start} to ${backfill_end}" else warn "Public traffic historical backfill failed; check /tmp/public-traffic-rollup-backfill.err" cat /tmp/public-traffic-rollup-backfill.err >&2 || true fi fi else warn "Public traffic statistics rollup timers were not installed" fi if [ -n "$LEGACY_IMAGE" ]; then docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true fi PROD_SCRIPT )" export_script="$(printf '%s' "$PROD_PAYLOAD" | base64 -w 0)" ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" \ "TARGET='$TARGET' CONTAINER_NAME='$CONTAINER_NAME' PORT='$PORT' REGISTRY_COMMIT='$REGISTRY_COMMIT' COMMIT_SHA='$COMMIT_SHA' ENV_FILE_PATH='/mnt/PoolOne/apps/youyidian-3d/$ENV_FILE' PUBLIC_APP_URL='$PUBLIC_APP_URL' CORS_ALLOWED_ORIGINS='$CORS_ALLOWED_ORIGINS' DINGTALK_CALLBACK_ORIGIN='$DINGTALK_CALLBACK_ORIGIN' DINGTALK_INTERNAL_RETURN_ORIGINS='$DINGTALK_INTERNAL_RETURN_ORIGINS' S3_PUBLIC_DOMAIN='$S3_PUBLIC_DOMAIN' DOWNLOAD_TOKEN_SECRET='$DOWNLOAD_TOKEN_SECRET' PROXY_RECONCILE_SCRIPT_B64='$PROXY_RECONCILE_SCRIPT_B64' NETWORK_NAME='youyidian-3d_app-network' UPLOADS_MOUNT='/mnt/PoolOne/apps/youyidian-3d/uploads' ROLLBACK_ON_FAILURE='$ROLLBACK_ON_FAILURE' bash -lc \"printf '%s' '$export_script' | base64 -d | bash\"" ok "Production deployment command completed" if [ "$TARGET" = "production" ]; then log "Checking public FRP endpoints after deployment" curl -fsS --max-time 12 "$PUBLIC_APP_URL/api/health" >/tmp/git-deploy-public-health.json ok "Public application endpoint is healthy" if curl -fsS --max-time 12 "https://s3.jiansun.vip/minio/health/live" >/tmp/git-deploy-public-s3-health.txt; then ok "Public S3 endpoint is healthy" else warn "Public S3 endpoint is unhealthy; application deployment succeeded, check the storage tunnel separately" fi fi else warn "Skipping production deployment by request" fi start_remote_step "Cleanup" if [ "$KEEP_OLD_IMAGES" = "1" ]; then warn "Skipping cleanup by request" else docker image prune -f >/dev/null 2>&1 || true if [ "$AGGRESSIVE_CLEANUP" = "1" ]; then docker builder prune -af --filter until=168h >/dev/null 2>&1 || true ok "Build server cleanup completed, including old build cache" else ok "Build server image cleanup completed; build cache preserved" fi ssh -o StrictHostKeyChecking=no "$PROD_SERVER_USER@$PROD_SERVER_IP" \ "docker image prune -af --filter until=6h >/dev/null 2>&1 || true" ok "Production server cleanup completed" fi start_remote_step "Deployment Summary" ok "Target: $TARGET" ok "Commit: $COMMIT_SHA" ok "Image: $REGISTRY_COMMIT" ok "Container: $CONTAINER_NAME" '@ $remoteScript = $remoteTemplate foreach ($key in $tokens.Keys) { $remoteScript = $remoteScript.Replace($key, $tokens[$key]) } Write-Step "Git Incremental Deploy" Write-Info "Target: $Target" Write-Info "Repository: $RepoUrl" Write-Info "Branch: $Branch" if ($Commit) { Write-Info "Commit override: $Commit" } if ($DryRun) { Write-Warning-Custom 'DryRun mode: no code, image, or production container will be changed.' } $exitCode = Invoke-BuildServerScript -ScriptText $remoteScript if ($exitCode -ne 0) { Write-Error-Custom "Git incremental deployment failed with exit code $exitCode" exit $exitCode } Write-Success 'Git incremental deployment finished.'