feat(release): package runtime and operations freezes
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$EvidenceDirectory = 'output\aug1-rc\0.1.10-110-dc20b920-frozen',
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackendJar,
|
||||
|
||||
[string]$ContentCandidate = 'docs\content-candidates\aug1-life-advisor-content-candidates-v0.1.json',
|
||||
|
||||
[string]$RuntimeCommit = 'dc20b92061f10a356ed73eb105ef7b00c8b8a617',
|
||||
|
||||
[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**; manual privacy/permission, authenticated business, content and authorization 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 the user privacy choice or authenticated business acceptance.',
|
||||
'',
|
||||
'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; 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`).',
|
||||
'- 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
|
||||
manualPrivacyChoicePending = $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'
|
||||
smsOrLoginTriggered = $false
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user