338 lines
14 KiB
PowerShell
338 lines
14 KiB
PowerShell
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]$LibraryRoot = "",
|
|
[string]$MaterialsRoot = "",
|
|
[string]$ArtifactRoot = "",
|
|
[int]$MinTemplates = 10,
|
|
[int]$MinMaterials = 7,
|
|
[switch]$FailOnBlocked
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
|
$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss-fff"
|
|
|
|
if ([string]::IsNullOrWhiteSpace($LibraryRoot)) {
|
|
$LibraryRoot = Join-Path $repoRoot "project-docs\phase13-legal-knowledge-template-library"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($MaterialsRoot)) {
|
|
$MaterialsRoot = Join-Path $repoRoot "project-docs\formal-launch-ops"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) {
|
|
$ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\formal-launch-preparation-gate"
|
|
}
|
|
foreach ($pathName in @("LibraryRoot", "MaterialsRoot", "ArtifactRoot")) {
|
|
$value = Get-Variable -Name $pathName -ValueOnly
|
|
if (-not [System.IO.Path]::IsPathRooted($value)) {
|
|
Set-Variable -Name $pathName -Value (Join-Path $repoRoot $value)
|
|
}
|
|
}
|
|
|
|
$artifactDir = Join-Path $ArtifactRoot $timestamp
|
|
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
|
|
|
|
$summaryJson = Join-Path $artifactDir "formal-launch-preparation-gate-summary.json"
|
|
$summaryMd = Join-Path $artifactDir "formal-launch-preparation-gate-summary.md"
|
|
$checksCsv = Join-Path $artifactDir "formal-launch-preparation-checks.csv"
|
|
$gitStatusPath = Join-Path $artifactDir "git-status.txt"
|
|
$pcGitStatusPath = Join-Path $artifactDir "pc-git-status.txt"
|
|
|
|
$checks = New-Object System.Collections.Generic.List[object]
|
|
|
|
function Add-Check {
|
|
param(
|
|
[string]$Key,
|
|
[string]$Label,
|
|
[ValidateSet("pass", "fail", "warn", "skip", "blocked")]
|
|
[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 50 | Set-Content -Encoding UTF8 -Path $Path
|
|
}
|
|
|
|
function Import-CsvRows {
|
|
param([string]$Path)
|
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
|
return @()
|
|
}
|
|
return @(Import-Csv -LiteralPath $Path)
|
|
}
|
|
|
|
function Read-JsonFile {
|
|
param([string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
|
return $null
|
|
}
|
|
return Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
}
|
|
|
|
function Invoke-ChildScript {
|
|
param(
|
|
[string]$Key,
|
|
[string]$Label,
|
|
[string]$ScriptRelativePath,
|
|
[string[]]$Arguments,
|
|
[string]$ExpectedPrefix,
|
|
[string]$SummaryFileName,
|
|
[string[]]$AcceptedStatuses = @("PASS")
|
|
)
|
|
|
|
$scriptPath = Join-Path $repoRoot $ScriptRelativePath
|
|
$logPath = Join-Path $artifactDir "$Key.log"
|
|
if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) {
|
|
Add-Check -Key $Key -Label $Label -Status "fail" -Detail "missing script: $ScriptRelativePath" -Meta @{ log = $logPath }
|
|
return $null
|
|
}
|
|
|
|
$previousErrorActionPreference = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
$output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scriptPath @Arguments 2>&1
|
|
$exitCode = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE }
|
|
} finally {
|
|
$ErrorActionPreference = $previousErrorActionPreference
|
|
}
|
|
$outputText = (@($output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine)
|
|
$outputText | Set-Content -Encoding UTF8 -Path $logPath
|
|
|
|
$status = "UNKNOWN"
|
|
$childArtifact = ""
|
|
$pattern = "(?m)^" + [regex]::Escape($ExpectedPrefix) + "(?<status>[A-Z_]+)\s+(?<artifact>.+?)\s*$"
|
|
$match = [regex]::Match($outputText, $pattern)
|
|
if ($match.Success) {
|
|
$status = $match.Groups["status"].Value
|
|
$childArtifact = $match.Groups["artifact"].Value.Trim()
|
|
}
|
|
|
|
$summaryPath = ""
|
|
if (-not [string]::IsNullOrWhiteSpace($childArtifact)) {
|
|
$candidate = Join-Path $childArtifact $SummaryFileName
|
|
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
|
|
$summaryPath = $candidate
|
|
}
|
|
}
|
|
$summary = Read-JsonFile -Path $summaryPath
|
|
|
|
if ($exitCode -ne 0 -and $AcceptedStatuses -notcontains $status) {
|
|
Add-Check -Key $Key -Label $Label -Status "fail" -Detail "child failed: status=$status exit=$exitCode" -Meta @{ log = $logPath; artifact = $childArtifact; summary = $summaryPath }
|
|
} elseif ($AcceptedStatuses -contains $status) {
|
|
Add-Check -Key $Key -Label $Label -Status "pass" -Detail "child status accepted: $status" -Meta @{ log = $logPath; artifact = $childArtifact; summary = $summaryPath }
|
|
} elseif ($status -eq "WARN") {
|
|
Add-Check -Key $Key -Label $Label -Status "warn" -Detail "child status=WARN" -Meta @{ log = $logPath; artifact = $childArtifact; summary = $summaryPath }
|
|
} elseif ($status -eq "BLOCKED") {
|
|
Add-Check -Key $Key -Label $Label -Status "blocked" -Detail "child status=BLOCKED" -Meta @{ log = $logPath; artifact = $childArtifact; summary = $summaryPath }
|
|
} else {
|
|
Add-Check -Key $Key -Label $Label -Status "fail" -Detail "unexpected child status=$status exit=$exitCode" -Meta @{ log = $logPath; artifact = $childArtifact; summary = $summaryPath }
|
|
}
|
|
|
|
return $summary
|
|
}
|
|
|
|
if (-not (Test-Path "$HOME\.Codex\skills\gstack\bin")) {
|
|
Add-Check -Key "gstack" -Label "gstack preflight" -Status "fail" -Detail "gstack 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 = git -C $repoRoot status --short
|
|
($gitStatus -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $gitStatusPath
|
|
|
|
$pcGitStatus = @($gitStatus | Where-Object {
|
|
$_ -notmatch "^\?\?\s+mobile/" -and
|
|
$_ -notmatch "^\s*M\s+mobile/" -and
|
|
$_ -notmatch "^\?\?\s+project-docs/.*mobile" -and
|
|
$_ -notmatch '^\?\?\s+"project-docs/\\347\\247\\273'
|
|
})
|
|
($pcGitStatus -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $pcGitStatusPath
|
|
|
|
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 @{ branch = $branch; commit = $commit; remote = $remoteUrl }
|
|
} else {
|
|
Add-Check -Key "origin_remote" -Label "origin remote" -Status "fail" -Detail "origin does not point to 192.168.3.90" -Meta @{ branch = $branch; commit = $commit; remote = $remoteUrl }
|
|
}
|
|
|
|
if ($pcGitStatus.Count -eq 0) {
|
|
Add-Check -Key "pc_worktree" -Label "PC launch worktree" -Status "pass" -Detail "no non-mobile uncommitted changes"
|
|
} else {
|
|
Add-Check -Key "pc_worktree" -Label "PC launch worktree" -Status "blocked" -Detail "non-mobile uncommitted changes remain" -Meta @{ artifact = $pcGitStatusPath }
|
|
}
|
|
|
|
if ([string]::IsNullOrWhiteSpace($env:AI_CONTRACT_ADMIN_PASSWORD)) {
|
|
Add-Check -Key "admin_password_env" -Label "production regression secret" -Status "blocked" -Detail "AI_CONTRACT_ADMIN_PASSWORD is not set in current process"
|
|
} else {
|
|
Add-Check -Key "admin_password_env" -Label "production regression secret" -Status "pass" -Detail "AI_CONTRACT_ADMIN_PASSWORD is set in current process"
|
|
}
|
|
|
|
$templates = Import-CsvRows -Path (Join-Path $LibraryRoot "inventories\contract-template-inventory.csv")
|
|
$generationSamples = Import-CsvRows -Path (Join-Path $LibraryRoot "inventories\generation-golden-sample-inventory.csv")
|
|
$materials = Import-CsvRows -Path (Join-Path $MaterialsRoot "production-materials-checklist.csv")
|
|
|
|
if ($templates.Count -ge $MinTemplates) {
|
|
Add-Check -Key "template_count" -Label "template inventory size" -Status "pass" -Detail "templates=$($templates.Count); min=$MinTemplates"
|
|
} else {
|
|
Add-Check -Key "template_count" -Label "template inventory size" -Status "blocked" -Detail "templates=$($templates.Count); min=$MinTemplates"
|
|
}
|
|
|
|
if ($generationSamples.Count -ge $templates.Count -and $templates.Count -gt 0) {
|
|
Add-Check -Key "generation_sample_count" -Label "generation sample coverage" -Status "pass" -Detail "generation_samples=$($generationSamples.Count); templates=$($templates.Count)"
|
|
} else {
|
|
Add-Check -Key "generation_sample_count" -Label "generation sample coverage" -Status "blocked" -Detail "generation_samples=$($generationSamples.Count); templates=$($templates.Count)"
|
|
}
|
|
|
|
$missingMaterials = @($materials | Where-Object {
|
|
$path = $_.file_path
|
|
[string]::IsNullOrWhiteSpace($path) -or -not (Test-Path -LiteralPath (Join-Path $repoRoot ($path -replace "/", "\")) -PathType Leaf)
|
|
})
|
|
if ($materials.Count -ge $MinMaterials -and $missingMaterials.Count -eq 0) {
|
|
Add-Check -Key "production_materials" -Label "production materials package" -Status "pass" -Detail "materials=$($materials.Count); min=$MinMaterials"
|
|
} else {
|
|
Add-Check -Key "production_materials" -Label "production materials package" -Status "blocked" -Detail "materials=$($materials.Count); missing=$($missingMaterials.Count); min=$MinMaterials"
|
|
}
|
|
|
|
Invoke-ChildScript -Key "production_health" -Label "production health" -ScriptRelativePath "scripts\run-production-health-check.ps1" -Arguments @(
|
|
"-ServerHost", $ServerHost,
|
|
"-BackendBaseUrl", $BackendBaseUrl,
|
|
"-FrontendBaseUrl", $FrontendBaseUrl,
|
|
"-ArtifactRoot", (Join-Path $artifactDir "production-health")
|
|
) -ExpectedPrefix "PHASE5_PRODUCTION_HEALTH_" -SummaryFileName "production-health-summary.json" | Out-Null
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($env:AI_CONTRACT_ADMIN_PASSWORD)) {
|
|
Invoke-ChildScript -Key "production_regression" -Label "production regression" -ScriptRelativePath "scripts\run-production-regression.ps1" -Arguments @(
|
|
"-WebUrl", $FrontendBaseUrl,
|
|
"-ApiUrl", $ApiUrl,
|
|
"-HealthUrl", $HealthUrl,
|
|
"-ReadinessUrl", $ReadinessUrl,
|
|
"-ArtifactRoot", (Join-Path $artifactDir "production-regression")
|
|
) -ExpectedPrefix "PHASE5_PRODUCTION_REGRESSION_" -SummaryFileName "production-regression-summary.json" | Out-Null
|
|
}
|
|
|
|
Invoke-ChildScript -Key "legal_library_gate" -Label "legal knowledge library gate" -ScriptRelativePath "scripts\run-legal-knowledge-library-gate.ps1" -Arguments @(
|
|
"-LibraryRoot", $LibraryRoot,
|
|
"-ArtifactRoot", (Join-Path $artifactDir "legal-library")
|
|
) -ExpectedPrefix "PHASE13_LEGAL_KNOWLEDGE_LIBRARY_GATE_" -SummaryFileName "legal-knowledge-library-gate-summary.json" -AcceptedStatuses @("PASS", "PASS_WITH_LEGAL_REVIEW_REQUIRED") | Out-Null
|
|
|
|
Invoke-ChildScript -Key "real_review_pilot" -Label "real review pilot package" -ScriptRelativePath "scripts\run-phase13-real-review-pilot-package-gate.ps1" -Arguments @(
|
|
"-LibraryRoot", $LibraryRoot,
|
|
"-ArtifactRoot", (Join-Path $repoRoot "project-docs\qa-artifacts\formal-launch-review-pilot")
|
|
) -ExpectedPrefix "PHASE13_REAL_REVIEW_PILOT_PACKAGE_" -SummaryFileName "phase13-real-review-pilot-package-summary.json" | Out-Null
|
|
|
|
$checks |
|
|
ForEach-Object {
|
|
[pscustomobject][ordered]@{
|
|
key = $_.key
|
|
label = $_.label
|
|
status = $_.status
|
|
required = $_.required
|
|
detail = $_.detail
|
|
}
|
|
} |
|
|
Export-Csv -LiteralPath $checksCsv -NoTypeInformation -Encoding UTF8
|
|
|
|
$failedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "fail" }).Count
|
|
$blockedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "blocked" }).Count
|
|
$warnings = @($checks | Where-Object { $_.status -eq "warn" }).Count
|
|
$overallStatus = if ($failedRequired -gt 0) {
|
|
"FAIL"
|
|
} elseif ($blockedRequired -gt 0) {
|
|
"BLOCKED"
|
|
} elseif ($warnings -gt 0) {
|
|
"WARN"
|
|
} else {
|
|
"PASS"
|
|
}
|
|
|
|
$summary = [ordered]@{
|
|
name = "formal-launch-preparation-gate"
|
|
status = $overallStatus
|
|
generated_at = (Get-Date).ToUniversalTime().ToString("o")
|
|
branch = $branch
|
|
commit = $commit
|
|
remote = $remoteUrl
|
|
library_root = $LibraryRoot
|
|
materials_root = $MaterialsRoot
|
|
metrics = [ordered]@{
|
|
templates = $templates.Count
|
|
generation_samples = $generationSamples.Count
|
|
production_materials = $materials.Count
|
|
pc_dirty_rows = $pcGitStatus.Count
|
|
failed_required = $failedRequired
|
|
blocked_required = $blockedRequired
|
|
warnings = $warnings
|
|
}
|
|
checks = $checks.ToArray()
|
|
artifacts = [ordered]@{
|
|
summary_json = $summaryJson
|
|
summary_md = $summaryMd
|
|
checks = $checksCsv
|
|
git_status = $gitStatusPath
|
|
pc_git_status = $pcGitStatusPath
|
|
}
|
|
}
|
|
Save-Json -Path $summaryJson -Value $summary
|
|
|
|
$md = New-Object System.Collections.Generic.List[string]
|
|
$md.Add("# Formal Launch Preparation Gate") | Out-Null
|
|
$md.Add("") | Out-Null
|
|
$md.Add("- Status: $overallStatus") | Out-Null
|
|
$md.Add("- Generated at: $($summary.generated_at)") | Out-Null
|
|
$md.Add("- Branch: $branch") | Out-Null
|
|
$md.Add("- Commit: $commit") | Out-Null
|
|
$md.Add("- Artifact dir: $artifactDir") | Out-Null
|
|
$md.Add("") | Out-Null
|
|
$md.Add("## Metrics") | Out-Null
|
|
$md.Add("") | Out-Null
|
|
$md.Add("| metric | count |") | Out-Null
|
|
$md.Add("|---|---:|") | Out-Null
|
|
$md.Add("| templates | $($summary.metrics.templates) |") | Out-Null
|
|
$md.Add("| generation_samples | $($summary.metrics.generation_samples) |") | Out-Null
|
|
$md.Add("| production_materials | $($summary.metrics.production_materials) |") | Out-Null
|
|
$md.Add("| pc_dirty_rows | $($summary.metrics.pc_dirty_rows) |") | Out-Null
|
|
$md.Add("| blocked_required | $($summary.metrics.blocked_required) |") | Out-Null
|
|
$md.Add("| failed_required | $($summary.metrics.failed_required) |") | Out-Null
|
|
$md.Add("") | Out-Null
|
|
$md.Add("## Checks") | Out-Null
|
|
$md.Add("") | Out-Null
|
|
$md.Add("| check | required | status | detail |") | Out-Null
|
|
$md.Add("|---|---:|---|---|") | Out-Null
|
|
foreach ($check in $checks) {
|
|
$detail = ([string]$check.detail).Replace("|", "/")
|
|
$md.Add("| $($check.label) | $($check.required) | $($check.status.ToUpperInvariant()) | $detail |") | Out-Null
|
|
}
|
|
$md.Add("") | Out-Null
|
|
$md.Add("## Boundary") | Out-Null
|
|
$md.Add("") | Out-Null
|
|
$md.Add("PASS means the PC formal-launch preparation package is ready and the automation can be rerun. It does not convert unreviewed legal assets into production-approved content.") | Out-Null
|
|
$md | Set-Content -Encoding UTF8 -Path $summaryMd
|
|
|
|
Write-Host "FORMAL_LAUNCH_PREPARATION_GATE_$overallStatus $artifactDir"
|
|
if ($overallStatus -eq "FAIL") {
|
|
throw "formal launch preparation gate failed: $summaryMd"
|
|
}
|
|
if ($overallStatus -eq "BLOCKED" -and $FailOnBlocked) {
|
|
throw "formal launch preparation gate blocked: $summaryMd"
|
|
}
|