Files
prop-ai-hr/scripts/package-aug1-release.ps1
T

504 lines
26 KiB
PowerShell

[CmdletBinding()]
param(
[string]$EvidenceDirectory = 'output\aug1-rc\0.1.13-113-af8af2f6-frozen',
[Parameter(Mandatory = $true)]
[string]$BackendJar,
[string]$ContentCandidate = 'docs\content-candidates\aug1-life-advisor-content-candidates-v0.1.json',
[string]$ReleaseApproval = 'docs\content-candidates\aug1-release-approval.json',
[string]$RuntimeCommit = 'af8af2f6dd169e4616a4ca0ca724e8b809502ab3',
[string]$OperationsCommit = 'HEAD',
[string]$OutputDirectory = 'output\aug1-release',
[switch]$PlanOnly
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$allowedOutputRoot = [IO.Path]::GetFullPath((Join-Path $projectRoot 'output'))
$utf8NoBom = New-Object Text.UTF8Encoding($false)
function Resolve-ProjectPath {
param([string]$Path)
if ([IO.Path]::IsPathRooted($Path)) {
return [IO.Path]::GetFullPath($Path)
}
return [IO.Path]::GetFullPath((Join-Path $projectRoot $Path))
}
function Read-Git {
param([string[]]$Arguments)
$output = & git -C $projectRoot @Arguments
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $($Arguments -join ' ')"
}
return ($output | Out-String).Trim()
}
function Get-Sha256 {
param([string]$Path)
return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant()
}
function Require-File {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Required release input is missing: $Path"
}
}
function Write-Utf8 {
param(
[string]$Path,
[string]$Content
)
[IO.File]::WriteAllText($Path, $Content, $utf8NoBom)
}
function Ensure-UnderOutput {
param([string]$Path)
$resolved = [IO.Path]::GetFullPath($Path)
$prefix = $allowedOutputRoot.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Release packaging path must stay under $allowedOutputRoot"
}
return $resolved
}
$evidenceDirectoryPath = Resolve-ProjectPath $EvidenceDirectory
$backendJarPath = Resolve-ProjectPath $BackendJar
$contentCandidatePath = Resolve-ProjectPath $ContentCandidate
$releaseApprovalPath = Resolve-ProjectPath $ReleaseApproval
$outputDirectoryPath = Ensure-UnderOutput (Resolve-ProjectPath $OutputDirectory)
$evidencePath = Join-Path $evidenceDirectoryPath 'release-evidence.json'
Require-File $evidencePath
Require-File $backendJarPath
Require-File $contentCandidatePath
Require-File $releaseApprovalPath
$runtimeCommitSha = Read-Git @('rev-parse', '--verify', "$RuntimeCommit^{commit}")
$operationsCommitSha = Read-Git @('rev-parse', '--verify', "$OperationsCommit^{commit}")
& git -C $projectRoot merge-base --is-ancestor $runtimeCommitSha $operationsCommitSha
if ($LASTEXITCODE -ne 0) {
throw "Runtime commit must be an ancestor of the operations commit: $runtimeCommitSha"
}
$worktreeStatus = Read-Git @('status', '--porcelain')
if (-not $PlanOnly -and -not [string]::IsNullOrWhiteSpace($worktreeStatus)) {
throw 'Release packaging requires a clean Git worktree.'
}
$evidence = Get-Content -Raw -Encoding UTF8 -LiteralPath $evidencePath | ConvertFrom-Json
if (-not [bool]$evidence.releaseEligible -or -not [bool]$evidence.git.clean) {
throw 'Frozen evidence is not release eligible or was produced from a dirty worktree.'
}
if ([string]$evidence.git.commit -ne $runtimeCommitSha) {
throw "Frozen evidence commit does not match runtime commit: $($evidence.git.commit)"
}
$apkPath = [IO.Path]::GetFullPath([string]$evidence.artifacts.apk.path)
$evidencePrefix = $evidenceDirectoryPath.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
if (-not $apkPath.StartsWith($evidencePrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Frozen APK path resolves outside the evidence directory.'
}
Require-File $apkPath
$apkSha256 = Get-Sha256 $apkPath
$backendSha256 = Get-Sha256 $backendJarPath
$contentSha256 = Get-Sha256 $contentCandidatePath
$releaseApprovalSha256 = Get-Sha256 $releaseApprovalPath
if ($apkSha256 -ne ([string]$evidence.artifacts.apk.sha256).ToLowerInvariant()) {
throw "Frozen APK hash mismatch: $apkSha256"
}
if ($backendSha256 -ne ([string]$evidence.artifacts.backendJarSha256).ToLowerInvariant()) {
throw "Frozen backend JAR hash mismatch: $backendSha256"
}
if ($contentSha256 -ne ([string]$evidence.contentCandidates.sha256).ToLowerInvariant()) {
throw "Content candidate hash mismatch: $contentSha256"
}
$releaseApprovalManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $releaseApprovalPath | ConvertFrom-Json
if ([string]$releaseApprovalManifest.candidateSha256 -ne $contentSha256) {
throw "Release approval candidate hash mismatch: $($releaseApprovalManifest.candidateSha256)"
}
if ([string]$releaseApprovalManifest.expectedApkSignerSha256 -ne ([string]$evidence.app.signerSha256).ToLowerInvariant()) {
throw "Release approval APK signer mismatch: $($releaseApprovalManifest.expectedApkSignerSha256)"
}
& node (Join-Path $projectRoot 'scripts\verify-aug1-formal-content.mjs') $releaseApprovalPath | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Release approval structure audit failed.'
}
$approvedScenarioCount = @($releaseApprovalManifest.scenarioApprovals.PSObject.Properties |
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$approvedQuestionCount = @($releaseApprovalManifest.policyQuestionApprovals.PSObject.Properties |
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$approvedChannelCount = @($releaseApprovalManifest.channelApprovals.PSObject.Properties |
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$approvedSignoffCount = @($releaseApprovalManifest.signoffs.PSObject.Properties |
Where-Object { [string]$_.Value.status -eq 'APPROVED' }).Count
$distributionApprovalStatus = [string]$releaseApprovalManifest.distributionApproval.status
$goNoGoCandidates = @(Get-ChildItem -LiteralPath (Join-Path $projectRoot 'docs') -File -Filter '*Android*Go-No-Go-20260728.md')
if ($goNoGoCandidates.Count -ne 1) {
throw "Expected one Android Go/No-Go document, found $($goNoGoCandidates.Count)."
}
$businessSignoffCandidates = @(
Get-ChildItem -LiteralPath (Join-Path $projectRoot 'docs') -File -Filter '*20260729.md' |
Where-Object {
$candidateText = Get-Content -Raw -Encoding UTF8 -LiteralPath $_.FullName
$candidateText.Contains('direct_president') -and
$candidateText.Contains('direct_finance') -and
$candidateText.Contains('direct_hr') -and
$candidateText.Contains('direct_audit') -and
$candidateText.Contains('direct_operations')
}
)
if ($businessSignoffCandidates.Count -ne 1) {
throw "Expected one August 1 business-content and five-channel sign-off document, found $($businessSignoffCandidates.Count)."
}
$businessSignoffPath = $businessSignoffCandidates[0].FullName
$operationSources = [ordered]@{
'operations/release-backend.sh' = Join-Path $projectRoot 'scripts\release-backend.sh'
'operations/release-preflight.sh' = Join-Path $projectRoot 'scripts\release-preflight.sh'
'operations/verify-aug1-production-api-readonly.sh' = Join-Path $projectRoot 'scripts\verify-aug1-production-api-readonly.sh'
'operations/verify-aug1-authenticated-production.sh' = Join-Path $projectRoot 'scripts\verify-aug1-authenticated-production.sh'
'operations/verify-aug1-content-candidates.mjs' = Join-Path $projectRoot 'scripts\verify-aug1-content-candidates.mjs'
'operations/verify-aug1-formal-content.mjs' = Join-Path $projectRoot 'scripts\verify-aug1-formal-content.mjs'
'operations/verify-aug1-release-readiness.sh' = Join-Path $projectRoot 'scripts\verify-aug1-release-readiness.sh'
'operations/capture-android-acceptance.ps1' = Join-Path $projectRoot 'scripts\capture-android-acceptance.ps1'
'operations/go-no-go.md' = $goNoGoCandidates[0].FullName
'operations/business-content-and-five-channel-signoff.md' = $businessSignoffPath
}
foreach ($source in $operationSources.Values) {
Require-File $source
}
$versionName = [string]$evidence.app.versionName
$versionCode = [string]$evidence.app.versionCode
$runtimeShort = $runtimeCommitSha.Substring(0, 8)
$operationsShort = $operationsCommitSha.Substring(0, 8)
$packageName = "bangdao-aug1-$versionName-$versionCode-$runtimeShort-ops$operationsShort.zip"
$packagePath = Ensure-UnderOutput (Join-Path $outputDirectoryPath $packageName)
Write-Output "package_mode=$(if ($PlanOnly) { 'read-only-plan' } else { 'build' })"
Write-Output "runtime_commit=$runtimeCommitSha"
Write-Output "operations_commit=$operationsCommitSha"
Write-Output "apk_sha256=$apkSha256"
Write-Output "backend_sha256=$backendSha256"
Write-Output "content_sha256=$contentSha256"
Write-Output "release_approval_sha256=$releaseApprovalSha256"
Write-Output "package_path=$packagePath"
Write-Output 'package_entries=20'
if ($PlanOnly) {
exit 0
}
if (Test-Path -LiteralPath $packagePath) {
throw "Release package already exists and will not be overwritten: $packagePath"
}
New-Item -ItemType Directory -Path $outputDirectoryPath -Force | Out-Null
$stagingDirectory = Ensure-UnderOutput (Join-Path $outputDirectoryPath ('.staging-' + [Guid]::NewGuid().ToString('N')))
$partialPackagePath = Ensure-UnderOutput ($packagePath + '.partial-' + $PID)
New-Item -ItemType Directory -Path $stagingDirectory | Out-Null
$apkName = "bangdao-$versionName-$versionCode-dcloud-test.apk"
$backendName = "ruoyi-admin-$runtimeShort.jar"
$generatedAt = Read-Git @('show', '-s', '--format=%cI', $operationsCommitSha)
$operationsEpoch = [int64](Read-Git @('show', '-s', '--format=%ct', $operationsCommitSha))
$entryTimestamp = [DateTimeOffset]::FromUnixTimeSeconds($operationsEpoch)
if ($entryTimestamp.Year -lt 1980) {
$entryTimestamp = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
}
try {
Copy-Item -LiteralPath $apkPath -Destination (Join-Path $stagingDirectory $apkName)
Copy-Item -LiteralPath $backendJarPath -Destination (Join-Path $stagingDirectory $backendName)
Copy-Item -LiteralPath $contentCandidatePath -Destination (Join-Path $stagingDirectory 'aug1-life-advisor-content-candidates-v0.1.json')
Copy-Item -LiteralPath $releaseApprovalPath -Destination (Join-Path $stagingDirectory 'aug1-release-approval.json')
Copy-Item -LiteralPath $evidencePath -Destination (Join-Path $stagingDirectory 'release-evidence.json')
foreach ($entry in $operationSources.GetEnumerator()) {
$destination = Join-Path $stagingDirectory ($entry.Key -replace '/', '\')
New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force | Out-Null
Copy-Item -LiteralPath $entry.Value -Destination $destination
}
$stagedGoNoGoPath = Join-Path $stagingDirectory 'operations\go-no-go.md'
$goNoGoText = Get-Content -Raw -Encoding UTF8 -LiteralPath $stagedGoNoGoPath
$goNoGoPackageLinePattern = '(?m)^\|[^\r\n]*output/aug1-release/bangdao-aug1-[^\r\n]*$'
if (-not [regex]::IsMatch($goNoGoText, $goNoGoPackageLinePattern)) {
throw 'Go/No-Go snapshot does not contain the release-package table row.'
}
$goNoGoPackageLine = "| Release package snapshot | ``output/aug1-release/$packageName``; runtime ``$runtimeShort``; operations ``$operationsShort``. ZIP byte size and SHA-256 are external verification facts because an archive cannot embed its own final hash. |"
$goNoGoText = [regex]::Replace($goNoGoText, $goNoGoPackageLinePattern, $goNoGoPackageLine, 1)
Write-Utf8 -Path $stagedGoNoGoPath -Content $goNoGoText
$readme = @(
'# Bangdao August 1 Android controlled-test release package',
'',
'## Dual-commit freeze',
'',
"- Runtime commit: $runtimeCommitSha (unchanged APK/JAR)",
"- Operations commit: $operationsCommitSha (deploy, rollback, API and Android evidence tools)",
'- Automated candidate: `releaseEligible=true`',
'- Current decision: **No-Go**; Android device permission/write/media, formal content/handler, production authorization and enterprise sign-off gates remain.',
'',
'## Core artifacts',
'',
"| File | SHA-256 |",
'|---|---|',
"| $apkName | $apkSha256 |",
"| $backendName | $backendSha256 |",
"| aug1-life-advisor-content-candidates-v0.1.json | $contentSha256 |",
"| aug1-release-approval.json | $releaseApprovalSha256 |",
'',
'The APK uses a DCloud test signer and is limited to a small controlled internal test. Admin/H5 are deferred, schema is unchanged, and content candidates must not be imported or enabled.',
'',
'## Release procedure',
'',
'Run the versioned release script from a clean checkout of the operations commit. The ZIP operations folder is an audit snapshot and must not be executed outside repository context. Run read-only plan first; deploy only after separate explicit authorization. Do not manually overwrite the JAR.',
'',
'Run the GET-only API probe before and after release, and capture read-only Android evidence after each manual step. Neither replaces Android device acceptance or formal business sign-off.',
'',
'Recheck the bundled disabled content candidates with `node operations/verify-aug1-content-candidates.mjs aug1-life-advisor-content-candidates-v0.1.json`. Complete `operations/business-content-and-five-channel-signoff.md` before enabling content or binding handlers.',
'',
'Audit business evidence with `node operations/verify-aug1-formal-content.mjs aug1-release-approval.json`. The strict command and `operations/verify-aug1-release-readiness.sh --execute` must pass before the final Go decision; the latter is read-only and verifies fixed-code mode, formal content, per-channel access/reply evidence, two active handlers per channel, immutable production scenario snapshots, and controlled-distribution approval.',
'',
'The fixed-code authenticated smoke is bundled as `operations/verify-aug1-authenticated-production.sh`. It requires the explicit `--execute` flag, selects an existing eligible APP identity without printing it, never enables or sends real SMS, and does not submit business records.',
'',
'This package does not authorize production deployment, service restart, database change, Git push or public distribution.'
) -join [Environment]::NewLine
Write-Utf8 -Path (Join-Path $stagingDirectory 'README.md') -Content ($readme + [Environment]::NewLine)
$preflightSummary = @(
'# August 1 production preflight summary',
'',
"- Generated from operations commit: $operationsCommitSha",
'- Check type: read-only deploy plan; no deployment, restart, database mutation, SMS request, login, upload or static sync.',
'- Backend deploy plan: passed for the frozen JAR.',
"- Candidate JAR SHA-256: $backendSha256",
'- Current production JAR SHA-256: `46c99cff75d21f8536a71c3c058b6437e99dbf9ff4bae3542b44471f3f34ad84`',
'- Production service: `wygj-aihr.service` active and starts the fixed JAR path.',
'- Production schema: 64/64 and runtime schema bootstrap disabled/default.',
'- Production GET-only route probe: 20/20 (`1x200`, `6x401`, `13x405`).',
'- Separate fixed-code authenticated smoke passed request/login, read-only business routes, logout, old-token rejection and re-login; application logs confirmed that no real SMS was sent.',
"- Formal approval audit: $approvedScenarioCount/5 scenarios, $approvedQuestionCount/30 policy questions, $approvedChannelCount/5 channel acceptances, $approvedSignoffCount/5 owner sign-offs, distribution $distributionApprovalStatus.",
'- Strict August 1 readiness is expected to fail closed until formal evidence and real channel handlers are complete.',
'- Backend normalized AIHR module remains mismatched until an authorized deploy.',
'- Overall result: **No-Go** pending the manual and authorization gates in `operations/go-no-go.md`.'
) -join [Environment]::NewLine
Write-Utf8 -Path (Join-Path $stagingDirectory 'production-preflight-summary.md') -Content ($preflightSummary + [Environment]::NewLine)
$contentReview = @(
'# August 1 content-candidate review notice',
'',
"- Content version: $($evidence.contentCandidates.contentVersion)",
"- Status: $($evidence.contentCandidates.candidateStatus)",
"- Scenario candidates: $($evidence.contentCandidates.scenarioCandidates)",
"- Policy-question candidates: $($evidence.contentCandidates.policyQuestionCandidates)",
"- Formally approved scenarios: $approvedScenarioCount/5",
"- Formally evidenced policy questions: $approvedQuestionCount/30",
"- Approved direct-channel acceptances: $approvedChannelCount/5",
"- Owner sign-offs: $approvedSignoffCount/5",
"- Controlled-distribution approval: $distributionApprovalStatus",
'',
'Candidates are for business review only. Do not import, enable or count them toward August 1 acceptance until formal sources and item-level sign-off are complete.',
'',
'Run `node operations/verify-aug1-content-candidates.mjs aug1-life-advisor-content-candidates-v0.1.json` after extraction, then use `operations/business-content-and-five-channel-signoff.md` for item-level review and handler assignment.',
'',
'Record only source/evidence references and hashes in `aug1-release-approval.json`; do not copy formal answer text or identities into it. Run `node operations/verify-aug1-formal-content.mjs aug1-release-approval.json` for progress and add `--strict` only after every item is signed.'
) -join [Environment]::NewLine
Write-Utf8 -Path (Join-Path $stagingDirectory 'content-review-readme.md') -Content ($contentReview + [Environment]::NewLine)
$manifest = [ordered]@{
packageFormatVersion = 2
releaseTarget = [string]$evidence.releaseTarget
profile = 'android-controlled-test'
generatedAt = $generatedAt
runtimeCommit = $runtimeCommitSha
operationsCommit = $operationsCommitSha
goNoGo = 'NO_GO'
automatedReleaseEligible = $true
android = [ordered]@{
packageName = [string]$evidence.app.packageName
versionName = $versionName
versionCode = [int]$versionCode
targetSdkVersion = [int]$evidence.app.targetSdkVersion
artifact = $apkName
sha256 = $apkSha256
signerSha256 = ([string]$evidence.app.signerSha256).ToLowerInvariant()
internalTestOnly = $true
androidDeviceAcceptancePending = $true
loginLegalInteractionOnAndroidPending = $true
}
backend = [ordered]@{
artifact = $backendName
sha256 = $backendSha256
productionSha256 = '46c99cff75d21f8536a71c3c058b6437e99dbf9ff4bae3542b44471f3f34ad84'
readOnlyDeployPlanPassed = $true
deploymentAuthorized = $false
target = 'YCWY:/opt/wygj/app/ruoyi-admin.jar'
service = 'wygj-aihr.service'
schemaChangeRequired = $false
}
productionReadOnly = [ordered]@{
schema = '64/64'
runtimeSchemaBootstrap = 'false/default'
routeProbe = '20/20: 1 public 200, 6 auth 401, 13 method 405'
deployPlanSmsOrLoginTriggered = $false
authenticatedFixedCodeSmokePassed = $true
fixedCodeNoRealSmsConfirmed = $true
}
contentCandidates = [ordered]@{
artifact = 'aug1-life-advisor-content-candidates-v0.1.json'
sha256 = $contentSha256
status = [string]$evidence.contentCandidates.candidateStatus
publishable = $false
scenarioCandidates = [int]$evidence.contentCandidates.scenarioCandidates
policyQuestionCandidates = [int]$evidence.contentCandidates.policyQuestionCandidates
formalPublishableScenarios = $approvedScenarioCount
formallySourcedStandardAnswers = $approvedQuestionCount
}
formalApproval = [ordered]@{
artifact = 'aug1-release-approval.json'
sha256 = $releaseApprovalSha256
status = [string]$releaseApprovalManifest.releaseStatus
formalContentVersion = [string]$releaseApprovalManifest.formalContentVersion
approvedScenarios = $approvedScenarioCount
approvedPolicyQuestions = $approvedQuestionCount
approvedDirectChannels = $approvedChannelCount
approvedOwnerSignoffs = $approvedSignoffCount
controlledDistributionStatus = $distributionApprovalStatus
}
operations = @($operationSources.Keys)
manualGatesRemaining = @($evidence.manualGatesRemaining)
}
$manifestJson = $manifest | ConvertTo-Json -Depth 8
Write-Utf8 -Path (Join-Path $stagingDirectory 'release-manifest.json') -Content ($manifestJson + [Environment]::NewLine)
$payloadFiles = Get-ChildItem -LiteralPath $stagingDirectory -Recurse -File |
Sort-Object { $_.FullName.Substring($stagingDirectory.Length + 1) }
$checksumLines = foreach ($file in $payloadFiles) {
$relative = $file.FullName.Substring($stagingDirectory.Length + 1).Replace('\', '/')
"$(Get-Sha256 $file.FullName) $relative"
}
Write-Utf8 -Path (Join-Path $stagingDirectory 'SHA256SUMS.txt') -Content (($checksumLines -join [Environment]::NewLine) + [Environment]::NewLine)
$allFiles = Get-ChildItem -LiteralPath $stagingDirectory -Recurse -File |
Sort-Object { $_.FullName.Substring($stagingDirectory.Length + 1) }
if ($allFiles.Count -ne 20) {
throw "Release package expected 20 files, found $($allFiles.Count)."
}
foreach ($file in $allFiles) {
$file.LastWriteTimeUtc = $entryTimestamp.UtcDateTime
}
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archiveStream = [IO.File]::Open($partialPackagePath, [IO.FileMode]::CreateNew, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
try {
$archive = New-Object IO.Compression.ZipArchive($archiveStream, [IO.Compression.ZipArchiveMode]::Create, $true)
try {
foreach ($file in $allFiles) {
$relative = $file.FullName.Substring($stagingDirectory.Length + 1).Replace('\', '/')
$entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal)
$entry.LastWriteTime = $entryTimestamp
$entryStream = $entry.Open()
$sourceStream = [IO.File]::OpenRead($file.FullName)
try {
$sourceStream.CopyTo($entryStream)
}
finally {
$sourceStream.Dispose()
$entryStream.Dispose()
}
}
}
finally {
$archive.Dispose()
}
}
finally {
$archiveStream.Dispose()
}
$verificationArchive = [IO.Compression.ZipFile]::OpenRead($partialPackagePath)
try {
if ($verificationArchive.Entries.Count -ne 20) {
throw "ZIP verification expected 20 entries, found $($verificationArchive.Entries.Count)."
}
$entryNames = @($verificationArchive.Entries | ForEach-Object { $_.FullName })
foreach ($requiredEntry in @(
$apkName,
$backendName,
'release-manifest.json',
'SHA256SUMS.txt',
'operations/release-backend.sh',
'operations/verify-aug1-authenticated-production.sh',
'operations/verify-aug1-content-candidates.mjs',
'operations/verify-aug1-formal-content.mjs',
'operations/verify-aug1-release-readiness.sh',
'aug1-release-approval.json',
'operations/business-content-and-five-channel-signoff.md'
)) {
if ($entryNames -notcontains $requiredEntry) {
throw "ZIP verification is missing $requiredEntry"
}
}
$expectedEntryHashes = @{}
foreach ($checksumLine in $checksumLines) {
if ($checksumLine -notmatch '^([0-9a-f]{64}) (.+)$') {
throw "Invalid generated checksum line: $checksumLine"
}
$expectedEntryHashes[$Matches[2]] = $Matches[1]
}
foreach ($entry in $verificationArchive.Entries) {
if ($entry.FullName -eq 'SHA256SUMS.txt') {
continue
}
if (-not $expectedEntryHashes.ContainsKey($entry.FullName)) {
throw "ZIP verification has no expected hash for $($entry.FullName)"
}
$entryStream = $entry.Open()
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
$hashBytes = $sha256.ComputeHash($entryStream)
$actualEntryHash = ([BitConverter]::ToString($hashBytes)).Replace('-', '').ToLowerInvariant()
}
finally {
$sha256.Dispose()
$entryStream.Dispose()
}
if ($actualEntryHash -ne $expectedEntryHashes[$entry.FullName]) {
throw "ZIP entry hash mismatch: $($entry.FullName)"
}
}
}
finally {
$verificationArchive.Dispose()
}
[IO.File]::Move($partialPackagePath, $packagePath)
Write-Output "package_bytes=$((Get-Item -LiteralPath $packagePath).Length)"
Write-Output "package_sha256=$(Get-Sha256 $packagePath)"
Write-Output 'package_verified=true'
}
finally {
if (Test-Path -LiteralPath $partialPackagePath -PathType Leaf) {
Remove-Item -LiteralPath $partialPackagePath -Force
}
if (Test-Path -LiteralPath $stagingDirectory -PathType Container) {
$resolvedStaging = [IO.Path]::GetFullPath($stagingDirectory)
$outputPrefix = $allowedOutputRoot.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar
if (-not $resolvedStaging.StartsWith($outputPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to clean unsafe staging directory: $resolvedStaging"
}
Remove-Item -LiteralPath $resolvedStaging -Recurse -Force
}
}