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

408 lines
19 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]$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
$outputDirectoryPath = Ensure-UnderOutput (Resolve-ProjectPath $OutputDirectory)
$evidencePath = Join-Path $evidenceDirectoryPath 'release-evidence.json'
Require-File $evidencePath
Require-File $backendJarPath
Require-File $contentCandidatePath
$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
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"
}
$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)."
}
$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/capture-android-acceptance.ps1' = Join-Path $projectRoot 'scripts\capture-android-acceptance.ps1'
'operations/go-no-go.md' = $goNoGoCandidates[0].FullName
}
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 "package_path=$packagePath"
Write-Output 'package_entries=14'
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 $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
}
$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 |",
'',
'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.',
'',
'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.',
'- 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 publishable scenarios: 0',
'- Formally sourced standard answers: 0',
'',
'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.'
) -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 = 0
formallySourcedStandardAnswers = 0
}
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 14) {
throw "Release package expected 14 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 14) {
throw "ZIP verification expected 14 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')) {
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
}
}