[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ApkPath, [ValidateSet('dcloud-test', 'release')] [string]$BuildKind = 'dcloud-test', [string]$ExpectedSignerSha256, [string]$TermsUrl = 'https://peilian.njzhmj.top/legal/terms.html', [string]$PrivacyUrl = 'https://peilian.njzhmj.top/legal/privacy.html', [switch]$SkipBuild, [switch]$AllowDirtyRehearsal ) $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent $PSScriptRoot Set-Location -LiteralPath $projectRoot function Invoke-Checked { param( [Parameter(Mandatory = $true)] [string]$FilePath, [string[]]$Arguments = @() ) & $FilePath @Arguments if ($LASTEXITCODE -ne 0) { throw "Command failed with exit code $LASTEXITCODE`: $FilePath $($Arguments -join ' ')" } } function Read-Git { param([string[]]$Arguments) $output = & git @Arguments if ($LASTEXITCODE -ne 0) { throw "git command failed: git $($Arguments -join ' ')" } return ($output | Out-String).Trim() } $worktreeStatus = Read-Git @('status', '--porcelain') $isDirty = -not [string]::IsNullOrWhiteSpace($worktreeStatus) if ($isDirty -and -not $AllowDirtyRehearsal) { throw 'RC preparation requires a clean worktree. Commit the reviewed release batch first.' } if ($BuildKind -eq 'release' -and [string]::IsNullOrWhiteSpace($ExpectedSignerSha256)) { throw 'Enterprise release verification requires -ExpectedSignerSha256.' } $resolvedApkPath = (Resolve-Path -LiteralPath $ApkPath).Path $manifestPath = Join-Path $projectRoot 'mobile-uni\src\manifest.json' $packagePath = Join-Path $projectRoot 'mobile-uni\package.json' $manifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $manifestPath | ConvertFrom-Json $package = Get-Content -Raw -Encoding UTF8 -LiteralPath $packagePath | ConvertFrom-Json if ([string]$manifest.versionName -ne [string]$package.version) { throw "mobile-uni package version $($package.version) does not match manifest version $($manifest.versionName)." } $commit = Read-Git @('rev-parse', 'HEAD') $shortCommit = Read-Git @('rev-parse', '--short=8', 'HEAD') $upstreamDelta = Read-Git @('rev-list', '--left-right', '--count', 'HEAD...origin/main') Invoke-Checked -FilePath 'git' -Arguments @('diff', '--check') $contentCandidatePath = Join-Path $projectRoot 'docs\content-candidates\aug1-life-advisor-content-candidates-v0.1.json' $contentCandidateVerifier = Join-Path $projectRoot 'scripts\verify-aug1-content-candidates.mjs' Invoke-Checked -FilePath 'node' -Arguments @($contentCandidateVerifier) $contentCandidate = Get-Content -Raw -Encoding UTF8 -LiteralPath $contentCandidatePath | ConvertFrom-Json $env:VITE_APP_TERMS_URL = $TermsUrl $env:VITE_APP_PRIVACY_URL = $PrivacyUrl Remove-Item Env:VITE_DEV_SMS_HINT_ENABLED -ErrorAction SilentlyContinue Remove-Item Env:VITE_DEV_SMS_HINT_VALUE -ErrorAction SilentlyContinue if (-not $SkipBuild) { Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'test:unit') Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'typecheck') Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'configure:app-privacy') Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'build:h5') Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'build:app') Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'mobile-uni', 'run', 'verify:app-release') Invoke-Checked -FilePath 'npm' -Arguments @('--prefix', 'frontend', 'run', 'build:prod') Push-Location (Join-Path $projectRoot 'backend') try { Invoke-Checked -FilePath 'mvn' -Arguments @( '-pl', 'ruoyi-modules/ruoyi-aihr', '-am', '-DskipTests=false', 'test' ) Invoke-Checked -FilePath 'mvn' -Arguments @( '-pl', 'ruoyi-admin', '-am', '-DskipTests', 'package' ) } finally { Pop-Location } } $postBuildWorktreeStatus = Read-Git @('status', '--porcelain') $postBuildDirty = -not [string]::IsNullOrWhiteSpace($postBuildWorktreeStatus) if (-not $isDirty -and $postBuildDirty) { throw 'RC build changed the previously clean worktree. Review and commit or revert generated changes before release.' } $apkVerifier = Join-Path $projectRoot 'mobile-uni\scripts\verify-android-apk.ps1' $verifyArguments = @( '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $apkVerifier, '-ApkPath', $resolvedApkPath, '-BuildKind', $BuildKind, '-ExpectedTermsUrl', $TermsUrl, '-ExpectedPrivacyUrl', $PrivacyUrl ) if (-not [string]::IsNullOrWhiteSpace($ExpectedSignerSha256)) { $verifyArguments += @('-ExpectedSignerSha256', $ExpectedSignerSha256) } $apkVerificationOutput = & powershell @verifyArguments 2>&1 $apkVerificationOutput | ForEach-Object { Write-Output $_ } if ($LASTEXITCODE -ne 0) { throw 'Android APK verification failed.' } $apkVerificationText = ($apkVerificationOutput | Out-String) $signerMatch = [regex]::Match($apkVerificationText, 'signer SHA-256:\s*([0-9A-Fa-f]+)') $cleartextMatch = [regex]::Match($apkVerificationText, 'usesCleartextTraffic:\s*(True|False)') if (-not $signerMatch.Success -or -not $cleartextMatch.Success) { throw 'APK verifier output is missing signer or cleartext metadata.' } $versionName = [string]$manifest.versionName $versionCode = [string]$manifest.versionCode $releaseEligible = -not $isDirty -and -not $postBuildDirty -and -not $SkipBuild $modeSuffix = if ($releaseEligible) { 'frozen' } else { 'rehearsal' } $evidenceDirectory = Join-Path $projectRoot "output\aug1-rc\$versionName-$versionCode-$shortCommit-$modeSuffix" New-Item -ItemType Directory -Path $evidenceDirectory -Force | Out-Null $artifactName = "bangdao-$versionName-$versionCode-$shortCommit-$BuildKind.apk" $artifactPath = Join-Path $evidenceDirectory $artifactName Copy-Item -LiteralPath $resolvedApkPath -Destination $artifactPath -Force $frontendIndex = Join-Path $projectRoot 'frontend\dist\index.html' $mobileIndex = Join-Path $projectRoot 'mobile-uni\dist\build\h5\index.html' $backendJar = Join-Path $projectRoot 'backend\ruoyi-admin\target\ruoyi-admin.jar' foreach ($requiredArtifact in @($frontendIndex, $mobileIndex, $backendJar)) { if (-not (Test-Path -LiteralPath $requiredArtifact)) { throw "Required release artifact is missing: $requiredArtifact" } } $rehearsalReasons = @() if ($isDirty) { $rehearsalReasons += 'worktree contains uncommitted changes' } if ($SkipBuild) { $rehearsalReasons += 'full build and tests were skipped' } $evidence = [ordered]@{ generatedAt = (Get-Date).ToUniversalTime().ToString('o') releaseTarget = '2026-08-01 Android controlled internal test' releaseEligible = $releaseEligible rehearsalReasons = $rehearsalReasons git = [ordered]@{ commit = $commit upstreamDelta = $upstreamDelta clean = -not $postBuildDirty } app = [ordered]@{ packageName = [string]$manifest.'app-plus'.distribute.android.packagename versionName = $versionName versionCode = $versionCode targetSdkVersion = [int]$manifest.'app-plus'.distribute.android.targetSdkVersion buildKind = $BuildKind signerSha256 = $signerMatch.Groups[1].Value.ToUpperInvariant() usesCleartextTraffic = [bool]::Parse($cleartextMatch.Groups[1].Value) legalUrls = @($TermsUrl, $PrivacyUrl) } artifacts = [ordered]@{ apk = [ordered]@{ path = $artifactPath bytes = (Get-Item -LiteralPath $artifactPath).Length sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPath).Hash } frontendIndexSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $frontendIndex).Hash mobileH5IndexSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $mobileIndex).Hash backendJarSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $backendJar).Hash } contentCandidates = [ordered]@{ contentVersion = [string]$contentCandidate.contentVersion sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $contentCandidatePath).Hash candidateStatus = [string]$contentCandidate.candidateStatus publishable = [bool]$contentCandidate.publishable scenarioCandidates = @($contentCandidate.scenarios).Count policyQuestionCandidates = @($contentCandidate.policyQuestionCandidates).Count formalPublishableScenarios = 0 formallySourcedStandardAnswers = 0 } automatedGates = if ($SkipBuild) { @( 'content candidate safety validation', 'artifact-only rehearsal; review prior build evidence separately' ) } else { @( 'content candidate safety validation', 'mobile unit tests', 'mobile typecheck', 'mobile H5 build', 'mobile App build', 'App release asset and sensitive-value scan', 'frontend production build', 'ruoyi-aihr full test suite', 'backend package', 'APK metadata, legal links, signature, content match and sensitive-value scan' ) } manualGatesRemaining = @( 'privacy reject and consent choices', 'login error and re-login', 'microphone allow and deny', 'media cancellation', 'foreground/background and network recovery', 'authenticated search, practice, ask and five direct-feedback channels', 'business content and authorization sign-off', 'release-unit and rollback sign-off' ) } $jsonPath = Join-Path $evidenceDirectory 'release-evidence.json' $markdownPath = Join-Path $evidenceDirectory 'release-evidence.md' $evidence | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 @( '# Bangdao August 1 Android RC evidence', '', "- Generated at: $($evidence['generatedAt'])", "- Release eligible: $releaseEligible", "- Git commit: $commit", "- Clean worktree: $(-not $postBuildDirty)", "- App: $($evidence['app']['packageName']) $versionName ($versionCode), API $($evidence['app']['targetSdkVersion'])", "- APK: $artifactName", "- APK SHA-256: $($evidence['artifacts']['apk']['sha256'])", "- Signer SHA-256: $($evidence['app']['signerSha256'])", "- Cleartext traffic allowed: $($evidence['app']['usesCleartextTraffic'])", '', '## Manual gates remaining', '', ($evidence.manualGatesRemaining | ForEach-Object { "- $_" }) ) | Set-Content -LiteralPath $markdownPath -Encoding UTF8 Write-Output "RC evidence written to $evidenceDirectory" if (-not $releaseEligible) { Write-Warning 'This is a rehearsal and cannot be distributed as the August 1 RC.' }