[CmdletBinding()] param( [string]$Server = '218.93.190.75', [string]$KeyPath, [Security.SecureString]$Password, [switch]$UpdatePassword, [string]$SshUser = 'root', [string]$RemotePath = '/opt/caiwuxitong', [string]$AppUrl, [string]$BuilderName = 'caiwu-builder', [string]$BuildkitImage = 'm.daocloud.io/docker.io/moby/buildkit:buildx-stable-1', [string]$DatabaseImage = 'mariadb:11.4', [string]$DatabaseMirrorImage = 'm.daocloud.io/docker.io/library/mariadb:11.4', [int]$KeepImages = 2, [ValidatePattern('^\d+(KB|MB|GB|TB)$')] [string]$BuildCacheMaxUsedSpace = '24GB', [ValidateRange(24, 8760)] [int]$BuildCacheMaxAgeHours = 720, [switch]$BootstrapServer, [switch]$ConfigureDingTalkCallback, [switch]$AllowNonMain, [switch]$AllowMigrations, [switch]$SkipRegctl ) $ErrorActionPreference = 'Stop' $scriptRoot = $PSScriptRoot $repoRoot = (Resolve-Path (Join-Path $scriptRoot '..')).Path . (Join-Path $scriptRoot 'deploy-auth.ps1') $authSession = $null $sshOptions = @() $tunnelSshOptions = @() $scpOptions = @() if ($IsMacOS) { $scpOptions += '-O' } $sshControlPath = if ($IsMacOS) { "/tmp/caiwu-ssh-$PID-$([Guid]::NewGuid().ToString('N')).sock" } else { $null } $sshTarget = "${SshUser}@${Server}" $imageName = 'caiwu_akaunting' $targetPlatform = 'linux/amd64' $tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("caiwu-deploy-" + [Guid]::NewGuid().ToString('N')) $localTar = Join-Path $tempRoot 'image.tar' $localArchive = Join-Path $tempRoot 'image.tar.gz' $remoteArchive = $null $bootstrapRemote = $null $sshTunnel = $null $buildkitConfig = Join-Path $repoRoot 'deploy/buildkitd.toml' $regctlVersion = 'v0.11.5' $regctlSha256 = '9b83975d464e4ee5a9f899a3ead26072ed67e9a8ed0f582897bfad8141b45938' function Invoke-Native { param( [Parameter(Mandatory = $true)] [scriptblock]$Command, [Parameter(Mandatory = $true)] [string]$FailureMessage ) & $Command if ($LASTEXITCODE -ne 0) { throw $FailureMessage } } function Invoke-NativeWithRetry { param( [Parameter(Mandatory = $true)] [scriptblock]$Command, [Parameter(Mandatory = $true)] [string]$FailureMessage, [int]$Attempts = 3, [int]$DelaySeconds = 3 ) for ($attempt = 1; $attempt -le $Attempts; $attempt++) { & $Command if ($LASTEXITCODE -eq 0) { return } if ($attempt -lt $Attempts) { Write-Warning "$FailureMessage 正在重试($attempt/$Attempts)。" Start-Sleep -Seconds $DelaySeconds } } throw $FailureMessage } function Get-PrivateEnvironmentValue { param([Parameter(Mandatory = $true)][string]$Name) $value = [Environment]::GetEnvironmentVariable($Name, 'Process') if (-not $value) { $value = [Environment]::GetEnvironmentVariable($Name, 'User') } if (-not $value) { $credentialFile = Join-Path $repoRoot 'runtime/qiniu-business-storage.env' if (Test-Path -LiteralPath $credentialFile) { foreach ($line in Get-Content -LiteralPath $credentialFile) { if ($line -match "^\s*$([Regex]::Escape($Name))\s*=\s*(.+?)\s*$") { $value = $Matches[1].Trim().Trim('"').Trim("'") break } } } } if ($value -match "[`r`n]") { throw "本机私有配置中的 $Name 格式无效。" } return $value } function Ensure-QiniuBusinessStorageRuntime { $remoteEnvironment = "$RemotePath/runtime/qiniu-storage.env" $remoteReadyCommand = @( "test -f '$remoteEnvironment'" "grep -q '^BUSINESS_STORAGE_DRIVER=qiniu$' '$remoteEnvironment'" "grep -q '^QINIU_ACCESS_KEY=.' '$remoteEnvironment'" "grep -q '^QINIU_SECRET_KEY=.' '$remoteEnvironment'" ) -join ' && ' & ssh @sshOptions $sshTarget $remoteReadyCommand $remoteReady = $LASTEXITCODE -eq 0 if ($remoteReady) { Write-Host '服务器私有业务文件存储配置已存在。' return } $accessKey = Get-PrivateEnvironmentValue -Name 'QINIU_ACCESS_KEY' $secretKey = Get-PrivateEnvironmentValue -Name 'QINIU_SECRET_KEY' if (-not $accessKey -or -not $secretKey) { throw '服务器尚未配置私有业务文件存储,且本机没有可用的七牛凭据。' } $environmentContent = @( 'BUSINESS_STORAGE_DRIVER=qiniu' "QINIU_ACCESS_KEY=$accessKey" "QINIU_SECRET_KEY=$secretKey" ) -join "`n" $pendingEnvironment = "$remoteEnvironment.pending" $environmentContent | & ssh ` @sshOptions ` $sshTarget ` "umask 077; cat > '$pendingEnvironment'; chmod 600 '$pendingEnvironment'; mv '$pendingEnvironment' '$remoteEnvironment'" if ($LASTEXITCODE -ne 0) { throw '写入服务器私有业务文件存储配置失败。' } Write-Host '服务器私有业务文件存储配置已安全写入。' } 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 } } } function Compress-Gzip { param( [Parameter(Mandatory = $true)] [string]$Source, [Parameter(Mandatory = $true)] [string]$Destination ) $inputStream = $null $outputStream = $null $gzipStream = $null try { $inputStream = [IO.File]::OpenRead($Source) $outputStream = [IO.File]::Create($Destination) $gzipStream = [IO.Compression.GZipStream]::new( $outputStream, [IO.Compression.CompressionLevel]::Fastest ) $inputStream.CopyTo($gzipStream) } finally { if ($gzipStream) { $gzipStream.Dispose() } if ($outputStream) { $outputStream.Dispose() } if ($inputStream) { $inputStream.Dispose() } } } function Get-RegctlPath { $toolBase = if ($IsWindows) { $env:LOCALAPPDATA } else { Join-Path $HOME '.cache' } $toolRoot = Join-Path $toolBase 'CaiWuXiTong/tools' $regctlAsset = 'regctl-windows-amd64.exe' $regctlExtension = '.exe' $expectedHash = $regctlSha256 if ($IsMacOS -and [Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [Runtime.InteropServices.Architecture]::Arm64) { $regctlAsset = 'regctl-darwin-arm64' $regctlExtension = '' $expectedHash = 'f4d536d64d0c3cc1db7400902175a1c314675991d22e87e15c319501a2676d3f' } elseif ($IsMacOS) { $regctlAsset = 'regctl-darwin-amd64' $regctlExtension = '' $expectedHash = 'c132fdddda68b9c7584ac19f3b40cd17f71916c2bca8182270ebe65b55198a12' } elseif ($IsLinux -and [Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [Runtime.InteropServices.Architecture]::Arm64) { $regctlAsset = 'regctl-linux-arm64' $regctlExtension = '' $expectedHash = 'c4cf231e74cda685f1599f3d866b02b03c572e54b79ec8b062f32070b0ba4587' } elseif ($IsLinux) { $regctlAsset = 'regctl-linux-amd64' $regctlExtension = '' } $regctlPath = Join-Path $toolRoot "regctl-$regctlVersion$regctlExtension" if (Test-Path -LiteralPath $regctlPath) { $actualHash = (Get-FileHash -LiteralPath $regctlPath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -eq $expectedHash) { if (-not $IsWindows) { chmod +x $regctlPath } return $regctlPath } Remove-Item -LiteralPath $regctlPath -Force } New-Item -ItemType Directory -Path $toolRoot -Force | Out-Null $downloadPath = "$regctlPath.download" try { Invoke-WebRequest ` -Uri "https://github.com/regclient/regclient/releases/download/$regctlVersion/$regctlAsset" ` -OutFile $downloadPath $actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -ne $expectedHash) { throw "regctl 校验失败:预期 $expectedHash,实际 $actualHash。" } Move-Item -LiteralPath $downloadPath -Destination $regctlPath -Force if (-not $IsWindows) { chmod +x $regctlPath } } finally { if (Test-Path -LiteralPath $downloadPath) { Remove-Item -LiteralPath $downloadPath -Force } } return $regctlPath } function Get-AvailableLocalPort { $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) try { $listener.Start() return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() } } function Start-RegistryTunnel { param([int]$LocalPort) $sshPath = (Get-Command ssh -ErrorAction Stop).Source $curlPath = (Get-Command $(if ($IsWindows) { 'curl.exe' } else { 'curl' }) -CommandType Application -ErrorAction Stop).Source $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $sshPath $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true foreach ($argument in ($tunnelSshOptions + @( '-N', '-o', 'ConnectTimeout=10', '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=20', '-L', "127.0.0.1:${LocalPort}:127.0.0.1:5000", $sshTarget ))) { $startInfo.ArgumentList.Add($argument) } $process = [Diagnostics.Process]::Start($startInfo) $attempt = 1 while ($attempt -le 20) { if ($process.HasExited) { throw 'SSH 增量传输隧道启动失败。' } & $curlPath ` --fail ` --silent ` --show-error ` --max-time 1 ` "http://127.0.0.1:$LocalPort/v2/" 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { return $process } Start-Sleep -Milliseconds 500 $attempt++ } $process.Kill() $process.Dispose() throw 'SSH 隧道已建立,但无法访问服务器本机 Registry。' } 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" $buildDate = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') if (-not $AppUrl) { $AppUrl = if ($Server -eq '218.93.190.75') { 'https://caiwu.jiansuntuzhi.cn:8442' } else { "https://${Server}" } } Invoke-Native { docker version | Out-Null } 'Docker Desktop 未启动或不可用。' $authSession = Start-CaiwuDeploymentAuth -Server $Server -SshUser $SshUser -KeyPath $KeyPath -Password $Password -UpdatePassword:$UpdatePassword $sshOptions = $authSession.Options $tunnelSshOptions = @($sshOptions) if ($sshControlPath) { $sshOptions += @('-o', 'ControlMaster=auto', '-o', 'ControlPersist=600', '-o', "ControlPath=$sshControlPath") } Invoke-NativeWithRetry { ssh @sshOptions $sshTarget true } '服务器登录失败或已取消,已停止部署。' -Attempts 3 if ($authSession.Directory) { $env:CAIWU_DEPLOY_PASSWORD_UPDATE = $null } $remoteArchive = "$RemotePath/image-$imageTag.tar.gz" # deploy-auth.ps1 is dot-sourced and can clear the automatic script path # variable on PowerShell Core. Restore it before invoking the build helper. $PSScriptRoot = $scriptRoot $pwshPath = (Get-Process -Id $PID).Path if ([string]::IsNullOrWhiteSpace($pwshPath)) { throw '无法定位当前 PowerShell 可执行文件。' } & $pwshPath -NoProfile -File (Join-Path $scriptRoot 'build-frontend.ps1') docker buildx inspect $BuilderName 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { Invoke-Native { docker buildx create ` --name $BuilderName ` --driver docker-container ` --driver-opt "image=$BuildkitImage" ` --buildkitd-config $buildkitConfig ` --use | Out-Null } '创建项目专属 BuildKit 构建器失败。' } Invoke-Native { docker buildx inspect $BuilderName --bootstrap | Out-Null } '启动 BuildKit 构建器失败。' Invoke-Native { docker buildx build ` --builder $BuilderName ` --platform $targetPlatform ` --load ` --tag $fullImage ` --build-context 'frontend=.caiwu-build/public' ` --build-arg "VCS_REF=$deploySha" ` --build-arg "BUILD_DATE=$buildDate" ` --build-arg 'APP_VERSION=3.1.15' ` --label 'com.caiwu.project=CaiWuXiTong' ` --file deploy/Dockerfile ` . } '本机镜像构建失败。服务器未发生任何变更。' # 在无网络、无生产配置的临时容器中引导 Laravel,拦截开发包缓存混入镜像。 Invoke-Native { docker run --rm --network none --user www-data --entrypoint php $fullImage artisan --version } '发布镜像无法引导 Laravel。服务器未发生任何变更。' # Compare the image's source and compiled assets with this workstation build. # This also catches a Docker COPY or stale build-context overwrite before upload. foreach ($asset in @( @{ Local = 'resources/assets/js/components/business/AccountBills.vue'; Image = 'resources/assets/js/components/business/AccountBills.vue' }, @{ Local = '.caiwu-build/public/js/business/pages.min.js'; Image = 'public/js/business/pages.min.js' }, @{ Local = '.caiwu-build/public/mix-manifest.json'; Image = 'public/mix-manifest.json' } )) { $expectedHash = (Get-FileHash -LiteralPath (Join-Path $repoRoot $asset.Local) -Algorithm SHA256).Hash.ToLowerInvariant() $imageHash = @(& docker run --rm --network none --user www-data --entrypoint sha256sum $fullImage $asset.Image) if ($LASTEXITCODE -ne 0 -or ($imageHash -join ' ') -notmatch "^$expectedHash\s") { throw "发布镜像源码或前端资源不匹配:$($asset.Image)。服务器未发生任何变更。" } } $previousShaOutput = @(& ssh @sshOptions -o StrictHostKeyChecking=accept-new $sshTarget "cat '$RemotePath/.deployed-sha' 2>/dev/null || true") $previousSha = ($previousShaOutput -join "`n").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 并核对全部待执行迁移,按 AGENTS.md 完成备份后使用 -AllowMigrations。" } if ($previousSha -and $previousShaExists) { $migrationChanges = git diff --diff-filter=ACMRTUXB --name-only "$previousSha..$deploySha" -- ` database/migrations ` ':(glob)modules/*/Database/Migrations/*.php' if ($migrationChanges -and -not $AllowMigrations) { throw "检测到数据库迁移:$($migrationChanges -join ', ')。由 Codex 核对全部待执行迁移;本任务镜像迁移完成备份后可直接添加 -AllowMigrations,其他迁移按 AGENTS.md 执行。" } } New-Item -ItemType Directory -Path $tempRoot | Out-Null if ($BootstrapServer) { $bootstrapRemote = "/tmp/caiwu-bootstrap-$imageTag.sh" Invoke-Native { scp @scpOptions @sshOptions deploy/bootstrap-server.sh "${sshTarget}:$bootstrapRemote" } '上传服务器初始化脚本失败。' Invoke-Native { ssh @sshOptions $sshTarget "chmod 700 '$bootstrapRemote' && '$bootstrapRemote' '$AppUrl' '$RemotePath' && rm -f '$bootstrapRemote'" } '服务器初始化失败。' } Invoke-Native { ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'" } '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。' Ensure-QiniuBusinessStorageRuntime $migrationFlag = if ($AllowMigrations) { '1' } else { '0' } Invoke-Native { scp @scpOptions @sshOptions ` compose.jiangling.yml ` deploy/activate-release.sh ` deploy/activate-incremental-release.sh ` deploy/config-db-init.sh ` deploy/update-runtime-url.sh ` deploy/configure-dingtalk-callback.sh ` deploy/host-nginx-8442-dingtalk.conf ` deploy/setup-incremental-registry.sh ` deploy/registry-config.yml ` "${sshTarget}:$RemotePath/" } '上传运行配置失败。' Invoke-Native { ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/update-runtime-url.sh' && '$RemotePath/update-runtime-url.sh' '$AppUrl' '$RemotePath'" } '更新服务器访问地址失败。' if ($BootstrapServer) { docker image inspect $DatabaseImage 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { docker image inspect $DatabaseMirrorImage 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { Invoke-Native { docker pull $DatabaseMirrorImage } '下载数据库运行镜像失败。' } Invoke-Native { docker tag $DatabaseMirrorImage $DatabaseImage } '标记数据库运行镜像失败。' } Invoke-Native { docker save --output $localTar $fullImage $DatabaseImage } '导出应用与数据库运行镜像失败。' Compress-Gzip -Source $localTar -Destination $localArchive Remove-Item -LiteralPath $localTar -Force $archiveSizeMB = [math]::Round((Get-Item -LiteralPath $localArchive).Length / 1MB, 1) Write-Host "首次初始化全量包:$archiveSizeMB MiB" Invoke-Native { scp @scpOptions @sshOptions $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。' Invoke-Native { ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'" } '服务器激活、健康检查或增量仓库初始化失败。' } else { Invoke-NativeWithRetry { ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')" } '服务器增量镜像仓库初始化失败。' # docker save 导出未压缩层,每次导入重新压缩会改变摘要,导致 Registry 无法复用。 # 直接导出 BuildKit 原生 OCI 层,让未变化层的压缩摘要跨版本保持稳定。 # 注意:360/金山等安全软件会拦截未签名的 regctl(启动即“拒绝访问”), # 这类开发机可以用 -SkipRegctl,或者交给下面的自动降级改走 docker 直传。 Invoke-Native { docker buildx build ` --builder $BuilderName ` --platform $targetPlatform ` --output "type=oci,dest=$localTar" ` --tag $fullImage ` --build-context 'frontend=.caiwu-build/public' ` --build-arg "VCS_REF=$deploySha" ` --build-arg "BUILD_DATE=$buildDate" ` --build-arg 'APP_VERSION=3.1.15' ` --label 'com.caiwu.project=CaiWuXiTong' ` --file deploy/Dockerfile ` . } '导出稳定分层的 OCI 应用镜像失败。' $serverRegistryImage = "127.0.0.1:5000/caiwuxitong/app:$imageTag" $beforeBytesOutput = @(& ssh @sshOptions $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1") $beforeBytes = [long](($beforeBytesOutput -join '').Trim()) $uploadTimer = [Diagnostics.Stopwatch]::StartNew() $uploadSucceeded = $false if (-not $SkipRegctl) { try { $regctlPath = Get-RegctlPath $localRegistryPort = Get-AvailableLocalPort $sshTunnel = Start-RegistryTunnel -LocalPort $localRegistryPort $registryImage = "127.0.0.1:${localRegistryPort}/caiwuxitong/app:$imageTag" for ($uploadAttempt = 1; $uploadAttempt -le 2; $uploadAttempt++) { if ($uploadAttempt -gt 1) { if ($sshTunnel) { if (-not $sshTunnel.HasExited) { $sshTunnel.Kill() $sshTunnel.WaitForExit() } $sshTunnel.Dispose() } $localRegistryPort = Get-AvailableLocalPort $sshTunnel = Start-RegistryTunnel -LocalPort $localRegistryPort $registryImage = "127.0.0.1:${localRegistryPort}/caiwuxitong/app:$imageTag" } if ($sshTunnel.HasExited) { $uploadExitCode = 255 } else { & $regctlPath ` --host "reg=127.0.0.1:${localRegistryPort},tls=disabled" ` image import $registryImage $localTar $uploadExitCode = $LASTEXITCODE } if ($uploadExitCode -eq 0) { $uploadSucceeded = $true break } if ($uploadAttempt -eq 1) { Write-Warning '增量镜像隧道在上传期间中断,正在重新建立隧道并重试一次。' } } } catch { Write-Warning "regctl 无法运行(本机安全软件可能拦截了未签名程序):$($_.Exception.Message)" } } else { Write-Host '已指定 -SkipRegctl:跳过 regctl,直接用 docker 直传镜像。' } if (-not $uploadSucceeded) { if (-not $SkipRegctl) { Write-Warning '改用 docker 直传方式上传镜像(与 regctl 增量导入等效,只是改为传输完整镜像包)。' } if ($sshTunnel) { if (-not $sshTunnel.HasExited) { $sshTunnel.Kill() $sshTunnel.WaitForExit() } $sshTunnel.Dispose() $sshTunnel = $null } if (Test-Path -LiteralPath $localTar) { Remove-Item -LiteralPath $localTar -Force } Invoke-Native { docker save --output $localTar $fullImage } '导出应用镜像失败。' Compress-Gzip -Source $localTar -Destination $localArchive $archiveSizeMB = [math]::Round((Get-Item -LiteralPath $localArchive).Length / 1MB, 1) Write-Host "docker 直传镜像包:$archiveSizeMB MiB" Invoke-Native { scp @scpOptions @sshOptions $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。' Invoke-Native { ssh @sshOptions $sshTarget "gunzip -cf '$remoteArchive' | docker load && docker tag '$fullImage' '$serverRegistryImage' && docker push '$serverRegistryImage'" } '服务器加载镜像或推入本机 Registry 失败。' $uploadSucceeded = $true } if (-not $uploadSucceeded) { throw '增量镜像层上传失败。' } $uploadTimer.Stop() $afterBytesOutput = @(& ssh @sshOptions $sshTarget "du -sb /var/lib/docker-registry 2>/dev/null | cut -f1") $afterBytes = [long](($afterBytesOutput -join '').Trim()) $incrementalMiB = [math]::Round([math]::Max(0, $afterBytes - $beforeBytes) / 1MB, 2) Write-Host "增量新增镜像层:$incrementalMiB MiB;传输耗时:$([math]::Round($uploadTimer.Elapsed.TotalSeconds, 1)) 秒" Invoke-Native { ssh @sshOptions $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'" } '服务器增量激活或健康检查失败。' } $expectedPageBundleHash = (Get-FileHash -LiteralPath (Join-Path $repoRoot '.caiwu-build/public/js/business/pages.min.js') -Algorithm SHA256).Hash.ToLowerInvariant() $deployedPageBundleHashOutput = @(& ssh @sshOptions $sshTarget "docker exec caiwu_app sha256sum /var/www/html/public/js/business/pages.min.js | cut -d ' ' -f1") if ($LASTEXITCODE -ne 0 -or ($deployedPageBundleHashOutput -join '').Trim().ToLowerInvariant() -ne $expectedPageBundleHash) { throw '生产容器中的业务页签脚本与本次构建不一致。' } if ($ConfigureDingTalkCallback) { Invoke-Native { ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/configure-dingtalk-callback.sh' && '$RemotePath/configure-dingtalk-callback.sh' '$Server' '$RemotePath'" } '钉钉 HTTP 回调 Nginx 配置失败。' } Remove-OldProjectImages -CurrentImage $fullImage Write-Host "发布完成:$fullImage -> $AppUrl" } finally { try { Pop-Location if (Test-Path -LiteralPath $tempRoot) { Remove-Item -LiteralPath $tempRoot -Recurse -Force } if ($sshTunnel) { if (-not $sshTunnel.HasExited) { $sshTunnel.Kill() } $sshTunnel.Dispose() } if ($remoteArchive) { ssh @sshOptions -o ConnectTimeout=5 $sshTarget "rm -f '$remoteArchive'" 2>$null | Out-Null } if ($bootstrapRemote) { ssh @sshOptions -o ConnectTimeout=5 $sshTarget "rm -f '$bootstrapRemote'" 2>$null | Out-Null } if ($sshControlPath) { ssh @sshOptions -O exit $sshTarget 2>$null | Out-Null } # Keep the compiled PHP base and Composer vendor layers between releases. # The production build cache is normally about 20 GB; pruning it to 3 GB made # every deployment rebuild PHP extensions and download all dependencies again. $pruneHelp = docker buildx prune --help 2>$null | Out-String $buildCacheMaxAge = "${BuildCacheMaxAgeHours}h" docker buildx prune --builder $BuilderName --filter "until=$buildCacheMaxAge" --force 2>$null | Out-Null if ($pruneHelp -match '--max-used-space') { docker buildx prune --builder $BuilderName --max-used-space $BuildCacheMaxUsedSpace --force 2>$null | Out-Null } elseif ($pruneHelp -match '--keep-storage') { docker buildx prune --builder $BuilderName --keep-storage $BuildCacheMaxUsedSpace --force 2>$null | Out-Null } docker system df } finally { Stop-CaiwuDeploymentAuth $authSession } }