param( [string]$RepoRoot = $(Split-Path -Parent $PSScriptRoot), [string]$ProjectSubdir = "furniture-3d-system", [string]$OutputRoot = "", [string]$Ref = "origin/main", [string]$RemoteBase = "/opt/furniture-3d-system-prod", [string]$ServerHost = "ltai5tdb2ogyj59fquyedh.yydd.ai", [int]$AppPort = 3001, [string]$Pm2Name = "furniture-3d-system-prod", [int]$KeepReleases = 5 ) $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($OutputRoot)) { $OutputRoot = Join-Path $env:TEMP '3danzhuang-deploy-artifacts\prod' } if (-not (Test-Path -LiteralPath $RepoRoot)) { throw "仓库目录不存在: $RepoRoot" } if (-not (Test-Path -LiteralPath $OutputRoot)) { New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null } $projectRoot = Join-Path $RepoRoot $ProjectSubdir if (-not (Test-Path -LiteralPath $projectRoot)) { throw "项目目录不存在: $projectRoot" } function Invoke-NativeCommand { param( [string]$FilePath, [string[]]$Arguments, [string]$WorkingDirectory = $RepoRoot ) $previousErrorActionPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' Push-Location -LiteralPath $WorkingDirectory try { $output = & $FilePath @Arguments 2>&1 $exitCode = $LASTEXITCODE } finally { Pop-Location } $lines = @( $output | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { [string]$_ } } | Where-Object { $_.Length -gt 0 } ) if ($exitCode -ne 0) { throw (($lines -join [Environment]::NewLine).Trim()) } return $lines } finally { $ErrorActionPreference = $previousErrorActionPreference } } function Invoke-Git { param( [Parameter(ValueFromRemainingArguments = $true)] [string[]]$Arguments ) return Invoke-NativeCommand -FilePath 'git' -Arguments (@('-C', $RepoRoot) + $Arguments) -WorkingDirectory $RepoRoot } if ($Ref -match '^(origin/|refs/remotes/origin/)') { Invoke-Git fetch origin | Out-Null } $commit = (Invoke-Git rev-parse $Ref | Select-Object -First 1).Trim() if ([string]::IsNullOrWhiteSpace($commit)) { throw "无法解析部署引用: $Ref" } $shortCommit = $commit.Substring(0, 7) $releaseId = "{0}-{1}" -f (Get-Date -Format 'yyyyMMdd-HHmmss'), $shortCommit $archiveName = "deploy-furniture-prod-$releaseId.tar.gz" $scriptName = "deploy-furniture-prod-$releaseId.sh" $archivePath = Join-Path $OutputRoot $archiveName $scriptPath = Join-Path $OutputRoot $scriptName $remoteIncoming = "$RemoteBase/incoming" $remoteArchivePath = "$remoteIncoming/$archiveName" $remoteScriptPath = "$remoteIncoming/$scriptName" if (Test-Path -LiteralPath $archivePath) { Remove-Item -LiteralPath $archivePath -Force } if (Test-Path -LiteralPath $scriptPath) { Remove-Item -LiteralPath $scriptPath -Force } Invoke-Git archive --format=tar.gz "--output=$archivePath" "--prefix=$ProjectSubdir/" "$commit`:$ProjectSubdir" | Out-Null $shellScript = @' #!/usr/bin/env bash set -euo pipefail BASE="__REMOTE_BASE__" INCOMING="$BASE/incoming" RELEASES="$BASE/releases" SHARED="$BASE/shared" CURRENT="$BASE/current" LEGACY_APP="$BASE/__PROJECT_SUBDIR__" RELEASE_ID="__RELEASE_ID__" ARCHIVE="$INCOMING/__ARCHIVE_NAME__" APP_PORT="__APP_PORT__" PM2_NAME="__PM2_NAME__" KEEP_RELEASES="__KEEP_RELEASES__" COMMIT_SHA="__COMMIT_SHA__" SOURCE_REF="__SOURCE_REF__" assert_path_in_base() { local target="$1" case "$target" in "$BASE"/*) ;; *) echo "Refuse to touch path outside deployment base: $target" >&2 exit 1 ;; esac } copy_legacy_env_if_needed() { if [ -f "$SHARED/.env" ]; then return 0 fi mkdir -p "$SHARED" if [ -f "$BASE/.env" ]; then cp "$BASE/.env" "$SHARED/.env" return 0 fi if [ -f "$LEGACY_APP/.env" ]; then cp "$LEGACY_APP/.env" "$SHARED/.env" return 0 fi echo "Missing shared env file. Expected one of:" >&2 echo " $SHARED/.env" >&2 echo " $BASE/.env" >&2 echo " $LEGACY_APP/.env" >&2 exit 1 } start_pm2_release() { local release_root="$1" local app_root="$release_root/__PROJECT_SUBDIR__" local start_command start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec ./node_modules/.bin/next start -H 0.0.0.0 -p $APP_PORT" pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command" pm2 save >/dev/null } cleanup_old_releases() { if [ "$KEEP_RELEASES" -le 0 ]; then return 0 fi mapfile -t release_dirs < <(find "$RELEASES" -mindepth 1 -maxdepth 1 -type d | sort) local total="${#release_dirs[@]}" if [ "$total" -le "$KEEP_RELEASES" ]; then return 0 fi local current_real="" current_real="$(readlink -f "$CURRENT" 2>/dev/null || true)" local remove_count=$((total - KEEP_RELEASES)) for ((i=0; i/dev/null || true fi if [ -d "$LEGACY_APP/public/non-integrated-test-assets" ] && [ ! -L "$LEGACY_APP/public/non-integrated-test-assets" ]; then cp -a "$LEGACY_APP/public/non-integrated-test-assets/." "$shared_assets/" 2>/dev/null || true fi rm -rf -- "$release_assets" mkdir -p "$(dirname "$release_assets")" ln -sfn "$shared_assets" "$release_assets" } mkdir -p "$INCOMING" "$RELEASES" "$SHARED" copy_legacy_env_if_needed RELEASE_DIR="$RELEASES/$RELEASE_ID" APP_DIR="$RELEASE_DIR/__PROJECT_SUBDIR__" PREVIOUS_RELEASE="" PREVIOUS_RELEASE="$(readlink -f "$CURRENT" 2>/dev/null || true)" HEALTHCHECK_FILE="/tmp/furniture-3d-system-prod-healthcheck-$RELEASE_ID.txt" DEPLOY_SUCCEEDED=0 cleanup_failed_release() { local status="$1" rm -f -- "${HEALTHCHECK_FILE:-}" >/dev/null 2>&1 || true if [ "$DEPLOY_SUCCEEDED" -eq 1 ]; then exit "$status" fi if [ -n "${RELEASE_DIR:-}" ] && [ -d "$RELEASE_DIR" ]; then local current_real="" current_real="$(readlink -f "$CURRENT" 2>/dev/null || true)" if [ -n "$current_real" ] && [ "$current_real" = "$RELEASE_DIR" ]; then if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE" ]; then ln -sfn "$PREVIOUS_RELEASE" "$CURRENT" if ! start_pm2_release "$CURRENT"; then echo "Rollback start failed for previous release: $PREVIOUS_RELEASE" >&2 status=1 fi fi fi assert_path_in_base "$RELEASE_DIR" rm -rf -- "$RELEASE_DIR" fi exit "$status" } trap 'cleanup_failed_release "$?"' EXIT assert_path_in_base "$RELEASE_DIR" rm -rf -- "$RELEASE_DIR" mkdir -p "$RELEASE_DIR" tar -xzf "$ARCHIVE" -C "$RELEASE_DIR" if [ ! -d "$APP_DIR" ]; then echo "Archive extracted but app directory missing: $APP_DIR" >&2 exit 1 fi printf '%s\n' "$COMMIT_SHA" > "$RELEASE_DIR/REVISION" printf '%s\n' "$SOURCE_REF" > "$RELEASE_DIR/SOURCE_REF" ln -sfn "$SHARED/.env" "$APP_DIR/.env" cd "$APP_DIR" export PUPPETEER_SKIP_DOWNLOAD=true npm ci --no-audit --no-fund npx prisma generate npx prisma migrate deploy npm run build ensure_shared_non_integrated_assets "$APP_DIR" ln -sfn "$RELEASE_DIR" "$CURRENT" start_pm2_release "$CURRENT" sleep 5 if ! curl -fsS -I "http://127.0.0.1:$APP_PORT" >"$HEALTHCHECK_FILE"; then echo "New release health check failed. Rolling back..." >&2 if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE" ]; then ln -sfn "$PREVIOUS_RELEASE" "$CURRENT" start_pm2_release "$CURRENT" fi exit 1 fi sed -n '1,12p' "$HEALTHCHECK_FILE" cleanup_old_releases rm -f -- "$ARCHIVE" rm -f -- "$HEALTHCHECK_FILE" DEPLOY_SUCCEEDED=1 trap - EXIT echo "=== SAFE RELEASE DEPLOY DONE ===" pm2 describe "$PM2_NAME" | sed -n '1,35p' '@ $shellScript = $shellScript.Replace('__REMOTE_BASE__', $RemoteBase) $shellScript = $shellScript.Replace('__PROJECT_SUBDIR__', $ProjectSubdir) $shellScript = $shellScript.Replace('__RELEASE_ID__', $releaseId) $shellScript = $shellScript.Replace('__ARCHIVE_NAME__', $archiveName) $shellScript = $shellScript.Replace('__APP_PORT__', [string]$AppPort) $shellScript = $shellScript.Replace('__PM2_NAME__', $Pm2Name) $shellScript = $shellScript.Replace('__KEEP_RELEASES__', [string]$KeepReleases) $shellScript = $shellScript.Replace('__COMMIT_SHA__', $commit) $shellScript = $shellScript.Replace('__SOURCE_REF__', $Ref) $shellScript = $shellScript -replace "`r`n", "`n" [System.IO.File]::WriteAllText($scriptPath, $shellScript, [System.Text.UTF8Encoding]::new($false)) $archive = Get-Item -LiteralPath $archivePath [PSCustomObject]@{ Mode = 'safe-git-release' Ref = $Ref Commit = $commit Archive = $archive.FullName ArchiveMB = [math]::Round($archive.Length / 1MB, 2) Script = $scriptPath RemoteArchivePath = $remoteArchivePath RemoteScriptPath = $remoteScriptPath RemoteBase = $RemoteBase ServerHost = $ServerHost AppPort = $AppPort Pm2Name = $Pm2Name KeepReleases = $KeepReleases }