写字台(xiezitai)是自托管的写作与博客平台,移动端用 Flutter 一份代码同时出 iOS 和 Android。这篇文章只讲 Android 这一侧:没有 Mac、不装 Android Studio、只靠 GitHub 的 Linux runner,怎么把 .aab 稳定构建出来、签好名、自动送到 Google Play。
不讲概念,只讲踩过的地方。
很多人第一次做 Android 发布,会把这两件事混成一件事,结果是:CI 明明是绿的,产物却传不上 Play。
flutter build appbundle --release 能产出文件。哪怕用 debug 签名,它也能跑通。所以写字台的 Android 侧刻意做成两条独立流水线,而不是一条带条件判断的臃肿流水线:
| 流水线 | 文件 | 触发 | 签名 | 目的 |
|---|---|---|---|---|
| 验证构建 | android.yml |
push main / PR / 手动 | debug key 兜底 | 证明代码能编译、analyze 和 test 能过 |
| 签名发布 | android-release.yml |
打 v* tag / 手动 |
上传密钥 | 产出可上架的 AAB,并推 Play 内部测试轨道 |
这个拆分的直接收益是:日常提交不需要碰任何密钥。发布流水线才还原密钥,日常 PR 永远不会因为「没配 Secret」而红。
push / PR ──► android.yml ──► analyze + test + release AAB(debug 签名) ──► artifact
tag v* / 手动 ──► android-release.yml
├─ 还原 keystore(来自 Secrets)
├─ release AAB(上传密钥签名)
├─ release APK(split-per-abi,侧载分发用)
├─ 上传 Google Play internal 轨道
└─ 删除 keystore(if: always)
关键在于 android/app/build.gradle 里的一小段分支逻辑:没有 key.properties 就自动退回 debug 签名,而不是直接抛错。
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
def useReleaseSigning = false
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
useReleaseSigning = true
}
// ...
buildTypes {
release {
signingConfig = useReleaseSigning ? signingConfigs.release : signingConfigs.debug
minifyEnabled false
shrinkResources false
}
}
这几行的价值在上线节奏上体现得很明显:新同事 clone 仓库、flutter build appbundle --release,第一次就能跑通;想看真实签名产物,才需要配密钥。不存在「必须先找运维要密钥才能编译」的阻塞。
代价是必须自己记住一句话:debug 签名的 release 包,永远不能上传 Play。这句话我直接写进了 workflow 的头部注释里,避免半年后有人图省事拿验证产物去发布。
这是整篇文章里我最想强调的部分。Android 的签名密钥泄漏后果比 API Key 严重得多——拿到 .jks 和密码的人可以给你的应用签任何内容,而 Play 只认这一个证书。
四个核心 Secret:
| Secret 名 | 内容 | 用途 |
|---|---|---|
KEYSTORE_BASE64 |
上传密钥库的完整 base64 | 还原成 .jks 文件 |
KEYSTORE_PASSWORD |
store 密码 | 解库 |
KEY_ALIAS |
密钥别名 | 指定用哪把钥匙 |
KEY_PASSWORD |
key 密码 | 解锁 |
发布流水线在 runner 上把它们还原成临时文件:
- name: 还原签名密钥(Keystore + key.properties)
env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
set -euo pipefail
printf '%s' "$KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
printf 'storePassword=%s\nkeyPassword=%s\nkeyAlias=%s\nstoreFile=upload-keystore.jks\n' \
"$KEYSTORE_PASSWORD" "$KEY_PASSWORD" "$KEY_ALIAS" > android/key.properties
注意这里是 printf 而不是 echo,且密码通过 env 注入而非命令参数——命令参数会出现在 runner 的进程列表和调试日志里。
.gitignore 是最后一道闸,不是唯一一道android/key.properties
**/*.jks
光靠 gitignore 不够,因为它只拦「误提交」,不拦「误打包」。真正兜底的是第三步。
- name: 清理密钥
if: always()
run: rm -f android/app/upload-keystore.jks android/key.properties
if: always() 是这里唯一不能省的东西。如果构建中途失败、后面的清理步骤被跳过,密钥文件就会留在 runner 的工作目录里——虽然 GitHub 托管的 runner 是一次性的,但如果哪天换成 self-hosted runner,这就是一个长期后门。
缺 Secret 时最难受的不是报错,而是报错看不懂。所以还原密钥前先做一次显式检查:
if [ -z "$KEYSTORE_BASE64" ] || [ -z "$KEY_ALIAS" ] || [ -z "$KEYSTORE_PASSWORD" ] || [ -z "$KEY_PASSWORD" ]; then
echo "::error::缺少签名 Secret,需要 KEYSTORE_BASE64 / KEYSTORE_PASSWORD / KEY_ALIAS / KEY_PASSWORD"
exit 1
fi
一句话换掉半小时排查,非常划算。
从 0 到第一次成功,发布流水线一共跑了 7 次,前 6 次全部失败。这些坑很典型,基本每个 Flutter 项目都会撞上至少一个。
现象很唬人:job 直接 failure,连一个 step 都没跑起来,日志里什么都没有。
根因不在 Flutter 代码,在版本矩阵。Flutter 3.47.6 对 Android Gradle Plugin(AGP)、Kotlin 插件、Gradle wrapper 有最低版本要求,而这三者是绑在一起的——升一个必须升另外两个,否则 Gradle 在配置阶段就崩。
当时的分三步修法:
教训:本地 Flutter 版本和 CI 不一致时,先怀疑 Gradle 插件矩阵,而不是代码。写字台现在所有 workflow 都把 FLUTTER_VERSION 钉死在环境变量里,两侧必须一致——版本漂移带来的排查成本远超收益。
startup_failure 和 failure 是两种病有一个 run 的结论是 startup_failure,不是 failure。它意味着 workflow 文件本身没解析通过——比如引用了一个不存在的 secrets.* 表达式、或者 YAML 结构有问题。
这种情况不会产生 job,所以去「日志」里找原因是白找,得回到 workflow 文件本身看。
这条值得单独说,因为它是静默到编译期才炸的典型。
现象:debug 构建正常,flutter build appbundle --release 必挂,报的是 aapt2 链接错误,指向 mipmap-anydpi-v26/ic_launcher.xml。
根因:那个 xml 里声明了前景层 @mipmap/ic_launcher_foreground,但这张图从来就没进过仓库——当时只提交了 xml 和背景色定义。debug 构建不会严格校验这条引用,release 链接期才会。
修复不是「随便塞张图」,而是想清楚自适应图标的机制:Android 8.0 起,系统会把「前景层 + 背景层」按启动器自己的形状动态遮罩裁切。如果直接把一张已经烤好圆角、带渐变背景的成品方图当图使用,启动器会再套一层自己的遮罩,结果是白底里再套一个白圆角方块、渐变被切成方块——各家 ROM 表现还不一样。
正解是分成两层,并把前景的 inset 显式设为 0:
flutter_launcher_icons:
image_path: "assets/icon/app_icon.png"
android: true
adaptive_icon_background: "assets/icon/app_icon_background.png"
adaptive_icon_foreground: "assets/icon/app_icon_foreground.png"
adaptive_icon_foreground_inset: 0
adaptive_icon_foreground_inset 默认是 16,会把前景再缩到 68%,卡片从画布 59% 掉到 40%,跟 iOS 图标完全对不上。这个默认值坑了我一次,第一次生成完看 xml 才发现。
顺带一个副作用要留意:重跑生成器会同时重写 iOS 的 AppIcon。同源同尺寸的情况下字节一致、git 无 diff,但换源图时一定要 diff 确认,别误伤 iOS 那条线。
一开始我在 workflow 里只写 flutter build appbundle,绿了就以为完事。实际上还缺三样东西,任何一样缺了都等于没做完:
apksigner verify,确认用的不是 debug key)。构建产物有了,剩下的是把它交出去。写字台用的是 r0adkll/upload-google-play 这个 action,直接推到 internal 轨道:
- name: 上传到 Google Play(内部测试轨道)
if: ${{ steps.check_play_secret.outputs.has_play_store_secret == 'true' }}
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
packageName: cn.xiezitai.app
releaseFiles: build/app/outputs/bundle/release/*.aab
track: internal
status: completed
前面那个 if 是刻意加的:服务账号 JSON 是可选 Secret。没配的人照样能拿到签名产物手动上传,不会因为少一个 Secret 就整条流水线失败。这类「可选依赖」一定要在 workflow 里显式表达,否则模板给别人用时会有大量无效报障。
很多人卡在第一次上传,报的是权限或证书错误,其实问题在准备工作:
keytool -export -rfc -file upload-certificate.pem \
-keystore upload-keystore.jks -alias upload
服务账号则走:Play Console → 用户和权限 → 服务账号 → 下载 JSON,把整段 JSON 存进 PLAY_STORE_SERVICE_ACCOUNT_JSON。这段 JSON 里含 private_key,属于最高级别机密,只放 Secret,不要贴进任何文档、issue 或聊天窗口。
internal(内部测试,最快,最多 100 人)
└─► closed(封闭测试,需要审核)
└─► open(公开测试)
└─► production(正式)
写字台现在的自动化只做到 internal,后面的轨道故意留给人工点——自动发布到正式轨道是个危险动作,尤其当 versionCode 由 github.run_number 自动递增时,一次误触发就会占用一个版本号且不可回退。
发布前逐条过一遍,能过滤掉 90% 的翻车:
| 检查项 | 为什么 |
|---|---|
versionCode 是否递增 |
Play 不接受重复或回退的 versionCode |
| 签名是不是上传密钥 | apksigner verify --print-certs 看一眼 |
applicationId 有没有改过 |
改了就变成新应用,老用户永远收不到更新 |
| 密钥文件是否已清理 | 本地构建后别忘了删 key.properties 和 .jks |
| 有没有提交过密钥 | git log -p 搜 storePassword,历史里提交过就得立刻轮换 |
| Play Console 里证书是否登记 | 换过密钥就必须重新登记 |
| 目标 API 级别是否满足当年要求 | Play 每年都会抬门槛 |
最后一行那个「历史里提交过就得轮换」要展开一句:密钥一旦进过 git 历史,只删文件是没用的,必须认为它已经泄漏,重新生成密钥并提交 Play 支持重置上传密钥。这是很多人踩过的最贵的坑。
算一笔账。手工打包的流程是:开 Android Studio → 等 Gradle 同步 → 填签名密码 → build → 找产物 → 手动上传 Play。一次 15 到 30 分钟,还要求手上有一台配好环境的机器。
流水线化之后,打一个 tag 就去干别的,回来产物和 Play 内部测试版本都已经就位。更重要的是可复现:三个月后要发一个 hotfix,不需要回忆「当初那台电脑上装的是哪个 JDK」。
真正的收益不在省下的那几十分钟,而在于发布这件事从「依赖某个人和某台机器」变成了「依赖一段可 review 的代码」。密钥怎么用、什么时候删、失败会不会漏删,全在 diff 里看得见。
这才是把发布流程写进仓库的意义。
还没有评论,登录后来说两句。