From 1e2bec0f6357a591fa2c380b9158421990f79a79 Mon Sep 17 00:00:00 2001 From: yuqei Date: Sat, 16 May 2026 11:24:37 +0800 Subject: [PATCH] feat: add production deployment rehearsal checklist --- ...助手-正式生产部署演练与上线检查清单-2026-05-16.md | 143 +++++++ project-docs/README.md | 1 + .../run-production-deployment-rehearsal.ps1 | 387 ++++++++++++++++++ 3 files changed, 531 insertions(+) create mode 100644 project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md create mode 100644 scripts/run-production-deployment-rehearsal.ps1 diff --git a/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md b/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md new file mode 100644 index 00000000..49d44b70 --- /dev/null +++ b/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md @@ -0,0 +1,143 @@ +# AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16 + +## 一句话结论 + +当前主线已经具备进入正式生产部署演练的条件。正式部署前不再只看“功能能不能跑”,而是要把代码基线、自动化门禁、PostgreSQL 真相源、MinIO 证据导入、生产健康检查、回滚能力统一收成一套可重复执行的流程。 + +## 演练目标 + +这套演练不直接替代真实部署动作,目标是回答四个问题: + +1. 当前 `main` 是否就是准备上线的代码。 +2. 已通过的自动化门禁是否有可追溯产物。 +3. 正式上线批准是否来自远程 PostgreSQL 真相源,而不是本地 CSV 兜底。 +4. 如果部署后发现问题,是否有健康检查、回归检查、备份和回滚路径。 + +## 当前生产目标 + +| 项目 | 地址或说明 | +|---|---| +| 服务器 | `192.168.3.90` | +| 前端 | `http://192.168.3.90:3003` | +| 后端 | `http://192.168.3.90:8001` | +| API | `http://192.168.3.90:8001/api/v1` | +| 健康检查 | `http://192.168.3.90:8001/health` | +| 就绪检查 | `http://192.168.3.90:8001/api/v1/system/readiness` | +| 远程项目目录 | `/opt/ai-contract-platform` | +| Compose 文件 | `docker-compose.server.yml` | +| 对象存储 | MinIO,`192.168.3.90:9000` | + +## 默认演练命令 + +默认演练只做无破坏性检查,不部署、不重启、不回滚: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\run-production-deployment-rehearsal.ps1 +``` + +默认演练会检查: + +| 检查项 | 通过含义 | +|---|---| +| gstack | 当前机器满足仓库约束 | +| Git 远端 | `origin` 指向正式 Git 服务 | +| Git 同步 | 本地 `main` 与 `origin/main` 一致 | +| 工作区 | 没有未提交改动 | +| 脚本库存 | 部署、回滚、健康、回归、备份、正式上线门禁脚本都存在 | +| 全量自动化产物 | 最近一次 `run until blocked` 已全阶段通过 | +| formal-launch DB 导入产物 | 最近一次 PostgreSQL + MinIO 导入通过 | +| formal-launch 批准产物 | 最近一次正式批准门禁来自 database truth source 且通过 | +| 部署包文档 | 生产部署包仍存在 | + +## 数据库真相源复跑命令 + +如果要重新生成“远程 PostgreSQL 真相源确认版”证据,先建立临时 SSH 隧道: + +```powershell +ssh -i $HOME\.ssh\water_audit_ed25519 -L 15432:172.18.0.3:5432 -L 19000:127.0.0.1:9000 root@192.168.3.90 -N +``` + +再运行正式批准门禁: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\run-production-deployment-rehearsal.ps1 ` + -RunFormalLaunchApprovalGate ` + -RequireDatabaseTruth ` + -FormalLaunchDatabaseUrl "postgresql+psycopg://postgres:postgres@127.0.0.1:15432/ai_contract_platform" +``` + +如果要重新导入 MinIO 证据,再单独执行: + +```powershell +$env:MINIO_ENDPOINT = "127.0.0.1:19000" +$env:MINIO_ACCESS_KEY = "minioadmin" +$env:MINIO_SECRET_KEY = "minioadmin" +$env:MINIO_SECURE = "false" + +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\import-formal-launch-governance-db.ps1 ` + -DatabaseUrl "postgresql+psycopg://postgres:postgres@127.0.0.1:15432/ai_contract_platform" ` + -BatchName "formal-launch-production-rehearsal" +``` + +说明:隧道只用于本机演练。演练结束后关闭 SSH 窗口即可。 + +## 生产发布门禁演练 + +如果要把远程生产健康、依赖治理、安全基线、生产回归也纳入同一轮演练,执行: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\run-production-deployment-rehearsal.ps1 ` + -RunProductionReleaseGate ` + -RequireCleanWorktree +``` + +如果本轮只想快速检查,不跑完整前端回归,可以加: + +```powershell +-SkipProductionRegression +``` + +如果要做更完整的上线前演练,可以加: + +```powershell +-IncludeBackupDrill +``` + +## 正式上线前检查清单 + +| 顺序 | 检查项 | 放行标准 | +|---:|---|---| +| 1 | `main` 与远端同步 | `git rev-list --left-right --count origin/main...main` 为 `0 0` | +| 2 | 工作区干净 | `git status --short` 无输出 | +| 3 | 全量自动化 | `run-until-blocked` 最近产物全阶段 `PASS` | +| 4 | Codex 无人审批 | Phase 5 默认包含 `codex-autonomous-formal-launch` 且通过 | +| 5 | PostgreSQL 真相源 | formal-launch approval `source_mode=database` | +| 6 | MinIO 证据导入 | formal-launch DB import `status=PASS` | +| 7 | 正式上线批准 | `blocked_required=0`、`warnings=0` | +| 8 | 生产健康 | 后端 health、readiness、前端访问通过 | +| 9 | 生产回归 | Phase 1/2/3 关键业务回归通过 | +| 10 | 回滚路径 | `rollback-frontend-docker.ps1` 可用,最近有回滚证据或演练计划 | + +## 当前已确认的证据 + +| 证据 | 路径 | +|---|---| +| 全量自动化通过 | `project-docs/automation-artifacts/run-until-blocked/2026-05-16T08-55-56/summary.md` | +| PostgreSQL + MinIO 导入通过 | `project-docs/qa-artifacts/formal-launch-governance-db-import/2026-05-16T10-10-51-993/formal-launch-governance-db-import-summary.md` | +| 数据库真相源正式批准通过 | `project-docs/qa-artifacts/formal-launch-approval-gate/2026-05-16T10-12-16-829/formal-launch-approval-summary.md` | + +## 放行判断 + +正式生产部署演练可以判定为 `PASS` 的条件: + +1. `run-production-deployment-rehearsal.ps1` 没有 required failure。 +2. PostgreSQL 真相源门禁为 `PASS`。 +3. MinIO 证据导入为 `PASS`。 +4. Git 本地与远端一致。 +5. 如本轮包含生产发布门禁,则生产发布门禁没有 required failure。 + +如果只是默认演练通过,但没有打开 `-RunProductionReleaseGate`,结论应写作“部署前证据链通过”,不能写作“已完成真实生产发布”。 + +## 下一步建议 + +下一步可以把这套演练脚本接入正式上线流程:每次部署前先跑默认演练,部署后再跑生产发布门禁。这样后续上线不再靠口头确认,而是靠同一条命令产出的证据目录来判断是否放行。 diff --git a/project-docs/README.md b/project-docs/README.md index c6748ccc..00e426b0 100644 --- a/project-docs/README.md +++ b/project-docs/README.md @@ -18,6 +18,7 @@ - `AI合同起草与审查助手-对外产品介绍文案-官网演示版-2026-05-11.md` - `AI合同起草与审查助手-真人参与最小化与必要人工环节说明-2026-05-14.md` - `AI合同起草与审查助手-真人参与流程-会议汇报版-2026-05-14.md` +- `AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md` ## 当前仍保留的专项资料 diff --git a/scripts/run-production-deployment-rehearsal.ps1 b/scripts/run-production-deployment-rehearsal.ps1 new file mode 100644 index 00000000..1f1f4550 --- /dev/null +++ b/scripts/run-production-deployment-rehearsal.ps1 @@ -0,0 +1,387 @@ +param( + [string]$ServerHost = "192.168.3.90", + [string]$BackendBaseUrl = "http://192.168.3.90:8001", + [string]$FrontendBaseUrl = "http://192.168.3.90:3003", + [string]$ApiUrl = "http://192.168.3.90:8001/api/v1", + [string]$HealthUrl = "http://192.168.3.90:8001/health", + [string]$ReadinessUrl = "http://192.168.3.90:8001/api/v1/system/readiness", + [string]$FormalLaunchDatabaseUrl = "", + [string]$ArtifactRoot = "", + [switch]$RunFormalLaunchApprovalGate, + [switch]$RequireDatabaseTruth, + [switch]$RunProductionReleaseGate, + [switch]$RequireCleanWorktree, + [switch]$IncludeBackupDrill, + [switch]$SkipBuild, + [switch]$SkipProductionRegression +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss-fff" +if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) { + $ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\production-deployment-rehearsal" +} +if (-not [System.IO.Path]::IsPathRooted($ArtifactRoot)) { + $ArtifactRoot = Join-Path $repoRoot $ArtifactRoot +} +$artifactDir = Join-Path $ArtifactRoot $timestamp +$summaryJson = Join-Path $artifactDir "production-deployment-rehearsal-summary.json" +$summaryMd = Join-Path $artifactDir "production-deployment-rehearsal-summary.md" +$gitStatusPath = Join-Path $artifactDir "git-status.txt" + +$initialGitStatus = git -C $repoRoot status --short + +New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null + +$checks = New-Object System.Collections.Generic.List[object] + +function Add-Check { + param( + [string]$Key, + [string]$Label, + [ValidateSet("pass", "fail", "warn", "skip")] + [string]$Status, + [string]$Detail, + [bool]$Required = $true, + [hashtable]$Meta = @{} + ) + + $checks.Add([ordered]@{ + key = $Key + label = $Label + status = $Status + required = $Required + detail = $Detail + meta = $Meta + }) | Out-Null +} + +function Save-Json { + param( + [string]$Path, + [object]$Value + ) + + $Value | ConvertTo-Json -Depth 12 | Set-Content -Encoding UTF8 -Path $Path +} + +function Get-LatestSummary { + param( + [string]$Root, + [string]$SummaryFile + ) + + if (-not (Test-Path $Root)) { + return $null + } + + $dirs = Get-ChildItem -LiteralPath $Root -Directory | Sort-Object Name -Descending + foreach ($dir in $dirs) { + $candidate = Join-Path $dir.FullName $SummaryFile + if (Test-Path $candidate) { + return [ordered]@{ + dir = $dir.FullName + path = $candidate + content = Get-Content -LiteralPath $candidate -Raw + } + } + } + + return $null +} + +function Invoke-ChildScript { + param( + [string]$Key, + [string]$Label, + [string]$ScriptName, + [string[]]$Arguments, + [bool]$Required = $true + ) + + $scriptPath = Join-Path $repoRoot "scripts\$ScriptName" + $logPath = Join-Path $artifactDir "$Key.log" + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scriptPath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } + + $outputText = ($output -join [Environment]::NewLine) + $outputText | Set-Content -Encoding UTF8 -Path $logPath + $artifactMatch = [regex]::Match($outputText, '([A-Z]:\\[^\r\n]+)$', [System.Text.RegularExpressions.RegexOptions]::Multiline) + $childArtifact = if ($artifactMatch.Success) { $artifactMatch.Groups[1].Value.Trim() } else { $null } + + if ($exitCode -eq 0 -and $outputText -match "_WARN") { + Add-Check -Key $Key -Label $Label -Status "warn" -Required $false -Detail "$Label completed with warnings" -Meta @{ + log = $logPath + artifact_dir = $childArtifact + } + } elseif ($exitCode -eq 0) { + Add-Check -Key $Key -Label $Label -Status "pass" -Required $Required -Detail "$Label passed" -Meta @{ + log = $logPath + artifact_dir = $childArtifact + } + } else { + Add-Check -Key $Key -Label $Label -Status "fail" -Required $Required -Detail "$Label failed" -Meta @{ + exit_code = $exitCode + log = $logPath + artifact_dir = $childArtifact + } + } +} + +if (-not (Test-Path "$HOME\.Codex\skills\gstack\bin")) { + Add-Check -Key "gstack" -Label "gstack preflight" -Status "fail" -Detail "gstack is missing" +} else { + Add-Check -Key "gstack" -Label "gstack preflight" -Status "pass" -Detail "GSTACK_OK" +} + +$branch = (git -C $repoRoot rev-parse --abbrev-ref HEAD).Trim() +$commit = (git -C $repoRoot rev-parse HEAD).Trim() +$remoteUrl = (git -C $repoRoot remote get-url origin).Trim() +$gitStatus = $initialGitStatus +($gitStatus -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $gitStatusPath + +if ($remoteUrl -match "192\.168\.3\.90") { + Add-Check -Key "origin_remote" -Label "origin remote" -Status "pass" -Detail "origin points to 192.168.3.90" -Meta @{ + remote = $remoteUrl + } +} else { + Add-Check -Key "origin_remote" -Label "origin remote" -Status "fail" -Detail "origin does not point to 192.168.3.90" -Meta @{ + remote = $remoteUrl + } +} + +$revCounts = (git -C $repoRoot rev-list --left-right --count origin/main...main).Trim() -split '\s+' +$behind = [int]$revCounts[0] +$ahead = [int]$revCounts[1] +if ($behind -eq 0 -and $ahead -eq 0) { + Add-Check -Key "git_sync" -Label "git sync" -Status "pass" -Detail "main is synchronized with origin/main" -Meta @{ + branch = $branch + commit = $commit + } +} else { + Add-Check -Key "git_sync" -Label "git sync" -Status "fail" -Detail "main is not synchronized with origin/main" -Meta @{ + behind = $behind + ahead = $ahead + branch = $branch + commit = $commit + } +} + +if ($gitStatus) { + if ($RequireCleanWorktree) { + Add-Check -Key "working_tree" -Label "working tree" -Status "fail" -Detail "working tree has uncommitted changes and -RequireCleanWorktree is set" -Meta @{ + artifact = $gitStatusPath + } + } else { + Add-Check -Key "working_tree" -Label "working tree" -Status "warn" -Required $false -Detail "working tree has uncommitted changes; rehearsal continues in advisory mode" -Meta @{ + artifact = $gitStatusPath + } + } +} else { + Add-Check -Key "working_tree" -Label "working tree" -Status "pass" -Detail "working tree is clean" -Meta @{ + artifact = $gitStatusPath + } +} + +$requiredScripts = @( + "run-ai-contract-full-automation-until-blocked.ps1", + "run-codex-autonomous-formal-launch.ps1", + "import-formal-launch-governance-db.ps1", + "run-formal-launch-approval-gate.ps1", + "run-production-release-gate.ps1", + "run-production-health-check.ps1", + "run-production-regression.ps1", + "run-backup-drill.ps1", + "deploy-frontend-docker.ps1", + "rollback-frontend-docker.ps1" +) +$missingScripts = @() +foreach ($scriptName in $requiredScripts) { + if (-not (Test-Path (Join-Path $repoRoot "scripts\$scriptName"))) { + $missingScripts += $scriptName + } +} +if ($missingScripts.Count -eq 0) { + Add-Check -Key "script_inventory" -Label "deployment script inventory" -Status "pass" -Detail "all required deployment and gate scripts are present" +} else { + Add-Check -Key "script_inventory" -Label "deployment script inventory" -Status "fail" -Detail "missing required scripts" -Meta @{ + missing = $missingScripts + } +} + +$runSummary = Get-LatestSummary -Root (Join-Path $repoRoot "project-docs\automation-artifacts\run-until-blocked") -SummaryFile "summary.md" +if ($null -eq $runSummary) { + Add-Check -Key "full_automation_artifact" -Label "full automation artifact" -Status "fail" -Detail "run-until-blocked summary not found" +} elseif ($runSummary.content -match '\|\s*all\s*\|\s*run-until-blocked-finished\s*\|\s*PASS\s*\|' -or $runSummary.content -match 'all configured phases passed') { + Add-Check -Key "full_automation_artifact" -Label "full automation artifact" -Status "pass" -Detail "latest full automation finished with all configured phases passed" -Meta @{ + artifact = $runSummary.path + } +} else { + Add-Check -Key "full_automation_artifact" -Label "full automation artifact" -Status "fail" -Detail "latest full automation summary is not a full PASS" -Meta @{ + artifact = $runSummary.path + } +} + +$dbImportSummary = Get-LatestSummary -Root (Join-Path $repoRoot "project-docs\qa-artifacts\formal-launch-governance-db-import") -SummaryFile "formal-launch-governance-db-import-summary.md" +if ($null -eq $dbImportSummary) { + Add-Check -Key "formal_launch_db_import" -Label "formal launch DB import artifact" -Status "fail" -Detail "formal launch DB import summary not found" +} elseif ($dbImportSummary.content -match '- status:\s*PASS' -and $dbImportSummary.content -match 'formal-launch-remote-truth') { + Add-Check -Key "formal_launch_db_import" -Label "formal launch DB import artifact" -Status "pass" -Detail "latest formal launch DB import passed" -Meta @{ + artifact = $dbImportSummary.path + } +} else { + Add-Check -Key "formal_launch_db_import" -Label "formal launch DB import artifact" -Status "fail" -Detail "latest formal launch DB import is not PASS" -Meta @{ + artifact = $dbImportSummary.path + } +} + +$approvalSummary = Get-LatestSummary -Root (Join-Path $repoRoot "project-docs\qa-artifacts\formal-launch-approval-gate") -SummaryFile "formal-launch-approval-summary.md" +if ($null -eq $approvalSummary) { + Add-Check -Key "formal_launch_approval" -Label "formal launch approval artifact" -Status "fail" -Detail "formal launch approval summary not found" +} elseif ($approvalSummary.content -match '- status:\s*PASS' -and $approvalSummary.content -match '- source_mode:\s*database' -and $approvalSummary.content -match '\|\s*database truth source\s*\|\s*True\s*\|\s*PASS\s*\|') { + Add-Check -Key "formal_launch_approval" -Label "formal launch approval artifact" -Status "pass" -Detail "latest formal launch approval gate passed with database truth source" -Meta @{ + artifact = $approvalSummary.path + } +} else { + Add-Check -Key "formal_launch_approval" -Label "formal launch approval artifact" -Status "fail" -Detail "latest formal launch approval gate is not database truth PASS" -Meta @{ + artifact = $approvalSummary.path + } +} + +$deploymentDoc = Join-Path $repoRoot "project-docs\phase10-delivery-package\deployment-package.md" +if (Test-Path $deploymentDoc) { + Add-Check -Key "deployment_package_doc" -Label "deployment package document" -Status "pass" -Detail "deployment package document exists" -Meta @{ + artifact = $deploymentDoc + } +} else { + Add-Check -Key "deployment_package_doc" -Label "deployment package document" -Status "fail" -Detail "deployment package document is missing" +} + +if ($RunFormalLaunchApprovalGate) { + $formalArgs = @("-SkipPreparationGate", "-ArtifactRoot", (Join-Path $artifactDir "formal-launch-approval-gate")) + if ($RequireDatabaseTruth) { + $formalArgs += "-RequireDatabaseTruth" + } + if (-not [string]::IsNullOrWhiteSpace($FormalLaunchDatabaseUrl)) { + $formalArgs += @("-DatabaseUrl", $FormalLaunchDatabaseUrl) + } + Invoke-ChildScript -Key "formal_launch_live_gate" -Label "formal launch live approval gate" -ScriptName "run-formal-launch-approval-gate.ps1" -Arguments $formalArgs +} else { + Add-Check -Key "formal_launch_live_gate" -Label "formal launch live approval gate" -Status "skip" -Required $false -Detail "skipped; pass -RunFormalLaunchApprovalGate to rerun" +} + +if ($RunProductionReleaseGate) { + $releaseArgs = @( + "-ServerHost", $ServerHost, + "-BackendBaseUrl", $BackendBaseUrl, + "-FrontendBaseUrl", $FrontendBaseUrl, + "-ApiUrl", $ApiUrl, + "-HealthUrl", $HealthUrl, + "-ReadinessUrl", $ReadinessUrl, + "-ArtifactRoot", (Join-Path $artifactDir "production-release-gate") + ) + if ($RequireCleanWorktree) { + $releaseArgs += "-RequireCleanWorktree" + } + if ($IncludeBackupDrill) { + $releaseArgs += "-IncludeBackupDrill" + } + if ($SkipBuild) { + $releaseArgs += "-SkipBuild" + } + if ($SkipProductionRegression) { + $releaseArgs += "-SkipProductionRegression" + } + Invoke-ChildScript -Key "production_release_gate" -Label "production release gate" -ScriptName "run-production-release-gate.ps1" -Arguments $releaseArgs +} else { + Add-Check -Key "production_release_gate" -Label "production release gate" -Status "skip" -Required $false -Detail "skipped; pass -RunProductionReleaseGate to rerun" +} + +$failedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }) +$warningsList = @($checks | Where-Object { $_.status -eq "warn" }) +$skippedList = @($checks | Where-Object { $_.status -eq "skip" }) +$overallStatus = if ($failedRequired.Count -gt 0) { "FAIL" } elseif ($warningsList.Count -gt 0) { "WARN" } else { "PASS" } + +$summary = [ordered]@{ + name = "production-deployment-rehearsal" + status = $overallStatus + generated_at = (Get-Date).ToUniversalTime().ToString("o") + artifact_dir = $artifactDir + branch = $branch + commit = $commit + remote = $remoteUrl + targets = [ordered]@{ + server_host = $ServerHost + frontend_base_url = $FrontendBaseUrl + backend_base_url = $BackendBaseUrl + api_url = $ApiUrl + health_url = $HealthUrl + readiness_url = $ReadinessUrl + } + options = [ordered]@{ + run_formal_launch_approval_gate = [bool]$RunFormalLaunchApprovalGate + require_database_truth = [bool]$RequireDatabaseTruth + run_production_release_gate = [bool]$RunProductionReleaseGate + require_clean_worktree = [bool]$RequireCleanWorktree + include_backup_drill = [bool]$IncludeBackupDrill + skip_build = [bool]$SkipBuild + skip_production_regression = [bool]$SkipProductionRegression + } + checks = $checks + totals = [ordered]@{ + total = $checks.Count + failed_required = $failedRequired.Count + warnings = $warningsList.Count + skipped = $skippedList.Count + } +} +Save-Json -Path $summaryJson -Value $summary + +$markdown = New-Object System.Collections.Generic.List[string] +$markdown.Add("# Production Deployment Rehearsal") | Out-Null +$markdown.Add("") | Out-Null +$markdown.Add("- Status: $overallStatus") | Out-Null +$markdown.Add("- Generated at: $($summary.generated_at)") | Out-Null +$markdown.Add("- Branch: $branch") | Out-Null +$markdown.Add("- Commit: $commit") | Out-Null +$markdown.Add("- Artifact dir: $artifactDir") | Out-Null +$markdown.Add("") | Out-Null +$markdown.Add("## Checks") | Out-Null +$markdown.Add("") | Out-Null +$markdown.Add("| Check | Required | Status | Detail |") | Out-Null +$markdown.Add("|---|---:|---|---|") | Out-Null +foreach ($check in $checks) { + $markdown.Add("| $($check.label) | $($check.required) | $($check.status.ToUpperInvariant()) | $($check.detail) |") | Out-Null +} +$markdown.Add("") | Out-Null +$markdown.Add("## Evidence") | Out-Null +$markdown.Add("") | Out-Null +foreach ($check in $checks) { + if ($check.meta.artifact) { + $markdown.Add(('- {0}: `{1}`' -f $check.label, $check.meta.artifact)) | Out-Null + } + if ($check.meta.artifact_dir) { + $markdown.Add(('- {0}: `{1}`' -f $check.label, $check.meta.artifact_dir)) | Out-Null + } +} +$markdown.Add("") | Out-Null +$markdown.Add("## Local Artifacts") | Out-Null +$markdown.Add("") | Out-Null +$markdown.Add('- `production-deployment-rehearsal-summary.json`') | Out-Null +$markdown.Add('- `production-deployment-rehearsal-summary.md`') | Out-Null +$markdown.Add('- `git-status.txt`') | Out-Null +$markdown.Add('- `*.log` when live gates are enabled') | Out-Null +$markdown | Set-Content -Encoding UTF8 -Path $summaryMd + +Write-Host "PRODUCTION_DEPLOYMENT_REHEARSAL_$overallStatus $artifactDir" +if ($overallStatus -eq "FAIL") { + throw "Production deployment rehearsal failed. See $summaryMd" +}