feat(release): package runtime and operations freezes

This commit is contained in:
key
2026-07-29 03:38:19 +08:00
parent 2d5568f94b
commit 861fa0ea76
3 changed files with 453 additions and 0 deletions
+9
View File
@@ -366,6 +366,15 @@ Android 人工验收每完成一个步骤,用 `.\scripts\capture-android-accep
./scripts/verify-aug1-production-api-readonly.sh
```
冻结运行时产物后,如发布/回滚或验收工具继续修正,不要重编同版本 APK/JAR冒充新运行时。使用双提交打包器保留 `dc20b920` 运行时提交,并把当前干净 `HEAD` 作为 operations commit;新包名同时包含两者短哈希,旧 ZIP 不覆盖:
```powershell
.\scripts\package-aug1-release.ps1 `
-BackendJar .\output\aug1-release\0.1.10-110-dc20b920\ruoyi-admin-dc20b920.jar
```
打包器重新校验冻结证据、APK/JAR/内容哈希和提交祖先关系,按固定顺序与时间戳生成 14 个文件的 ZIP,并附带发布、预检、API 探针、Android 取证及最新 Go/No-Go 的审计快照。ZIP 内 operations 脚本用于交付审计;实际执行仍须从对应 operations commit 的干净仓库运行。
## MVP 页面验证
登录后侧栏应只展示以下入口:
+403
View File
@@ -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
}
}
@@ -0,0 +1,41 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$scriptPath = Join-Path $projectRoot 'scripts\package-aug1-release.ps1'
$devSetupPath = Join-Path $projectRoot 'docs\DEV_SETUP.md'
$source = Get-Content -Raw -Encoding UTF8 -LiteralPath $scriptPath
$requiredFragments = @(
'Runtime commit must be an ancestor of the operations commit',
'Frozen evidence is not release eligible',
'Frozen APK hash mismatch',
'Frozen backend JAR hash mismatch',
'Content candidate hash mismatch',
"'operations/release-backend.sh'",
"'operations/release-preflight.sh'",
"'operations/verify-aug1-production-api-readonly.sh'",
"'operations/capture-android-acceptance.ps1'",
"'operations/go-no-go.md'",
'Release package already exists and will not be overwritten',
'Release package expected 14 files',
'ZIP entry hash mismatch',
'package_verified=true'
)
foreach ($fragment in $requiredFragments) {
if (-not $source.Contains($fragment)) {
throw "August 1 packager is missing required fragment: $fragment"
}
}
if ($source -match 'Compress-Archive') {
throw 'August 1 packager must use sorted entries and a fixed timestamp, not Compress-Archive.'
}
if ($source -match '\[switch\]\$Force') {
throw 'August 1 packager must not expose an overwrite switch.'
}
$devSetup = Get-Content -Raw -Encoding UTF8 -LiteralPath $devSetupPath
if (-not $devSetup.Contains('.\scripts\package-aug1-release.ps1')) {
throw 'DEV_SETUP is missing the August 1 release packaging command.'
}
Write-Output 'PASS: August 1 release packager freezes runtime and operations separately without overwriting prior packages'