scripts/release-app.ps1
Rama main · réplica de lectura del repositorio original.
# =====================================================
# TapLedger APP 一键发布脚本(默认完整发布流程)
# 用法(任意目录):
# powershell -ExecutionPolicy Bypass -File scripts\release-app.ps1 # 自动 patch +1
# powershell -ExecutionPolicy Bypass -File scripts\release-app.ps1 -Version 1.0.0 # 指定版本
# 可选 -CommitMsg "feat: xxx" 自定义提交信息(默认 chore: 发布 vX.Y.Z)
# 前置:
# - Node / Android SDK(gradlew + aapt)/ GitHub CLI(gh) 已登录
# - android/ 已存在(首次需 npx expo prebuild --platform android)
# - CHANGELOG.md 已包含目标版本条目(## [X.Y.Z])
# 流程:
# 版本号同步(app.json/package.json/package-lock.json/build.gradle/README)
# → 单测 + 类型检查 → 本地构建 APK → aapt 校验 → 复制 APK 到根目录
# → git 提交推送 → 创建 GitHub Release 并上传 APK(说明取自 CHANGELOG)
# =====================================================
param(
[string]$Version = '',
[string]$CommitMsg = ''
)
$ErrorActionPreference = 'Stop'
$utf8 = New-Object System.Text.UTF8Encoding($false)
function Fail([string]$msg) { Write-Host "[错误] $msg" -ForegroundColor Red; exit 1 }
$root = Split-Path -Parent $PSScriptRoot
Push-Location $root
try {
# ---------- 0. 解析目标版本 ----------
$appJson = Get-Content 'app.json' -Raw -Encoding UTF8 | ConvertFrom-Json
$current = $appJson.expo.version
if (-not $Version) {
# 默认:补丁位 +1
$parts = $current.Split('.')
$parts[2] = [string]([int]$parts[2] + 1)
$Version = $parts -join '.'
}
if ($Version -notmatch '^\d+\.\d+\.\d+$') { Fail "版本号格式错误:$Version(应为 X.Y.Z)" }
$vp = $Version.Split('.')
$versionCode = [int]$vp[0] * 10000 + [int]$vp[1] * 100 + [int]$vp[2]
if ([int]$vp[1] -ge 100 -or [int]$vp[2] -ge 100) { Fail "minor/patch 必须 < 100" }
Write-Host "==> 发布 v$Version(当前 $current,versionCode $versionCode)" -ForegroundColor Cyan
# ---------- 1. CHANGELOG 校验(说明来源,缺失则中止) ----------
$changelogPath = Join-Path $root 'CHANGELOG.md'
$changelogRaw = [System.IO.File]::ReadAllText($changelogPath, $utf8)
if ($changelogRaw -notmatch ('## \[' + [regex]::Escape($Version) + '\]')) {
Fail "CHANGELOG.md 缺少 [$Version] 条目,请先补充(## [版本] - 日期)再发布。"
}
# ---------- 2. 版本号同步 ----------
Write-Host "==> 同步版本号:app.json / package.json / package-lock.json" -ForegroundColor Cyan
& node (Join-Path $PSScriptRoot 'sync-version.js') $Version
if ($LASTEXITCODE -ne 0) { Fail "版本号同步失败。" }
Write-Host "==> 同步 android/app/build.gradle(versionCode $versionCode / versionName $Version)" -ForegroundColor Cyan
$gradlePath = Join-Path $root 'android\app\build.gradle'
if (-not (Test-Path $gradlePath)) { Fail "未找到 android\app\build.gradle,请先执行 npx expo prebuild --platform android" }
$g = [System.IO.File]::ReadAllText($gradlePath, $utf8)
$g = $g -replace 'versionCode\s+\d+', "versionCode $versionCode"
$g = $g -replace 'versionName\s+"[\d.]+"', "versionName `"$Version`""
[System.IO.File]::WriteAllText($gradlePath, $g, $utf8)
Write-Host "==> 同步 README 版本行" -ForegroundColor Cyan
$readmePath = Join-Path $root 'README.md'
$readmeRaw = [System.IO.File]::ReadAllText($readmePath, $utf8)
$readmeRaw = $readmeRaw -replace '当前版本:[\d.]+', "当前版本:$Version"
[System.IO.File]::WriteAllText($readmePath, $readmeRaw, $utf8)
# ---------- 3. 单测 + 类型检查 + Lint ----------
Write-Host "==> vitest + tsc + eslint" -ForegroundColor Cyan
& npx vitest run
if ($LASTEXITCODE -ne 0) { Fail "单测未通过。" }
& npx tsc --noEmit
if ($LASTEXITCODE -ne 0) { Fail "类型检查未通过。" }
& npx eslint .
if ($LASTEXITCODE -ne 0) { Fail "ESLint 未通过。" }
& npx prettier --check "src/**/*.{ts,tsx}" "server/src/**/*.ts" | Out-Null
if ($LASTEXITCODE -ne 0) { Fail "Prettier 格式检查未通过(先跑 npm run format)。" }
# ---------- 3.5 release 签名注入自检(v0.11.8)----------
# 只看构建产物会被 apksigner 拦住,但那时已经白跑几分钟 Gradle;
# 这里先确认 build.gradle 里插件的三段注入都在,缺任何一段就直接失败。
$gCheck = [System.IO.File]::ReadAllText($gradlePath, $utf8)
foreach ($needle in @(
'tapledgerKeystorePropsFile = rootProject',
'storeFile file(tapledgerKeystoreProps',
'signingConfig tapledgerKeystorePropsFile.exists() ? signingConfigs.release : signingConfigs.debug')) {
if ($gCheck -notlike "*$needle*") {
Fail "android/app/build.gradle 缺少 release 签名注入片段:$needle`n请确认 keystore.properties 存在后重新 npx expo prebuild --platform android"
}
}
if (-not (Test-Path (Join-Path $root 'keystore.properties'))) { Fail "缺少 keystore.properties,禁止发布。" }
# ---------- 4. 本地构建 release APK ----------
Write-Host "==> gradlew assembleRelease" -ForegroundColor Cyan
Push-Location (Join-Path $root 'android')
try { & .\gradlew.bat assembleRelease --console=plain }
finally { Pop-Location }
if ($LASTEXITCODE -ne 0) { Fail "APK 构建失败。" }
$apkPath = Join-Path $root 'android\app\build\outputs\apk\release\app-release.apk'
if (-not (Test-Path $apkPath)) { Fail "未找到构建产物:$apkPath" }
# ---------- 5. aapt 校验 versionName / versionCode ----------
$aapt = Get-ChildItem "$env:LOCALAPPDATA\Android\Sdk\build-tools" -Recurse -Filter aapt.exe -ErrorAction SilentlyContinue |
Sort-Object DirectoryName -Descending | Select-Object -First 1 -ExpandProperty FullName
if (-not $aapt) { Fail "未找到 aapt.exe(Android SDK build-tools),无法校验版本号。" }
$badgingOut = & $aapt dump badging $apkPath
$pkgLine = ($badgingOut | Where-Object { $_ -match '^package:' }) -join ' '
Write-Host "aapt: $pkgLine" -ForegroundColor Gray
if ($pkgLine -notmatch [regex]::Escape("versionName='$Version'")) { Fail "versionName 校验失败(期望 $Version)" }
if ($pkgLine -notmatch [regex]::Escape("versionCode='$versionCode'")) { Fail "versionCode 校验失败(期望 $versionCode)" }
# ---------- 5.5 apksigner 校验签名者(v0.11:禁止 debug 签名的 release 包发布) ----------
$apksigner = Get-ChildItem "$env:LOCALAPPDATA\Android\Sdk\build-tools" -Recurse -Filter apksigner.bat -ErrorAction SilentlyContinue |
Sort-Object DirectoryName -Descending | Select-Object -First 1 -ExpandProperty FullName
if (-not $apksigner) { Fail "未找到 apksigner.bat(Android SDK build-tools),无法校验签名。" }
$certOut = & $apksigner verify --print-certs $apkPath 2>&1 | Out-String
Write-Host "签名: $(($certOut -split "`n" | Where-Object { $_ -match 'certificate DN' }) -join ' ')" -ForegroundColor Gray
if ($certOut -match 'CN=Android Debug') {
Fail "APK 使用 debug keystore 签名,禁止发布!请确认 keystore.properties 与 keystore/ 目录存在后重新 prebuild。"
}
# ---------- 6. 复制 APK 到根目录 ----------
$apkDest = Join-Path $root "TapLedger-v$Version.apk"
Copy-Item $apkPath $apkDest -Force
$mb = [math]::Round((Get-Item $apkDest).Length / 1MB, 1)
Write-Host "==> APK 就绪:TapLedger-v$Version.apk($mb MB)" -ForegroundColor Green
# ---------- 7. git 提交推送 ----------
# 只暂存发布链路真正改到的文件:git add -A 会把工作区里无关的半成品/意外改动
# 一起卷进 release 提交(本次审查就撞到一个:一次编码事故被 -A 顺手提交的风险)
Write-Host "==> git 提交推送" -ForegroundColor Cyan
# 注意:android/ 整个目录在 .gitignore 里(expo prebuild 产物,版本号只在本地的
# build.gradle 里生效),把它列进 pathspec 会让 git add 直接以非零码退出「ignored
# paths」——v0.11.12 就是被这一步卡住的。上面第 2 步仍然要写 build.gradle,
# 但那是本地构建输入,不是提交内容。
$stagePaths = @(
'app.json', 'package.json', 'package-lock.json', 'server/package-lock.json',
'README.md', 'CHANGELOG.md',
'src', 'server/src', 'App.tsx', 'plugins', 'scripts'
)
& git add -- $stagePaths
if ($LASTEXITCODE -ne 0) { Fail "git add 失败。" }
& git status --short
if (-not $CommitMsg) { $CommitMsg = "chore: 发布 v$Version" }
& git commit -m $CommitMsg
if ($LASTEXITCODE -ne 0) { Fail "git commit 失败(可能无可提交改动)。" }
& git push
if ($LASTEXITCODE -ne 0) { Fail "git push 失败。" }
# ---------- 8. GitHub Release + 上传 APK ----------
Write-Host "==> gh release create v$Version" -ForegroundColor Cyan
# Release 说明 = CHANGELOG 中本版本条目正文
# v0.11 修复:改用 --notes-file(经验教训:--notes 传含 [x] 的内容会被 PowerShell 通配符干扰)
$pattern = '(?s)## \[' + [regex]::Escape($Version) + '\] - [^\r\n]*\r?\n(.*?)(?=\r?\n## \[|$)'
$m = [regex]::Match($changelogRaw, $pattern)
$notes = if ($m.Success) { $m.Groups[1].Value.Trim() } else { "详见 CHANGELOG.md" }
$notesFile = Join-Path ([System.IO.Path]::GetTempPath()) "tapledger-release-notes-$Version.md"
[System.IO.File]::WriteAllText($notesFile, $notes, $utf8)
try {
& gh release create "v$Version" --repo sctale/TapLedger --title "v$Version" --target main --notes-file $notesFile $apkDest
if ($LASTEXITCODE -ne 0) { Fail "GitHub Release 创建失败。" }
} finally {
Remove-Item $notesFile -ErrorAction SilentlyContinue
}
Write-Host ""
Write-Host "发布完成:v$Version" -ForegroundColor Green
Write-Host " Release: https://github.com/sctale/TapLedger/releases/tag/v$Version" -ForegroundColor Cyan
}
finally {
Pop-Location
}