179 lines
7.0 KiB
PowerShell
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 }
|