255 lines
11 KiB
PowerShell
255 lines
11 KiB
PowerShell
[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-legal')
|
|
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, login legal links, no native privacy prompt, signature, content match and sensitive-value scan'
|
|
)
|
|
}
|
|
manualGatesRemaining = @(
|
|
'Android login legal links and unchecked-consent failure',
|
|
'Android microphone allow and deny',
|
|
'Android media chooser cancellation',
|
|
'Android foreground/background and network recovery',
|
|
'authenticated Android write and media paths after content and handler sign-off',
|
|
'formal business content and five-channel handler sign-off',
|
|
'production backend authorization, deploy match and rollback sign-off',
|
|
'company legal, enterprise signer and controlled-distribution 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.'
|
|
}
|