[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 } }