Files
ai-contract-platform/scripts/run-phase15-release-gate.ps1
T

730 lines
33 KiB
PowerShell

param(
[string]$RemoteHost = "192.168.3.90",
[string]$RemoteUser = "root",
[string]$BackendContainer = "ai-contract-backend-restore",
[string]$SharedAIContainer = "shared-ai-platform",
[string]$BackendBaseUrl = "http://192.168.3.90:8001",
[string]$FrontendBaseUrl = "http://192.168.3.90:3003",
[string]$SshKeyPath = "$HOME\.ssh\water_audit_ed25519",
[switch]$UseSshpass,
[string]$TenantId = "tenant-demo-001",
[int]$ChatWaitSeconds = 35,
[string]$ArtifactRoot = "",
[switch]$RequireCleanWorktree,
[switch]$SkipProductE2E
)
$ErrorActionPreference = "Stop"
function Add-Check {
param(
[System.Collections.Generic.List[object]]$Checks,
[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 100 | Set-Content -Encoding UTF8 -Path $Path
}
function Read-JsonFile {
param([string]$Path)
return Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
}
function ConvertTo-ShellSingleQuote {
param([string]$Value)
return "'" + ($Value -replace "'", "'`"`"`'") + "'"
}
function Resolve-SshpassPath {
$command = Get-Command sshpass.exe -ErrorAction SilentlyContinue
if ($null -ne $command) {
return $command.Source
}
$candidates = @(
(Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Packages\xhcoding.sshpass-win32_Microsoft.Winget.Source_8wekyb3d8bbwe\sshpass.exe")
)
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
return (Resolve-Path -LiteralPath $candidate).Path
}
}
return $null
}
function Resolve-RemoteAuth {
if ($UseSshpass -or -not [string]::IsNullOrWhiteSpace($env:SSHPASS)) {
$sshpassPath = Resolve-SshpassPath
if ([string]::IsNullOrWhiteSpace($sshpassPath)) {
throw "sshpass.exe not found. Install sshpass or use SSH key auth."
}
if ([string]::IsNullOrWhiteSpace($env:SSHPASS)) {
throw "SSHPASS environment variable is empty."
}
return [ordered]@{
mode = "sshpass"
ssh_file = $sshpassPath
scp_file = $sshpassPath
ssh_prefix = @("-e", "ssh")
scp_prefix = @("-e", "scp")
ssh_common = @("-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10")
scp_common = @("-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10")
}
}
if (Test-Path -LiteralPath $SshKeyPath -PathType Leaf) {
return [ordered]@{
mode = "key"
ssh_file = "ssh"
scp_file = "scp"
ssh_prefix = @("-i", $SshKeyPath)
scp_prefix = @("-i", $SshKeyPath)
ssh_common = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10")
scp_common = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10")
}
}
throw "SSH key not found and SSHPASS is not configured."
}
function ConvertTo-ProcessArgument {
param([AllowNull()][string]$Value)
if ($null -eq $Value) {
return '""'
}
$text = [string]$Value
if ($text.Length -eq 0) {
return '""'
}
if ($text -notmatch '[\s"]') {
return $text
}
return '"' + $text.Replace('"', '\"') + '"'
}
function Invoke-LoggedCommand {
param(
[string]$Name,
[string]$FilePath,
[string[]]$Arguments,
[string]$LogDir,
[switch]$AllowFailure
)
$logPath = Join-Path $LogDir "$Name.log"
$stdoutPath = Join-Path $LogDir "$Name.stdout.log"
$stderrPath = Join-Path $LogDir "$Name.stderr.log"
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$quotedArguments = @($Arguments | ForEach-Object { ConvertTo-ProcessArgument $_ })
$process = Start-Process -FilePath $FilePath -ArgumentList $quotedArguments -NoNewWindow -Wait -PassThru -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath
$exitCode = if ($process -and $process.ExitCode -is [int]) { $process.ExitCode } else { 0 }
} finally {
$ErrorActionPreference = $previousErrorActionPreference
}
$stdoutText = if (Test-Path -LiteralPath $stdoutPath -PathType Leaf) { Get-Content -LiteralPath $stdoutPath -Raw -ErrorAction SilentlyContinue } else { "" }
$stderrText = if (Test-Path -LiteralPath $stderrPath -PathType Leaf) { Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue } else { "" }
$outputText = (@($stdoutText, $stderrText) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine
$outputText | Set-Content -Encoding UTF8 -Path $logPath
if ($exitCode -ne 0 -and -not $AllowFailure) {
throw "$Name failed with exit code $exitCode. See $logPath"
}
return [ordered]@{
exit_code = $exitCode
output = $outputText
log = $logPath
}
}
function Invoke-HttpProbe {
param([string]$Url)
try {
$response = Invoke-WebRequest -UseBasicParsing -Method Get -Uri $Url -TimeoutSec 30
return [ordered]@{
status_code = [int]$response.StatusCode
ok = ([int]$response.StatusCode -ge 200 -and [int]$response.StatusCode -lt 400)
content_length = if ($null -ne $response.Content) { $response.Content.Length } else { 0 }
}
} catch {
$statusCode = $null
if ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
$statusCode = [int]$_.Exception.Response.StatusCode
}
return [ordered]@{
status_code = $statusCode
ok = $false
error = $_.Exception.Message
}
}
}
function Get-LatestArtifactDirectory {
param([string]$Root)
if (-not (Test-Path -LiteralPath $Root -PathType Container)) {
return $null
}
return Get-ChildItem -LiteralPath $Root -Directory |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
}
function New-ReleaseInventoryPython {
param([string]$Path)
$script = @'
from __future__ import annotations
import argparse
import json
import os
from datetime import datetime, timezone
from typing import Any, Dict, List
from sqlalchemy import func
from app.db import SessionLocal
from app.models.contract_template import ContractTemplate
from app.models.legal_article import LegalArticle
from app.models.legal_document_type import LegalDocumentType
from app.models.prompt_asset import PromptAsset
from app.models.prompt_asset_version import PromptAssetVersion
from app.services.legal_article_service import LegalArticleService
from app.services.prompt_runtime_service import PromptRuntimeService
FOCUS_DOCUMENT_TYPES = [
"investment_agreement",
"valuation_adjustment_agreement",
"shareholder_resolution",
"power_of_attorney",
]
LEGAL_SEARCH_CASES = [
"\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u6c11\u6cd5\u5178 \u7b2c\u4e94\u767e\u96f6\u4e5d\u6761 \u5408\u540c\u5c65\u884c",
"\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u516c\u53f8\u6cd5 \u80a1\u4e1c\u4f1a \u51b3\u8bae",
"\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u52b3\u52a8\u5408\u540c\u6cd5 \u4e66\u9762\u52b3\u52a8\u5408\u540c",
"\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u4e2a\u4eba\u4fe1\u606f\u4fdd\u62a4\u6cd5 \u4e2a\u4eba\u4fe1\u606f\u5904\u7406",
]
def load_catalog(path: str) -> list[dict[str, Any]]:
with open(path, "r", encoding="utf-8-sig") as handle:
payload = json.load(handle)
assets = payload.get("assets") or []
if not isinstance(assets, list) or not assets:
raise RuntimeError("Prompt asset catalog is empty or invalid")
return assets
def to_bool(value: Any) -> bool:
return bool(value)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--catalog-path", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--tenant-id", required=True)
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
assets = load_catalog(args.catalog_path)
expected_keys = [str(item.get("prompt_key") or "").strip() for item in assets if str(item.get("prompt_key") or "").strip()]
expected_by_key = {str(item.get("prompt_key")): item for item in assets if item.get("prompt_key")}
current_user = {
"id": "phase15-release-gate",
"tenant_id": args.tenant_id,
"display_name": "Phase15 Release Gate",
"role": "admin",
}
db = SessionLocal()
try:
prompt_rows = (
db.query(PromptAsset)
.filter(PromptAsset.tenant_id == args.tenant_id, PromptAsset.prompt_key.in_(expected_keys))
.all()
)
prompts_by_key = {row.prompt_key: row for row in prompt_rows}
missing_prompt_keys = sorted(set(expected_keys) - set(prompts_by_key.keys()))
not_published: list[dict[str, Any]] = []
published_version_ids: list[str] = []
by_type: dict[str, dict[str, int]] = {}
for key in expected_keys:
expected = expected_by_key[key]
prompt_type = str(expected.get("prompt_type") or "")
by_type.setdefault(prompt_type, {"expected": 0, "found": 0, "published": 0})
by_type[prompt_type]["expected"] += 1
row = prompts_by_key.get(key)
if row is None:
continue
by_type[prompt_type]["found"] += 1
if row.status == "published" and row.published_version_id:
by_type[prompt_type]["published"] += 1
published_version_ids.append(row.published_version_id)
else:
not_published.append({"prompt_key": key, "status": row.status, "published_version_id": row.published_version_id})
published_versions = []
if published_version_ids:
published_versions = (
db.query(PromptAssetVersion)
.filter(PromptAssetVersion.tenant_id == args.tenant_id, PromptAssetVersion.id.in_(published_version_ids))
.all()
)
published_version_count = len([item for item in published_versions if item.is_published])
runtime_service = PromptRuntimeService(db)
runtime_results: list[dict[str, Any]] = []
runtime_failures: list[dict[str, Any]] = []
for key in expected_keys:
prompt_type = str(expected_by_key[key].get("prompt_type") or "")
runtime = runtime_service.resolve_published_prompt(
current_user,
prompt_type,
preferred_keys=[key],
fallback_prompt_version=f"{key}.local_default",
)
ok = (
runtime.get("available") is True
and runtime.get("source") == "published_prompt_asset"
and runtime.get("prompt_key") == key
and runtime.get("match_strategy") == "preferred_key"
)
item = {
"prompt_key": key,
"prompt_type": prompt_type,
"ok": ok,
"source": runtime.get("source"),
"match_strategy": runtime.get("match_strategy"),
"prompt_version": runtime.get("prompt_version"),
"prompt_asset_id": runtime.get("prompt_asset_id"),
}
runtime_results.append(item)
if not ok:
runtime_failures.append(item)
active_document_types = db.query(func.count(LegalDocumentType.id)).filter(LegalDocumentType.is_active.is_(True)).scalar() or 0
enabled_templates = (
db.query(func.count(ContractTemplate.id))
.filter(ContractTemplate.tenant_id == args.tenant_id, ContractTemplate.enabled.is_(True))
.scalar()
or 0
)
focus_results = []
for code in FOCUS_DOCUMENT_TYPES:
document_type = db.query(LegalDocumentType).filter(LegalDocumentType.code == code, LegalDocumentType.is_active.is_(True)).first()
template_count = (
db.query(func.count(ContractTemplate.id))
.filter(
ContractTemplate.tenant_id == args.tenant_id,
ContractTemplate.document_type_code == code,
ContractTemplate.enabled.is_(True),
)
.scalar()
or 0
)
focus_results.append(
{
"document_type_code": code,
"document_type_exists": document_type is not None,
"enabled_template_count": int(template_count),
"ok": document_type is not None and int(template_count) > 0,
}
)
published_indexed_count = (
db.query(func.count(LegalArticle.id))
.filter(
LegalArticle.tenant_id == args.tenant_id,
LegalArticle.published_status == "published",
LegalArticle.index_status == "indexed",
)
.scalar()
or 0
)
legal_service = LegalArticleService(db)
search_results = []
for query in LEGAL_SEARCH_CASES:
items = legal_service.search_runtime_sources(current_user, query, limit=5)
search_results.append(
{
"query": query,
"count": len(items),
"top_source_title": items[0].get("source_title") if items else None,
"top_article_no": items[0].get("article_no") if items else None,
"ok": len(items) > 0,
}
)
checks = {
"prompt_assets_36_36_published": len(expected_keys) == 36 and len(prompt_rows) == 36 and len(not_published) == 0 and published_version_count == 36,
"prompt_runtime_36_36_resolved": len(runtime_failures) == 0 and len(runtime_results) == 36,
"template_document_type_baseline": int(active_document_types) >= 18 and int(enabled_templates) >= 18 and all(item["ok"] for item in focus_results),
"legal_library_searchable": int(published_indexed_count) > 0 and all(item["ok"] for item in search_results),
}
status = "PASS" if all(checks.values()) else "FAIL"
summary = {
"status": status,
"generated_at": datetime.now(timezone.utc).isoformat(),
"tenant_id": args.tenant_id,
"prompt_assets": {
"catalog_total": len(expected_keys),
"found": len(prompt_rows),
"published": len(prompt_rows) - len(not_published),
"published_versions": published_version_count,
"runtime_resolved": len(runtime_results) - len(runtime_failures),
"missing_prompt_keys": missing_prompt_keys,
"not_published": not_published,
"runtime_failures": runtime_failures,
"by_type": by_type,
},
"templates_and_document_types": {
"active_document_types": int(active_document_types),
"enabled_templates": int(enabled_templates),
"focus_document_types": focus_results,
"focus_passed": sum(1 for item in focus_results if item["ok"]),
"focus_total": len(focus_results),
},
"legal_articles": {
"published_indexed_count": int(published_indexed_count),
"search_cases_passed": sum(1 for item in search_results if item["ok"]),
"search_cases_total": len(search_results),
"search_results": search_results,
},
"checks": checks,
}
output_path = os.path.join(args.output_dir, "phase15-release-gate-inventory.json")
with open(output_path, "w", encoding="utf-8") as handle:
json.dump(summary, handle, ensure_ascii=False, indent=2)
print(json.dumps({"status": status, "output_path": output_path}, ensure_ascii=False))
return 0 if status == "PASS" else 1
finally:
db.close()
if __name__ == "__main__":
raise SystemExit(main())
'@
$script | Set-Content -Encoding UTF8 -Path $Path
}
function Invoke-ReleaseInventoryCheck {
param(
[string]$ArtifactDir,
[string]$RepoRoot,
[string]$Timestamp
)
$catalogPath = Join-Path $RepoRoot "project-docs\phase15-prompt-assets\default-prompt-assets.json"
if (-not (Test-Path -LiteralPath $catalogPath -PathType Leaf)) {
throw "Prompt asset catalog not found: $catalogPath"
}
$auth = Resolve-RemoteAuth
$target = "${RemoteUser}@${RemoteHost}"
$remoteWorkDir = "/tmp/ai-contract-phase15-release-gate-$Timestamp"
$inventoryScriptPath = Join-Path $ArtifactDir "phase15_release_gate_inventory.py"
New-ReleaseInventoryPython -Path $inventoryScriptPath
Invoke-LoggedCommand -Name "remote-inventory-mkdir" -FilePath $auth.ssh_file -Arguments ($auth.ssh_prefix + $auth.ssh_common + @($target, "rm -rf $(ConvertTo-ShellSingleQuote $remoteWorkDir) && mkdir -p $(ConvertTo-ShellSingleQuote $remoteWorkDir)")) -LogDir $ArtifactDir | Out-Null
Invoke-LoggedCommand -Name "scp-inventory-script" -FilePath $auth.scp_file -Arguments ($auth.scp_prefix + $auth.scp_common + @($inventoryScriptPath, "${target}:$remoteWorkDir/phase15_release_gate_inventory.py")) -LogDir $ArtifactDir | Out-Null
Invoke-LoggedCommand -Name "scp-prompt-catalog" -FilePath $auth.scp_file -Arguments ($auth.scp_prefix + $auth.scp_common + @($catalogPath, "${target}:$remoteWorkDir/default-prompt-assets.json")) -LogDir $ArtifactDir | Out-Null
$containerScript = "$remoteWorkDir/phase15_release_gate_inventory.py"
$containerCatalog = "$remoteWorkDir/default-prompt-assets.json"
$containerOutput = "$remoteWorkDir/output"
$containerRunCommand = "cd /app && PYTHONPATH=/app python $(ConvertTo-ShellSingleQuote $containerScript) --catalog-path $(ConvertTo-ShellSingleQuote $containerCatalog) --output-dir $(ConvertTo-ShellSingleQuote $containerOutput) --tenant-id $(ConvertTo-ShellSingleQuote $TenantId)"
$remoteCommandParts = @(
"set +e",
"docker exec $BackendContainer sh -lc $(ConvertTo-ShellSingleQuote "rm -rf $remoteWorkDir && mkdir -p $containerOutput")",
"prep_exit=`$?",
"if [ `$prep_exit -ne 0 ]; then exit `$prep_exit; fi",
"docker cp $remoteWorkDir/phase15_release_gate_inventory.py ${BackendContainer}:$containerScript",
"copy_script_exit=`$?",
"if [ `$copy_script_exit -ne 0 ]; then exit `$copy_script_exit; fi",
"docker cp $remoteWorkDir/default-prompt-assets.json ${BackendContainer}:$containerCatalog",
"copy_catalog_exit=`$?",
"if [ `$copy_catalog_exit -ne 0 ]; then exit `$copy_catalog_exit; fi",
"docker exec $BackendContainer sh -lc $(ConvertTo-ShellSingleQuote $containerRunCommand)",
"inventory_exit=`$?",
"docker cp ${BackendContainer}:$containerOutput $remoteWorkDir/output >/dev/null 2>&1 || true",
"exit `$inventory_exit"
)
$remoteRun = Invoke-LoggedCommand -Name "remote-release-inventory" -FilePath $auth.ssh_file -Arguments ($auth.ssh_prefix + $auth.ssh_common + @($target, ($remoteCommandParts -join " ; "))) -LogDir $ArtifactDir -AllowFailure
Invoke-LoggedCommand -Name "scp-inventory-output" -FilePath $auth.scp_file -Arguments ($auth.scp_prefix + $auth.scp_common + @("-r", "${target}:$remoteWorkDir/output/.", $ArtifactDir)) -LogDir $ArtifactDir | Out-Null
$summaryPath = Join-Path $ArtifactDir "phase15-release-gate-inventory.json"
if (-not (Test-Path -LiteralPath $summaryPath -PathType Leaf)) {
throw "Inventory summary not found after remote run: $summaryPath"
}
return [ordered]@{
exit_code = $remoteRun.exit_code
summary_path = $summaryPath
summary = Read-JsonFile -Path $summaryPath
}
}
function Invoke-ProductE2E {
param(
[string]$ArtifactDir,
[string]$RepoRoot
)
$scriptPath = Join-Path $RepoRoot "scripts\run-phase15-product-e2e.ps1"
$childRoot = Join-Path $ArtifactDir "phase15-product-e2e"
$args = @(
"-RemoteHost", $RemoteHost,
"-RemoteUser", $RemoteUser,
"-BackendContainer", $BackendContainer,
"-SharedAIContainer", $SharedAIContainer,
"-BackendBaseUrl", $BackendBaseUrl,
"-FrontendBaseUrl", $FrontendBaseUrl,
"-SshKeyPath", $SshKeyPath,
"-TenantId", $TenantId,
"-ChatWaitSeconds", ([string]$ChatWaitSeconds),
"-ArtifactRoot", $childRoot
)
if ($UseSshpass) {
$args += "-UseSshpass"
}
$run = Invoke-LoggedCommand -Name "phase15-product-e2e" -FilePath "powershell.exe" -Arguments (@("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $scriptPath) + $args) -LogDir $ArtifactDir -AllowFailure
$match = [regex]::Match($run.output, "PHASE15_PRODUCT_E2E_(PASS|BLOCKED|FAIL)\s+(.+)")
$childArtifact = $null
if ($match.Success) {
$childArtifact = $match.Groups[2].Value.Trim()
}
$summaryPath = if ($childArtifact) { Join-Path $childArtifact "phase15-product-e2e-summary.json" } else { $null }
$summary = $null
if ($summaryPath -and (Test-Path -LiteralPath $summaryPath -PathType Leaf)) {
$summary = Read-JsonFile -Path $summaryPath
}
return [ordered]@{
exit_code = $run.exit_code
log = $run.log
artifact_dir = $childArtifact
summary_path = $summaryPath
summary = $summary
}
}
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$timestamp = Get-Date -Format "yyyy-MM-ddTHH-mm-ss"
if ([string]::IsNullOrWhiteSpace($ArtifactRoot)) {
$ArtifactRoot = Join-Path $repoRoot "project-docs\qa-artifacts\phase15-release-gate"
}
if (-not [System.IO.Path]::IsPathRooted($ArtifactRoot)) {
$ArtifactRoot = Join-Path $repoRoot $ArtifactRoot
}
$artifactDir = Join-Path $ArtifactRoot $timestamp
New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
$summaryJsonPath = Join-Path $artifactDir "phase15-release-gate-summary.json"
$summaryMdPath = Join-Path $artifactDir "phase15-release-gate-summary.md"
$gitStatusPath = Join-Path $artifactDir "git-status.txt"
$rollbackPromptPath = Join-Path $artifactDir "backup-rollback-prompt.json"
$checks = New-Object System.Collections.Generic.List[object]
if (-not (Test-Path "$HOME\.Codex\skills\gstack\bin")) {
Add-Check -Checks $checks -Key "gstack" -Label "gstack prerequisite" -Status "fail" -Detail "GSTACK_MISSING"
} else {
Add-Check -Checks $checks -Key "gstack" -Label "gstack prerequisite" -Status "pass" -Detail "GSTACK_OK"
}
$branch = (git -C $repoRoot rev-parse --abbrev-ref HEAD).Trim()
$commit = (git -C $repoRoot rev-parse HEAD).Trim()
$gitStatus = git -C $repoRoot status --short
($gitStatus -join [Environment]::NewLine) | Set-Content -Encoding UTF8 -Path $gitStatusPath
if ($gitStatus) {
if ($RequireCleanWorktree) {
Add-Check -Checks $checks -Key "working_tree" -Label "working tree" -Status "fail" -Detail "Working tree has uncommitted changes and -RequireCleanWorktree is set" -Meta @{ artifact = $gitStatusPath }
} else {
Add-Check -Checks $checks -Key "working_tree" -Label "working tree" -Status "warn" -Required:$false -Detail "Working tree has uncommitted changes; release gate continues in advisory mode" -Meta @{ artifact = $gitStatusPath }
}
} else {
Add-Check -Checks $checks -Key "working_tree" -Label "working tree" -Status "pass" -Detail "Working tree is clean" -Meta @{ artifact = $gitStatusPath }
}
$backendHealth = Invoke-HttpProbe -Url ($BackendBaseUrl.TrimEnd("/") + "/health")
Add-Check -Checks $checks -Key "backend_health" -Label "backend health" -Status $(if ($backendHealth.ok) { "pass" } else { "fail" }) -Detail "GET /health status=$($backendHealth.status_code)" -Meta @{ url = ($BackendBaseUrl.TrimEnd("/") + "/health") }
$readinessUrl = $BackendBaseUrl.TrimEnd("/") + "/api/v1/system/readiness"
$readiness = Invoke-HttpProbe -Url $readinessUrl
Add-Check -Checks $checks -Key "backend_readiness" -Label "backend readiness" -Status $(if ($readiness.ok) { "pass" } else { "fail" }) -Detail "GET /api/v1/system/readiness status=$($readiness.status_code)" -Meta @{ url = $readinessUrl }
$frontend = Invoke-HttpProbe -Url $FrontendBaseUrl.TrimEnd("/")
Add-Check -Checks $checks -Key "frontend_health" -Label "frontend health" -Status $(if ($frontend.ok) { "pass" } else { "fail" }) -Detail "GET frontend status=$($frontend.status_code)" -Meta @{ url = $FrontendBaseUrl.TrimEnd("/") }
try {
$inventoryRun = Invoke-ReleaseInventoryCheck -ArtifactDir $artifactDir -RepoRoot $repoRoot -Timestamp $timestamp
$inventory = $inventoryRun.summary
Add-Check -Checks $checks -Key "prompt_assets_published" -Label "Prompt assets 36/36 published" -Status $(if ($inventory.checks.prompt_assets_36_36_published -and $inventoryRun.exit_code -eq 0) { "pass" } else { "fail" }) -Detail "catalog=$($inventory.prompt_assets.catalog_total); published=$($inventory.prompt_assets.published); published_versions=$($inventory.prompt_assets.published_versions)" -Meta @{ artifact = $inventoryRun.summary_path }
Add-Check -Checks $checks -Key "prompt_runtime_resolved" -Label "Prompt runtime 36/36 published resolution" -Status $(if ($inventory.checks.prompt_runtime_36_36_resolved) { "pass" } else { "fail" }) -Detail "runtime_resolved=$($inventory.prompt_assets.runtime_resolved)/$($inventory.prompt_assets.catalog_total)" -Meta @{ artifact = $inventoryRun.summary_path }
Add-Check -Checks $checks -Key "templates_document_types" -Label "templates and document types" -Status $(if ($inventory.checks.template_document_type_baseline) { "pass" } else { "fail" }) -Detail "active_document_types=$($inventory.templates_and_document_types.active_document_types); enabled_templates=$($inventory.templates_and_document_types.enabled_templates); focus=$($inventory.templates_and_document_types.focus_passed)/$($inventory.templates_and_document_types.focus_total)" -Meta @{ artifact = $inventoryRun.summary_path }
Add-Check -Checks $checks -Key "legal_articles_searchable" -Label "legal article library searchable" -Status $(if ($inventory.checks.legal_library_searchable) { "pass" } else { "fail" }) -Detail "published_indexed=$($inventory.legal_articles.published_indexed_count); search=$($inventory.legal_articles.search_cases_passed)/$($inventory.legal_articles.search_cases_total)" -Meta @{ artifact = $inventoryRun.summary_path }
} catch {
$inventory = $null
Add-Check -Checks $checks -Key "release_inventory" -Label "release inventory" -Status "fail" -Detail $_.Exception.Message
}
if ($SkipProductE2E) {
$productE2E = $null
Add-Check -Checks $checks -Key "phase15_product_e2e" -Label "Phase15 product E2E" -Status "skip" -Required:$false -Detail "Skipped by -SkipProductE2E"
Add-Check -Checks $checks -Key "chat_stream" -Label "Chat stream" -Status "skip" -Required:$false -Detail "Skipped because product E2E was skipped"
} else {
$productE2E = Invoke-ProductE2E -ArtifactDir $artifactDir -RepoRoot $repoRoot
$productSummary = $productE2E.summary
$productPass = $productE2E.exit_code -eq 0 -and $null -ne $productSummary -and $productSummary.status -eq "PASS"
Add-Check -Checks $checks -Key "phase15_product_e2e" -Label "Phase15 product E2E" -Status $(if ($productPass) { "pass" } elseif ($productE2E.exit_code -eq 2) { "blocked" } else { "fail" }) -Detail $(if ($productSummary) { "status=$($productSummary.status); documents=$($productSummary.metrics.document_cases_passed)/$($productSummary.metrics.document_cases_total)" } else { "summary unavailable; exit=$($productE2E.exit_code)" }) -Meta @{ log = $productE2E.log; artifact_dir = $productE2E.artifact_dir; summary = $productE2E.summary_path }
$chatPass = $false
$chatDetail = "product E2E summary unavailable"
if ($null -ne $productSummary) {
$followupPassed = [int]$productSummary.metrics.frontend_continue_followup_passed
$followupTotal = [int]$productSummary.metrics.frontend_continue_followup_total
$businessPassed = [int]$productSummary.metrics.frontend_business_auxiliary_chat_passed
$businessTotal = [int]$productSummary.metrics.frontend_business_auxiliary_chat_total
$chatPass = ($followupTotal -gt 0 -and $followupPassed -eq $followupTotal -and $businessTotal -gt 0 -and $businessPassed -eq $businessTotal)
$chatDetail = "continue_followup=$followupPassed/$followupTotal; business_auxiliary=$businessPassed/$businessTotal"
}
Add-Check -Checks $checks -Key "chat_stream" -Label "Chat stream" -Status $(if ($chatPass) { "pass" } else { "fail" }) -Detail $chatDetail -Meta @{ artifact_dir = $productE2E.artifact_dir; summary = $productE2E.summary_path }
}
$restoreRoot = Join-Path $repoRoot "project-docs\qa-artifacts\restore-docker-deploy"
$latestRestore = Get-LatestArtifactDirectory -Root $restoreRoot
$rollbackPrompt = [ordered]@{
reminder = "Before production publish, confirm latest deploy artifacts and rollback owner. Keep backend/frontend/project-docs archives available until post-release signoff."
suggested_restore_command = "powershell -NoProfile -ExecutionPolicy Bypass -File scripts\deploy-ai-contract-restore-docker.ps1 -UseSshpass -AllowDirtyWorktree"
latest_restore_artifact_dir = if ($latestRestore) { $latestRestore.FullName } else { $null }
latest_restore_summary = if ($latestRestore) { Join-Path $latestRestore.FullName "restore-docker-deploy-summary.md" } else { $null }
}
Save-Json -Path $rollbackPromptPath -Value $rollbackPrompt
$rollbackStatus = if ($latestRestore -and (Test-Path -LiteralPath $rollbackPrompt.latest_restore_summary -PathType Leaf)) { "pass" } else { "warn" }
Add-Check -Checks $checks -Key "backup_rollback_prompt" -Label "backup and rollback prompt" -Status $rollbackStatus -Required:$false -Detail $(if ($latestRestore) { "latest_restore_artifact=$($latestRestore.Name)" } else { "No restore-docker-deploy artifact found; prompt generated" }) -Meta @{ artifact = $rollbackPromptPath }
$failedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "fail" })
$blockedRequired = @($checks | Where-Object { $_.required -and $_.status -eq "blocked" })
$warnings = @($checks | Where-Object { $_.status -eq "warn" })
$skipped = @($checks | Where-Object { $_.status -eq "skip" })
$overallStatus = if ($failedRequired.Count -gt 0) {
"FAIL"
} elseif ($blockedRequired.Count -gt 0) {
"BLOCKED"
} elseif ($warnings.Count -gt 0) {
"WARN"
} else {
"PASS"
}
$summary = [ordered]@{
name = "phase15-release-gate"
status = $overallStatus
generated_at = (Get-Date).ToUniversalTime().ToString("o")
artifact_dir = $artifactDir
branch = $branch
commit = $commit
targets = [ordered]@{
remote_host = $RemoteHost
backend_container = $BackendContainer
shared_ai_container = $SharedAIContainer
backend_base_url = $BackendBaseUrl
frontend_base_url = $FrontendBaseUrl
tenant_id = $TenantId
}
options = [ordered]@{
require_clean_worktree = [bool]$RequireCleanWorktree
skip_product_e2e = [bool]$SkipProductE2E
chat_wait_seconds = $ChatWaitSeconds
}
checks = $checks
inventory = if ($inventory) { $inventory } else { $null }
product_e2e = if ($productE2E) {
[ordered]@{
exit_code = $productE2E.exit_code
artifact_dir = $productE2E.artifact_dir
summary_path = $productE2E.summary_path
}
} else { $null }
rollback_prompt = $rollbackPrompt
totals = [ordered]@{
checks = $checks.Count
failed_required = $failedRequired.Count
blocked_required = $blockedRequired.Count
warnings = $warnings.Count
skipped = $skipped.Count
}
}
Save-Json -Path $summaryJsonPath -Value $summary
$markdown = New-Object System.Collections.Generic.List[string]
$markdown.Add("# Phase15 Release Gate") | 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("- Backend: $BackendBaseUrl") | Out-Null
$markdown.Add("- Frontend: $FrontendBaseUrl") | 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("## Backup And Rollback Prompt") | Out-Null
$markdown.Add("") | Out-Null
$markdown.Add("- Reminder: $($rollbackPrompt.reminder)") | Out-Null
$markdown.Add('- Suggested restore command: `' + $rollbackPrompt.suggested_restore_command + '`') | Out-Null
if ($rollbackPrompt.latest_restore_artifact_dir) {
$markdown.Add('- Latest restore artifact: `' + $rollbackPrompt.latest_restore_artifact_dir + '`') | Out-Null
}
$markdown.Add("") | Out-Null
$markdown.Add("## Artifacts") | Out-Null
$markdown.Add("") | Out-Null
$markdown.Add('- `phase15-release-gate-summary.json`') | Out-Null
$markdown.Add('- `phase15-release-gate-summary.md`') | Out-Null
$markdown.Add('- `phase15-release-gate-inventory.json`') | Out-Null
$markdown.Add('- `backup-rollback-prompt.json`') | Out-Null
$markdown.Add('- `git-status.txt`') | Out-Null
if ($productE2E -and $productE2E.artifact_dir) {
$markdown.Add('- Product E2E: `' + $productE2E.artifact_dir + '`') | Out-Null
}
$markdown | Set-Content -Encoding UTF8 -Path $summaryMdPath
Write-Host "PHASE15_RELEASE_GATE_$overallStatus $artifactDir"
if ($overallStatus -eq "FAIL" -or $overallStatus -eq "BLOCKED") {
exit 1
}
exit 0