[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Server, [Parameter(Mandatory = $true)] [string]$KeyPath, [string]$SshUser = 'root', [string]$RemotePath = '/opt/caiwuxitong', [string]$AppUrl, [string]$BuilderName = 'caiwu-builder', [int]$KeepImages = 2, [switch]$BootstrapServer, [switch]$AllowNonMain, [switch]$AllowMigrations ) $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $keyFullPath = (Resolve-Path $KeyPath).Path $sshTarget = "${SshUser}@${Server}" $imageName = 'caiwu_akaunting' $tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("caiwu-deploy-" + [Guid]::NewGuid().ToString('N')) $localArchive = Join-Path $tempRoot 'image.tar' $remoteArchive = $null $bootstrapRemote = $null function Invoke-Native { param( [Parameter(Mandatory = $true)] [scriptblock]$Command, [Parameter(Mandatory = $true)] [string]$FailureMessage ) & $Command if ($LASTEXITCODE -ne 0) { throw $FailureMessage } } function Remove-OldProjectImages { param([string]$CurrentImage) $projectImages = @( docker image ls $imageName --format '{{.Repository}}:{{.Tag}}' | Where-Object { $_ -and $_ -ne ':' } | Select-Object -Unique ) $keep = @($CurrentImage) foreach ($candidate in $projectImages) { if ($keep.Count -ge $KeepImages) { break } if ($candidate -notin $keep) { $keep += $candidate } } foreach ($candidate in $projectImages) { if ($candidate -notin $keep) { docker image rm $candidate 2>$null | Out-Null } } } Push-Location $repoRoot try { $branch = (git branch --show-current).Trim() if (-not $AllowNonMain -and $branch -ne 'main') { throw "当前分支是 $branch。正式发布只允许 main;测试分支需显式添加 -AllowNonMain。" } $trackedChanges = git status --porcelain --untracked-files=no if ($trackedChanges) { throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。' } $deploySha = (git rev-parse HEAD).Trim() $imageTag = $deploySha.Substring(0, 12) $fullImage = "${imageName}:$imageTag" $remoteArchive = "$RemotePath/image-$imageTag.tar" $buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') if (-not $AppUrl) { $AppUrl = "http://$Server" } Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。' docker buildx inspect $BuilderName 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { Invoke-Native { docker buildx create --name $BuilderName --driver docker-container --use | Out-Null } '创建项目专属 BuildKit 构建器失败。' } Invoke-Native { docker buildx inspect $BuilderName --bootstrap | Out-Null } '启动 BuildKit 构建器失败。' Invoke-Native { docker buildx build ` --builder $BuilderName ` --load ` --tag $fullImage ` --build-arg "VCS_REF=$deploySha" ` --build-arg "BUILD_DATE=$buildDate" ` --label 'com.caiwu.project=CaiWuXiTong' ` --file deploy/Dockerfile ` . } '本机镜像构建失败。服务器未发生任何变更。' $previousSha = (& ssh -i $keyFullPath -o BatchMode=yes -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true").Trim() if ($LASTEXITCODE -ne 0) { throw '无法连接服务器。' } $previousShaExists = $false if ($previousSha) { git cat-file -e "${previousSha}^{commit}" 2>$null $previousShaExists = $LASTEXITCODE -eq 0 } if ($previousSha -and -not $previousShaExists -and -not $AllowMigrations) { throw "本地缺少服务器当前提交 $previousSha,无法安全比较数据库迁移。请先 git fetch,或在负责人授权后使用 -AllowMigrations。" } if ($previousSha -and $previousShaExists) { $migrationChanges = git diff --name-only "$previousSha..$deploySha" -- database/migrations if ($migrationChanges -and -not $AllowMigrations) { throw "检测到数据库迁移:$($migrationChanges -join ', ')。需负责人确认后添加 -AllowMigrations。" } } New-Item -ItemType Directory -Path $tempRoot | Out-Null if ($BootstrapServer) { $bootstrapRemote = "/tmp/caiwu-bootstrap-$imageTag.sh" Invoke-Native { scp -i $keyFullPath -o BatchMode=yes deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote" } '上传服务器初始化脚本失败。' Invoke-Native { ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'" } '服务器初始化失败。' } Invoke-Native { ssh -i $keyFullPath -o BatchMode=yes $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'" } '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。' Invoke-Native { docker save --output $localArchive $fullImage } '导出镜像失败。' Invoke-Native { scp -i $keyFullPath -o BatchMode=yes $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。' Invoke-Native { scp -i $keyFullPath -o BatchMode=yes compose.jiangling.yml deploy/activate-release.sh "${sshTarget}:$RemotePath/" } '上传运行配置失败。' $migrationFlag = if ($AllowMigrations) { '1' } else { '0' } Invoke-Native { ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/activate-release.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'" } '服务器激活或健康检查失败。' Remove-OldProjectImages -CurrentImage $fullImage Write-Host "发布完成:$fullImage -> $AppUrl" } finally { Pop-Location if (Test-Path -LiteralPath $tempRoot) { Remove-Item -LiteralPath $tempRoot -Recurse -Force } if ($remoteArchive) { ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null } if ($bootstrapRemote) { ssh -i $keyFullPath -o BatchMode=yes -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 2>$null | Out-Null } $pruneHelp = docker buildx prune --help 2>$null | Out-String docker buildx prune --builder $BuilderName --filter 'until=168h' --force 2>$null | Out-Null if ($pruneHelp -match '--max-used-space') { docker buildx prune --builder $BuilderName --max-used-space 8GB --force 2>$null | Out-Null } elseif ($pruneHelp -match '--keep-storage') { docker buildx prune --builder $BuilderName --keep-storage 8GB --force 2>$null | Out-Null } docker system df }