很多人第一次配 Android 自动发布,卡住的地方不是写 workflow,而是看到别人仓库里躺着这几个名字就懵了:
KEYSTORE_BASE64
KEYSTORE_PASSWORD
KEY_ALIAS
KEY_PASSWORD
PLAY_STORE_SERVICE_ACCOUNT_JSON
大写的、看不出含义的、还都跟「密码」有关。去搜,出来的都是片段命令,照着敲完报错又看不懂。
这篇文章把它们一个个拆开,讲清楚每一个是什么、从哪来、怎么填、填错了会报什么错。全部步骤都是可以照着做的,不需要提前懂 Android 签名机制。
这五个配置不是一类东西。把它们分成两拨,后面就全通了:
| 分拨 | 配置 | 解决什么问题 |
|---|---|---|
| 第一拨:一把钥匙 + 三个密码 | KEYSTORE_BASE64KEYSTORE_PASSWORDKEY_ALIASKEY_PASSWORD |
证明「这个安装包确实是我发的」,防的是别人冒充你发版 |
| 第二拨:一张通行证 | PLAY_STORE_SERVICE_ACCOUNT_JSON |
允许「CI 机器人替我把包上传到 Google Play」 |
两拨互不相干:
所以如果你只想要「自动构建、产物自己下载」,第二拨(那个 JSON)完全可以不配。这是很多教程没说明白的地方。
用保险箱打个比方,非常贴切:
upload-keystore.jks ← 保险箱本身(一个二进制文件)
│
├─ 箱门密码 ──────────► KEYSTORE_PASSWORD 打开这个保险箱要的密码
│
└─ 里面有几个抽屉,每个抽屉有编号(别名)
│
├─ 抽屉编号 ─────► KEY_ALIAS 用哪一个抽屉里的钥匙
└─ 抽屉密码 ─────► KEY_PASSWORD 打开那个抽屉要的密码
(整个保险箱打成一段文本 ─► KEYSTORE_BASE64 因为 GitHub Secrets 只能存文本)
再补一句最容易被忽略的:KEYSTORE_BASE64 里装的是这个 .jks 文件本身。CI 在构建时把它解码还原成文件,用完删掉。所以这个 Secret 是最长的一个值(几千个字符),另外三个都是一行短密码。
这个文件叫 keystore,全称 Java KeyStore。一个应用从发布第一天到永久下架,最好只用这一个,因为 Google Play 靠它认「你就是原作者」。换一次,所有已安装的用户就再也收不到你的更新。
keytool 是 JDK 自带的,不用单独装。打开终端验证:
keytool -help
能打印出用法说明就说明有。如果提示找不到命令:
C:\Program Files\Java\jdk-17\bin\keytool.exe,可以用全路径调用;或者 Flutter 自带的 Android Studio 里那个 JDK 也行。/usr/libexec/java_home -V 看装了哪些,必要时 brew install openjdk@17。选一个不在 git 仓库里的目录执行(比如桌面上的临时文件夹),避免手滑提交:
keytool -genkeypair -v \
-keystore upload-keystore.jks \
-storetype JKS \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias upload
每个参数的意思:
| 参数 | 含义 | 为什么这么填 |
|---|---|---|
-genkeypair |
生成一对密钥(私钥 + 公钥证书) | 签名用的是私钥,Play 校验的是公钥 |
-keystore |
输出文件名 | 名字随意,但建议就叫 upload-keystore.jks,跟文档对得上 |
-storetype JKS |
指定容器格式 | ⚠️ 必须写,原因见下方 2.4 |
-keyalg RSA -keysize 2048 |
密钥算法与长度 | 2048 位 RSA 是 Play 的最低要求,也是通用选择 |
-validity 10000 |
有效期天数(约 27 年) | Google 要求密钥有效期至少到 2033 年之后,10000 天足够 |
-alias upload |
这把钥匙的「代号」 | 这个名字就是 KEY_ALIAS 的值,后面要一字不差地填进 Secret |
回车后会连续问一串问题,第一次做会有点慌,其实只有第一项重要:
Enter keystore password: ← 输入密码(屏幕上不显示),这就是 KEYSTORE_PASSWORD
Re-enter new password: ← 再输一遍
What is your first and last name?
[Unknown]: Zhang San ← 会写进证书,Play 后台能看到。填真名/公司名都行
What is the name of your organizational unit?
[Unknown]: ← 直接回车跳过
What is the name of your organization?
[Unknown]: ← 直接回车跳过
What is the name of your City or Locality?
[Unknown]: Beijing ← 建议填
What is the name of your State or Province?
[Unknown]: Beijing
What is the two-letter country code for this unit?
[Unknown]: CN
Is CN=Zhang San, OU=Unknown, O=Unknown, L=Beijing, ST=Beijing, C=CN correct?
[no]: yes ← 必须输入 yes(不是直接回车!回车等于放弃)
Generating 2,048 bit RSA key pair and self-signed certificate (SHA256withRSA)
Enter key password for <upload>
(RETURN if same as keystore password): ← 建议直接回车
最后那个 Enter key password for <upload>:
KEY_PASSWORD 与 KEYSTORE_PASSWORD 相同。强烈建议直接回车,理由见下一节。
执行完目录里会出现 upload-keystore.jks。立刻把它和密码备份到密码管理器,然后从工作目录移走。
这是本文最值钱的一段,请务必看完。
从 Java 9 开始,keytool 的默认容器格式从 JKS 改成了 PKCS12。而 PKCS12 有一个硬限制:
store 密码和 key 密码必须相同。
如果你没写 -storetype JKS,又在 Enter key password for <upload> 时另输了一个密码,keytool 会打印一条警告然后默默把你的 key 密码改成 store 密码:
Warning: Different store and key passwords not supported for PKCS12 KeyStores.
Use -keypass and -storepass options to specify them.
这条是 Warning 不是 Error,很容易被忽略。结果就是:你以为 KEY_PASSWORD 是 A,实际它是 B(等于 KEYSTORE_PASSWORD)。填进 Secret 后 CI 报:
Failed to read key upload from store ... keystore password was incorrect
然后你会以为是密码记错了,反复重试,越试越疑心人生。
两种做法都行,选一个:
做法 A(本文推荐):显式 -storetype JKS,两个密码想不同就不同。
做法 B(更省事):不管格式,两个密码填成一样,KEYSTORE_PASSWORD 和 KEY_PASSWORD 填同一个值。这是绝大多数项目的实际做法,也是我在流水线里推荐新手用的方式——少一个可能记错的东西。
别急着往下走,先用这条命令确认文件能打开、别名对不对:
keytool -list -v -keystore upload-keystore.jks -alias upload
输入 keystore 密码后,正常会打印一大段证书信息,重点看三处:
Keystore type: JKS ← 与你要填的格式一致
Alias name: upload ← 这就是 KEY_ALIAS,一字不差
Valid from: ... until: ... ← 到期日要晚于 2033 年
SHA256: 3A:1B:...:9F ← 记住它,跟 Play 后台核对用
命令能跑通,说明 keystore 密码、别名、文件本身三者都是对的——这一步花 10 秒,能省掉后面半小时排查。
GitHub Secrets 只能存文本,不能存文件。所以要把 .jks 这个二进制文件编码成 base64 字符串 —— 这就是 KEYSTORE_BASE64。
编码不是加密。任何拿到这段 base64 的人都能解回原始 keystore。所以它和裸密钥一样敏感,只能放进 Secret。
[Convert]::ToBase64String([IO.File]::ReadAllBytes("upload-keystore.jks"))
输出是一整行、无换行的 base64。全选复制这一行即可。
base64 -i upload-keystore.jks | tr -d '\n'
base64 -w 0 upload-keystore.jks
⚠️ 不要用
certutil -encode(网上很多教程这么写)。它会在输出里加上-----BEGIN CERTIFICATE-----/-----END CERTIFICATE-----两行,并且每 64 个字符插一个换行。把这些一起粘进 Secret,CI 解码出来的就是一个损坏的文件,报错通常是「签名失败」或「Keystore 无法解析」,而且极难看出原因。如果你手上只有 certutil 的输出,记得手动删掉首尾两行、去掉所有换行。
编码完,把刚复制的那段字符串解码回去,比对文件哈希。哈希一致 = 编码无损。
# Windows PowerShell
$b64 = '<把你复制的那段粘到这里>'
$bytes = [Convert]::FromBase64String($b64)
$hash = [Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
-join ($hash | ForEach-Object { $_.ToString('x2') })
# 原始文件的哈希
(Get-FileHash upload-keystore.jks -Algorithm SHA256).Hash.ToLower()
两行输出一致就对了。如果 FromBase64String 直接抛异常,说明字符串里有非法字符(常见是混进了 -----BEGIN)。
仓库首页 → Settings → Secrets and variables → Actions → Repository secrets → New repository secret
| Secret 名 | 填什么 | 形态 |
|---|---|---|
KEYSTORE_BASE64 |
第 2 步得到的那一整行 base64 | 超长单行,几千字符 |
KEYSTORE_PASSWORD |
生成时输入的 store 密码 | 短,一行 |
KEY_ALIAS |
upload(或你当时 -alias 指定的名字) |
短,一行 |
KEY_PASSWORD |
key 密码;若用了 PKCS12 且没另设,则与上者相同 | 短,一行 |
细节 1:名字必须一字不差。 Secret 名是大小写敏感的字面量,KEYSTORE_BASE64 写成 KEYSTORE_BASE_64 就读不到。workflow 里引用的是 ${{ secrets.KEYSTORE_BASE64 }},对不上就是空字符串。
细节 2:要配在 Repository secrets,不能只配在 Environment secrets。 如果 workflow 里没有声明 environment: xxx,那它只能读到仓库级的 Secret。这个坑特别隐蔽:环境 Secret 在设置页面里看得见、明明配了,跑起来却是空值。
细节 3:粘 base64 时小心自动换行。 有些编辑器/终端在复制超长单行时会插入硬换行。GitHub 的输入框能接受换行(base64 -d 对换行是宽容的),但如果混进了空格或制表符就可能出问题。粘完别手动敲回车,直接保存。
GitHub 只允许覆盖,不允许查看原值。这不是坏事,但意味着:填之前先确认值是对的。填错了没法比对,只能重新生成一遍填进去。
前四个解决了「包是我签的」,这一个解决「CI 有权限把包传上去」。它跟签名完全无关,是一条独立的路。
它本质上是一个Google Cloud 服务账号的密钥文件(JSON 格式,里面含 client_email 和 private_key)。要让它能操作 Play,需要在两个地方各配一次——这是所有人最容易漏的地方:只在 Google Cloud 建了服务账号,没在 Play Console 里授权,上传必然 401。
play-publisher。play-publisher(随意,便于识别)xxx-xxxx.json 文件。这个文件只能下载一次,丢了只能删掉重建。{
"type": "service_account",
"project_id": "play-publisher-123456",
"private_key_id": "...",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n",
"client_email": "play-publisher@play-publisher-123456.iam.gserviceaccount.com",
"client_id": "..."
}
client_email 那个邮箱地址,就是下一步要去 Play Console 邀请的「人」。
🔒 这个 JSON 里的
private_key属于最高级别机密:拿到它等于拿到你应用的发布权限。不要贴进聊天窗口、issue、文档、截图,也不要提交到仓库。它唯一的去处就是 GitHub Secret。
client_email(play-publisher@....iam.gserviceaccount.com)。这一步做完,通行证才真正生效。 只做 5.1 不做 5.2,报错长这样:
The service account ... does not have permission to perform this action.
或者更含蓄的 401 Unauthorized。看到这个报错,先回 Play Console 检查邀请记录,别去怀疑 JSON 本身。
把整个 JSON 文件的全部内容(从第一个 { 到最后一个 },包括所有换行)粘进 Secret PLAY_STORE_SERVICE_ACCOUNT_JSON。
流水线里通常直接用 serviceAccountJsonPlainText 传,所以必须保留原始 JSON 的换行格式,不要压成一行、不要做 base64。
如果不想让这个 Secret 成为必需项:很多流水线会先判断它是否存在,存在才执行上传步骤。这样没配的人照样能拿到签名产物手动上传,不会整条流水线变红。写字台项目就是这么做的——第 5 个 Secret 是可选的。
假设你现在 Secret 全配好了、流水线也绿了,但第一次上传仍然可能失败。因为 CI 不能替你完成这两件事:
Play Console → 创建应用,填名称、默认语言、应用/游戏、免费/付费,勾选声明。CI 只能往已存在的应用里传包,不能创建应用。 名称重复也没关系,只要 applicationId 没被占就行。
Play 需要认识你这把钥匙。导出公钥证书:
keytool -export -rfc -file upload-certificate.pem \
-keystore upload-keystore.jks -alias upload
然后把 upload-certificate.pem 上传到:
Play Console → 选中应用 → 测试和发布 → 应用完整性 → 应用签名 → 上传密钥证书
如果你用的是 Play 应用签名(推荐开启),这里要传的是上传密钥的证书,不是「应用签名密钥」——Play 会用你的上传密钥验证身份,再用它自己保管的密钥给最终 APK 签名。这个概念容易混,记住:你手上只有上传密钥,另一个在 Google 那里。
没登记就上传,报错是:
APK or Android App Bundle uses a signing key that is not registered.
密钥配好后不要直接推 CI —— 一次 CI 往返好几分钟,本地几十秒就能验证签名对不对。
步骤 1:把 upload-keystore.jks 放到 android/app/ 目录下。
步骤 2:在 android/ 目录(注意不是 android/app/)新建 key.properties:
storePassword=你的store密码
keyPassword=你的key密码
keyAlias=upload
storeFile=upload-keystore.jks
storeFile写的是相对android/app/的路径,所以只写文件名就行。这个文件的作用是:告诉 Gradle「有正式签名可用」。
步骤 3:构建并校验签名。
flutter build appbundle --release
# 看用的是哪张证书(Android SDK 的 build-tools 里有 apksigner)
apksigner verify --print-certs build/app/outputs/bundle/release/app-release.aab
输出里的 Signer #1 certificate DN 和 SHA-256 digest 应该跟你 keystore 里的一致(和第 2.5 步的 SHA256 对照)。如果显示的是 CN=Android Debug,说明签名没生效,Gradle 退回了 debug 签名,回到步骤 2 检查 key.properties 的路径和字段名。
步骤 4(务必做):删掉临时文件。
rm -f android/key.properties android/app/upload-keystore.jks
并在 .gitignore 里确认有这两行:
android/key.properties
**/*.jks
配置类问题最怕「看不懂报错」。下面这张表按报错原文索引,直接对号入座。
| 报错关键词 | 真实原因 | 怎么办 |
|---|---|---|
缺少签名 Secret / Secret 判空直接退出 |
Secret 名拼错,或配在了 Environment 而不是 Repository | 核对名字一字不差;确认没声明 environment 的 workflow 用的是仓库级 Secret |
Keystore 解码为空 |
KEYSTORE_BASE64 是空串,或值里混了非法字符 |
重做第 2 步,用 [Convert]::ToBase64String 重新编码并验证哈希 |
Keystore was tampered with, or password was incorrect |
KEYSTORE_PASSWORD 错;或 base64 解码出的文件已损坏 |
用 keytool -list -keystore xxx.jks 本地验证密码;确认 base64 里没有 BEGIN/END 行 |
Failed to read key upload from store / Alias <x> does not exist |
KEY_ALIAS 与生成时不一致(大小写、拼写) |
跑 keytool -list -keystore xxx.jks 看真实别名 |
keystore password was incorrect,但密码肯定没记错 |
PKCS12 把 key 密码强制改成了 store 密码 | 见 2.4,两个 Secret 填同一个值,或重新用 -storetype JKS 生成 |
The service account ... does not have permission / 401 Unauthorized |
只在 Google Cloud 建了服务账号,没在 Play Console 邀请授权 | 回到 5.2 |
Google Play Android Developer API has not been used in project |
Cloud 项目的 API 没启用 | 回到 5.1 第 3 步启用 |
APK or Android App Bundle uses a signing key that is not registered |
上传密钥证书没登记到 Play | 回到 6.2 |
Version code N has already been used |
同一个 versionCode 传过两次(常见于手动重跑流水线) | 别重跑同一次 run;versionCode 用 github.run_number 这类单调递增的值 |
| 上传成功但 Play 后台看不到新版本 | 传到了别的轨道,或 status 不是 completed |
检查 track(internal/alpha/beta/production)与 status |
Application not found / 404 |
Play Console 里还没建这个 applicationId 的应用 |
回到 6.1 |
Android 签名密钥的泄漏后果比 API Key 严重得多:拿到 .jks 加密码的人,可以给你的应用签任何内容,而老用户的手机会无差别地信任它(因为签名一致)。
.gitignore 是底线,但只是第一道闸——更要紧的是别把文件放在被 git add . 覆盖的目录里。gradle.properties、不发聊天窗口。if: always():如果构建中途失败、清理被跳过,密钥文件会留在 runner 工作目录里。托管 runner 是一次性的问题不大,换成自托管 runner 就是长期后门。① keytool -genkeypair → 得到 upload-keystore.jks
(记住 store 密码 / key 密码 / 别名)
↓
② base64 编码 → 得到一整行长文本
↓
③ GitHub Secrets 填 4 个:
KEYSTORE_BASE64 / KEYSTORE_PASSWORD / KEY_ALIAS / KEY_PASSWORD
↓
④ Cloud Console 建服务账号 + 启用 Play API → 下载 JSON
↓
⑤ Play Console 邀请 client_email 并授权 ← 最容易漏的一步
↓
⑥ JSON 全文填进 PLAY_STORE_SERVICE_ACCOUNT_JSON
↓
⑦ Play Console:建应用 + 登记上传证书
↓
⑧ 本地 key.properties 自测一次签名 → 删掉临时文件
↓
⑨ 打 tag / 手动触发流水线
回头看,这五个配置之所以让人发怵,是因为它们披着同一种外衣(全大写、都跟密码有关),实际是两条完全独立的链路。拆开之后,每一个都只是「在某处点几下、复制一段东西贴到另一处」而已。
真正需要提前想清楚的只有一件事:**这把钥匙你要用很多年,所以生成它的那 30 秒别图快。**把密码记进密码管理器、把文件备份两处、确认有效期够长。剩下的都是照着填。
还没有评论,登录后来说两句。