[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 }