param( [string]$RepoRoot = $(Split-Path -Parent $PSScriptRoot), [string]$ProjectSubdir = "furniture-3d-system", [string]$Ref = "origin/main", [string]$RemoteBase = "/opt/furniture-3d-system-prod", [string]$RemoteRepoDir = "", [string]$RemoteGitUrl = "", [string]$ServerHost = "ltai5tdb2ogyj59fquyedh.yydd.ai", [int]$SshPort = 60022, [string]$ServerUser = "root", [string]$ServerPassword = "", [string]$HostKeyFingerprint = "SHA256:JPHcomdjuUZOTM7sNdBYg0/xRkQSXzbXe7FEfNtIFAU", [int]$AppPort = 3001, [string]$Pm2Name = "furniture-3d-system-prod", [int]$KeepReleases = 5, [switch]$ForceArchiveFallback, [switch]$KeepArtifacts ) $ErrorActionPreference = "Stop" $createScript = Join-Path $RepoRoot "scripts\create-yydd-prod-deploy.ps1" $plinkPath = "C:\Program Files\PuTTY\plink.exe" $pscpPath = "C:\Program Files\PuTTY\pscp.exe" if (-not (Test-Path -LiteralPath $createScript)) { throw "Missing package script: $createScript" } if (-not (Test-Path -LiteralPath $plinkPath)) { throw "Missing plink.exe: $plinkPath" } if (-not (Test-Path -LiteralPath $pscpPath)) { throw "Missing pscp.exe: $pscpPath" } if ([string]::IsNullOrWhiteSpace($RemoteRepoDir)) { $RemoteRepoDir = ("{0}/repo" -f $RemoteBase) } 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 } function Invoke-Plink { param( [string]$Command ) $arguments = @( "-ssh" "-batch" "-hostkey" $HostKeyFingerprint "-P" [string]$SshPort ) if (-not [string]::IsNullOrWhiteSpace($ServerPassword)) { $arguments += @("-pw", $ServerPassword) } $arguments += @( ("{0}@{1}" -f $ServerUser, $ServerHost) $Command ) return Invoke-NativeCommand -FilePath $plinkPath -Arguments $arguments -WorkingDirectory $RepoRoot } function Copy-WithPscp { param( [string]$LocalPath, [string]$RemotePath ) if (-not (Test-Path -LiteralPath $LocalPath)) { throw "Upload source does not exist: $LocalPath" } $arguments = @( "-batch" "-hostkey" $HostKeyFingerprint "-P" [string]$SshPort ) if (-not [string]::IsNullOrWhiteSpace($ServerPassword)) { $arguments += @("-pw", $ServerPassword) } $arguments += @( $LocalPath ("{0}@{1}:{2}" -f $ServerUser, $ServerHost, $RemotePath) ) return Invoke-NativeCommand -FilePath $pscpPath -Arguments $arguments -WorkingDirectory $RepoRoot } function New-RemoteGitDeployScript { param( [string]$OutputRoot, [string]$RemoteBase, [string]$RemoteRepoDir, [string]$ProjectSubdir, [string]$ReleaseId, [string]$Commit, [string]$SourceRef, [string]$RemoteGitUrl, [int]$AppPort, [string]$Pm2Name, [int]$KeepReleases ) if (-not (Test-Path -LiteralPath $OutputRoot)) { New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null } $scriptName = "deploy-furniture-prod-$ReleaseId-remote.sh" $scriptPath = Join-Path $OutputRoot $scriptName $shellScript = @' #!/usr/bin/env bash set -euo pipefail BASE="__REMOTE_BASE__" REPO_DIR="__REMOTE_REPO_DIR__" RELEASES="$BASE/releases" SHARED="$BASE/shared" CURRENT="$BASE/current" LEGACY_APP="$BASE/__PROJECT_SUBDIR__" PROJECT_SUBDIR="__PROJECT_SUBDIR__" RELEASE_ID="__RELEASE_ID__" APP_PORT="__APP_PORT__" PM2_NAME="__PM2_NAME__" KEEP_RELEASES="__KEEP_RELEASES__" COMMIT_SHA="__COMMIT_SHA__" SOURCE_REF="__SOURCE_REF__" REMOTE_GIT_URL="__REMOTE_GIT_URL__" 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 git -C "$REPO_DIR" remote add origin "$REMOTE_GIT_URL" } resolve_fetch_ref() { case "$SOURCE_REF" in origin/*) printf '%s\n' "${SOURCE_REF#origin/}" ;; refs/remotes/origin/*) printf '%s\n' "${SOURCE_REF#refs/remotes/origin/}" ;; refs/heads/*) printf '%s\n' "${SOURCE_REF#refs/heads/}" ;; *) printf '%s\n' "$SOURCE_REF" ;; esac } fetch_target_commit() { local remote_ref="$1" if [ -z "$remote_ref" ]; then echo "Missing remote fetch ref derived from SOURCE_REF=$SOURCE_REF" >&2 exit 1 fi git -C "$REPO_DIR" fetch --prune --depth 1 origin "$remote_ref" if ! git -C "$REPO_DIR" cat-file -e "$COMMIT_SHA^{commit}" 2>/dev/null; then git -C "$REPO_DIR" fetch --prune origin "$remote_ref" fi git -C "$REPO_DIR" cat-file -e "$COMMIT_SHA^{commit}" } ensure_shared_non_integrated_assets() { local app_root="$1" local shared_assets="$SHARED/non-integrated-test-assets" local release_assets="$app_root/public/non-integrated-test-assets" assert_path_in_base "$shared_assets" mkdir -p "$shared_assets" if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE/$PROJECT_SUBDIR/public/non-integrated-test-assets" ]; then cp -a "$PREVIOUS_RELEASE/$PROJECT_SUBDIR/public/non-integrated-test-assets/." "$shared_assets/" 2>/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 "$BASE" "$RELEASES" "$SHARED" copy_legacy_env_if_needed ensure_repo fetch_target_commit "$(resolve_fetch_ref)" 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" git -C "$REPO_DIR" archive --format=tar --prefix="$PROJECT_SUBDIR/" "$COMMIT_SHA:$PROJECT_SUBDIR" | tar -xf - -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 -- "$HEALTHCHECK_FILE" DEPLOY_SUCCEEDED=1 trap - EXIT echo "=== REMOTE GIT DEPLOY DONE ===" pm2 describe "$PM2_NAME" | sed -n '1,35p' '@ $shellScript = $shellScript.Replace("__REMOTE_BASE__", $RemoteBase) $shellScript = $shellScript.Replace("__REMOTE_REPO_DIR__", $RemoteRepoDir) $shellScript = $shellScript.Replace("__PROJECT_SUBDIR__", $ProjectSubdir) $shellScript = $shellScript.Replace("__RELEASE_ID__", $ReleaseId) $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__", $SourceRef) $shellScript = $shellScript.Replace("__REMOTE_GIT_URL__", $RemoteGitUrl) $shellScript = $shellScript -replace "`r`n", "`n" [System.IO.File]::WriteAllText($scriptPath, $shellScript, [System.Text.UTF8Encoding]::new($false)) return $scriptPath } function Invoke-ArchiveFallbackDeploy { param( [string]$DeployRef ) $package = & $createScript ` -RepoRoot $RepoRoot ` -Ref $DeployRef ` -RemoteBase $RemoteBase ` -ServerHost $ServerHost ` -AppPort $AppPort ` -Pm2Name $Pm2Name ` -KeepReleases $KeepReleases if (-not $package) { throw "Package script returned no result" } $archivePath = [string]$package.Archive $scriptPath = [string]$package.Script $remoteArchivePath = [string]$package.RemoteArchivePath $remoteScriptPath = [string]$package.RemoteScriptPath $remoteIncoming = ("{0}/incoming" -f $RemoteBase) $mkdirIncomingCommand = "mkdir -p '{0}'" -f $remoteIncoming $runDeployCommand = "chmod +x '{0}' && bash '{0}'" -f $remoteScriptPath try { Invoke-Plink $mkdirIncomingCommand | Out-Null Copy-WithPscp -LocalPath $archivePath -RemotePath $remoteArchivePath | Out-Null Copy-WithPscp -LocalPath $scriptPath -RemotePath $remoteScriptPath | Out-Null $deployOutput = Invoke-Plink $runDeployCommand } finally { if (-not $KeepArtifacts) { try { Invoke-Plink ("rm -f '{0}' '{1}'" -f $remoteArchivePath, $remoteScriptPath) | Out-Null } catch { Write-Warning ("Failed to clean remote temp files: {0}" -f $_.Exception.Message) } if (Test-Path -LiteralPath $archivePath) { Remove-Item -LiteralPath $archivePath -Force } if (Test-Path -LiteralPath $scriptPath) { Remove-Item -LiteralPath $scriptPath -Force } } } return [PSCustomObject]@{ Mode = "archive-fallback" Ref = [string]$package.Ref Commit = [string]$package.Commit RemoteBase = $RemoteBase RemoteArchivePath = $remoteArchivePath RemoteScriptPath = $remoteScriptPath ServerHost = $ServerHost SshPort = $SshPort Pm2Name = $Pm2Name Output = ($deployOutput -join [Environment]::NewLine) } } 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 "Unable to resolve deploy ref: $Ref" } if ([string]::IsNullOrWhiteSpace($RemoteGitUrl)) { $RemoteGitUrl = (Invoke-Git remote get-url origin | Select-Object -First 1).Trim() } if ([string]::IsNullOrWhiteSpace($RemoteGitUrl)) { throw "Unable to resolve origin remote URL" } $releaseId = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmss"), $commit.Substring(0, 7) $tempOutputRoot = Join-Path $env:TEMP "3danzhuang-deploy-artifacts\prod" $remoteScriptPath = ("{0}/incoming/deploy-furniture-prod-{1}-remote.sh" -f $RemoteBase, $releaseId) $runDeployCommand = "chmod +x '{0}' && bash '{0}'" -f $remoteScriptPath if (-not $ForceArchiveFallback) { try { Invoke-Plink ("git ls-remote '{0}' HEAD >/dev/null" -f $RemoteGitUrl) | Out-Null $localScriptPath = New-RemoteGitDeployScript ` -OutputRoot $tempOutputRoot ` -RemoteBase $RemoteBase ` -RemoteRepoDir $RemoteRepoDir ` -ProjectSubdir $ProjectSubdir ` -ReleaseId $releaseId ` -Commit $commit ` -SourceRef $Ref ` -RemoteGitUrl $RemoteGitUrl ` -AppPort $AppPort ` -Pm2Name $Pm2Name ` -KeepReleases $KeepReleases try { Invoke-Plink ("mkdir -p '{0}/incoming'" -f $RemoteBase) | Out-Null Copy-WithPscp -LocalPath $localScriptPath -RemotePath $remoteScriptPath | Out-Null $deployOutput = Invoke-Plink $runDeployCommand } finally { if (-not $KeepArtifacts) { try { Invoke-Plink ("rm -f '{0}'" -f $remoteScriptPath) | Out-Null } catch { Write-Warning ("Failed to clean remote deploy script: {0}" -f $_.Exception.Message) } if (Test-Path -LiteralPath $localScriptPath) { Remove-Item -LiteralPath $localScriptPath -Force } } } return [PSCustomObject]@{ Mode = "remote-git-incremental" Ref = $Ref Commit = $commit RemoteBase = $RemoteBase RemoteRepoDir = $RemoteRepoDir ServerHost = $ServerHost SshPort = $SshPort Pm2Name = $Pm2Name Output = ($deployOutput -join [Environment]::NewLine) } } catch { Write-Warning ("Server-side Git deploy failed, falling back to package deploy: {0}" -f $_.Exception.Message) } } Invoke-ArchiveFallbackDeploy -DeployRef $Ref