Files
story/scripts/stage8-preflight.ps1
T

179 lines
7.0 KiB
PowerShell

[CmdletBinding()]
param(
[string]$EvidenceIndex = 'testdata/release-evidence-index.example.v2.json',
[string]$OutputPath = '',
[switch]$Strict
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$requiredCodes = @(
'REMOTE_CI_SUPPLY_CHAIN',
'REAL_DEVICE_RTC',
'REAL_DEVICE_FIRMWARE',
'REAL_AGENT_MODELS',
'PRODUCTION_INFRASTRUCTURE',
'PRODUCTION_RECOVERY',
'KMS_ROTATION',
'REAL_ALERT_RECEIVER',
'SAFARI_COMPATIBILITY',
'BUSINESS_APPROVAL'
)
function Invoke-GitText {
param([Parameter(Mandatory)][string[]]$Arguments)
$output = @(& git @Arguments 2>$null)
if ($LASTEXITCODE -ne 0) {
return @()
}
return @($output | ForEach-Object { $_.ToString().Trim() } | Where-Object { $_ })
}
function Test-PlaceholderDigest {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
return $Value -eq ('sha256:' + ('0' * 64))
}
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw 'Git is required for the stage 8 preflight.'
}
$resolvedIndex = (Resolve-Path -LiteralPath $EvidenceIndex).Path
$index = Get-Content -Raw -LiteralPath $resolvedIndex | ConvertFrom-Json
if ($index.schema_version -ne 'release-evidence-index-v2') {
throw "Unsupported evidence index schema: $($index.schema_version)"
}
$commitLines = @(Invoke-GitText -Arguments @('rev-parse', '--verify', 'HEAD'))
$commit = if ($commitLines.Count -gt 0) { $commitLines[0] } else { $null }
$branchLines = @(Invoke-GitText -Arguments @('branch', '--show-current'))
$branch = if ($branchLines.Count -gt 0) { $branchLines[0] } else { $null }
$remoteNames = @(Invoke-GitText -Arguments @('remote'))
$remotes = @()
foreach ($remoteName in $remoteNames) {
$remoteURLs = @(Invoke-GitText -Arguments @('remote', 'get-url', $remoteName))
$remotes += [ordered]@{
name = $remoteName
url_configured = $remoteURLs.Count -gt 0
}
}
$items = @($index.items)
$duplicateCodes = @($items | Group-Object code | Where-Object Count -gt 1 | ForEach-Object Name)
$actualCodes = @($items | ForEach-Object { $_.code })
$missingCodes = @($requiredCodes | Where-Object { $_ -notin $actualCodes })
$unexpectedCodes = @($actualCodes | Where-Object { $_ -notin $requiredCodes })
$validStatuses = @('PENDING', 'VERIFIED', 'WAIVED')
$invalidStatusCodes = @($items | Where-Object { $_.status -notin $validStatuses } | ForEach-Object code)
$gates = @($requiredCodes | ForEach-Object {
$code = $_
$item = @($items | Where-Object code -eq $code | Select-Object -First 1)
if ($item.Count -eq 0) {
[ordered]@{ code = $code; status = 'MISSING'; owner = ''; file_count = 0 }
} else {
[ordered]@{
code = $code
status = $item[0].status
owner = $item[0].owner
file_count = @($item[0].files).Count
}
}
})
$pendingCount = @($gates | Where-Object status -eq 'PENDING').Count
$verifiedCount = @($gates | Where-Object status -eq 'VERIFIED').Count
$waivedCount = @($gates | Where-Object status -eq 'WAIVED').Count
$requiredLocalServices = @('api', 'callbacks', 'worker', 'web', 'postgres', 'redis', 'minio', 'keycloak')
$runningServices = @()
$dockerAvailable = [bool](Get-Command docker -ErrorAction SilentlyContinue)
if ($dockerAvailable) {
$runningServices = @(& docker compose ps --services --status running 2>$null)
if ($LASTEXITCODE -ne 0) { $runningServices = @() }
}
$missingLocalServices = @($requiredLocalServices | Where-Object { $_ -notin $runningServices })
$externalConfigPrefixes = @(
'GITHUB_', 'CI_', 'KUBECONFIG', 'ARK_', 'VOLC_', 'CONTENT_SAFETY_',
'AWS_', 'AZURE_', 'GOOGLE_'
)
$externalConfigNames = @(Get-ChildItem Env: | Where-Object {
$name = $_.Name
@($externalConfigPrefixes | Where-Object { $name.StartsWith($_, [System.StringComparison]::OrdinalIgnoreCase) }).Count -gt 0
} | Sort-Object Name | ForEach-Object Name)
$indexValid = $duplicateCodes.Count -eq 0 -and $missingCodes.Count -eq 0 -and $unexpectedCodes.Count -eq 0 -and $invalidStatusCodes.Count -eq 0
$sourceControlReady = -not [string]::IsNullOrWhiteSpace($commit) -and $remotes.Count -gt 0
$entryReady = $sourceControlReady -and $indexValid -and -not (Test-PlaceholderDigest -Value $index.build_digest)
$blockers = @()
if ([string]::IsNullOrWhiteSpace($commit)) { $blockers += 'Git repository has no candidate commit.' }
if ($remotes.Count -eq 0) { $blockers += 'Git repository has no configured remote.' }
if (Test-PlaceholderDigest -Value $index.build_digest) { $blockers += 'Evidence index still uses a placeholder build digest.' }
if (-not $indexValid) { $blockers += 'Evidence index structure or statuses are invalid.' }
if ($pendingCount -gt 0) { $blockers += "$pendingCount external evidence gate(s) remain PENDING." }
$report = [ordered]@{
schema_version = 'stage8-external-preflight-v1'
generated_at = (Get-Date).ToUniversalTime().ToString('o')
repository = [ordered]@{
branch = $branch
commit = $commit
remotes = $remotes
source_control_ready = $sourceControlReady
}
local_runtime = [ordered]@{
docker_available = $dockerAvailable
required_services = $requiredLocalServices
running_service_count = $runningServices.Count
missing_required_services = $missingLocalServices
ready = $missingLocalServices.Count -eq 0
}
external_configuration = [ordered]@{
detected_variable_names = $externalConfigNames
secret_values_read = $false
note = 'Variable names are hints only and never count as acceptance evidence.'
}
evidence = [ordered]@{
index_path = $resolvedIndex
candidate_version = $index.candidate_version
build_digest = $index.build_digest
structure_valid = $indexValid
duplicate_codes = $duplicateCodes
missing_codes = $missingCodes
unexpected_codes = $unexpectedCodes
invalid_status_codes = $invalidStatusCodes
pending = $pendingCount
verified = $verifiedCount
waived = $waivedCount
gates = $gates
}
readiness = [ordered]@{
external_execution_entry_ready = $entryReady
authoritative_release_validation_required = $true
note = 'This preflight does not replace conformance reports or the release-evidence command.'
}
blockers = $blockers
}
$json = $report | ConvertTo-Json -Depth 12
if (-not [string]::IsNullOrWhiteSpace($OutputPath)) {
$parent = Split-Path -Parent $OutputPath
if ($parent -and -not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Path $parent | Out-Null
}
[System.IO.File]::WriteAllText($OutputPath, $json, [System.Text.UTF8Encoding]::new($false))
}
Write-Host "Stage 8 preflight: source_control_ready=$sourceControlReady, local_runtime_ready=$($missingLocalServices.Count -eq 0), pending_evidence=$pendingCount, entry_ready=$entryReady"
foreach ($blocker in $blockers) { Write-Host "[BLOCKED] $blocker" }
if ($OutputPath) { Write-Host "Report written to $OutputPath" }
if ($Strict -and -not $entryReady) { exit 1 }