diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 4aa5e670..6428068f 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -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 页面验证 登录后侧栏应只展示以下入口: diff --git a/scripts/package-aug1-release.ps1 b/scripts/package-aug1-release.ps1 new file mode 100644 index 00000000..da95b8ad --- /dev/null +++ b/scripts/package-aug1-release.ps1 @@ -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 + } +} diff --git a/scripts/tests/package-aug1-release.test.ps1 b/scripts/tests/package-aug1-release.test.ps1 new file mode 100644 index 00000000..6c3af3ea --- /dev/null +++ b/scripts/tests/package-aug1-release.test.ps1 @@ -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'