安卓自动发布的 5 个密钥配置:KEYSTORE_BASE64 这些到底从哪来

xiezitai · 2026-10-26 22:56 · 3 次阅读

很多人第一次配 Android 自动发布,卡住的地方不是写 workflow,而是看到别人仓库里躺着这几个名字就懵了:

KEYSTORE_BASE64
KEYSTORE_PASSWORD
KEY_ALIAS
KEY_PASSWORD
PLAY_STORE_SERVICE_ACCOUNT_JSON

大写的、看不出含义的、还都跟「密码」有关。去搜,出来的都是片段命令,照着敲完报错又看不懂。

这篇文章把它们一个个拆开,讲清楚每一个是什么、从哪来、怎么填、填错了会报什么错。全部步骤都是可以照着做的,不需要提前懂 Android 签名机制。


一、先建立一个正确的整体印象(这一步最关键)

这五个配置不是一类东西。把它们分成两拨,后面就全通了:

分拨 配置 解决什么问题
第一拨:一把钥匙 + 三个密码 KEYSTORE_BASE64
KEYSTORE_PASSWORD
KEY_ALIAS
KEY_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 是最长的一个值(几千个字符),另外三个都是一行短密码。


二、第 1 步:生成那个「保险箱」(只做一次)

这个文件叫 keystore,全称 Java KeyStore。一个应用从发布第一天到永久下架,最好只用这一个,因为 Google Play 靠它认「你就是原作者」。换一次,所有已安装的用户就再也收不到你的更新。

2.1 确认你本机有 keytool

keytool 是 JDK 自带的,不用单独装。打开终端验证:

keytool -help

能打印出用法说明就说明有。如果提示找不到命令:

2.2 生成命令

选一个不在 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

2.3 交互式问答怎么答

回车后会连续问一串问题,第一次做会有点慌,其实只有第一项重要:

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>:

强烈建议直接回车,理由见下一节。

执行完目录里会出现 upload-keystore.jks。立刻把它和密码备份到密码管理器,然后从工作目录移走。

2.4 一个几乎所有人都会踩的坑:PKCS12 不允许两个密码不一样

这是本文最值钱的一段,请务必看完。

从 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 填同一个值。这是绝大多数项目的实际做法,也是我在流水线里推荐新手用的方式——少一个可能记错的东西。

2.5 生成完先自检一次

别急着往下走,先用这条命令确认文件能打开、别名对不对:

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 秒,能省掉后面半小时排查。


三、第 2 步:把文件变成一段文本(base64)

GitHub Secrets 只能存文本,不能存文件。所以要把 .jks 这个二进制文件编码成 base64 字符串 —— 这就是 KEYSTORE_BASE64。

编码不是加密。任何拿到这段 base64 的人都能解回原始 keystore。所以它和裸密钥一样敏感,只能放进 Secret。

Windows(PowerShell,推荐这么写)

[Convert]::ToBase64String([IO.File]::ReadAllBytes("upload-keystore.jks"))

输出是一整行、无换行的 base64。全选复制这一行即可。

macOS

base64 -i upload-keystore.jks | tr -d '\n'

Linux

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)。


四、第 3 步:填进 GitHub Secrets

4.1 路径

仓库首页 → Settings → Secrets and variables → Actions → Repository secrets → New repository secret

4.2 四个值怎么填

Secret 名 填什么 形态
KEYSTORE_BASE64 第 2 步得到的那一整行 base64 超长单行,几千字符
KEYSTORE_PASSWORD 生成时输入的 store 密码 短,一行
KEY_ALIAS upload(或你当时 -alias 指定的名字) 短,一行
KEY_PASSWORD key 密码;若用了 PKCS12 且没另设,则与上者相同 短,一行

4.3 三个容易出事的细节

细节 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 对换行是宽容的),但如果混进了空格或制表符就可能出问题。粘完别手动敲回车,直接保存。

4.4 Secret 存进去就看不到了

GitHub 只允许覆盖,不允许查看原值。这不是坏事,但意味着:填之前先确认值是对的。填错了没法比对,只能重新生成一遍填进去。


五、第 4 步:搞那张「通行证」——PLAY_STORE_SERVICE_ACCOUNT_JSON

前四个解决了「包是我签的」,这一个解决「CI 有权限把包传上去」。它跟签名完全无关,是一条独立的路。

它本质上是一个Google Cloud 服务账号的密钥文件(JSON 格式,里面含 client_email 和 private_key)。要让它能操作 Play,需要在两个地方各配一次——这是所有人最容易漏的地方:只在 Google Cloud 建了服务账号,没在 Play Console 里授权,上传必然 401。

5.1 在 Google Cloud Console 建服务账号并下载 JSON

  1. 打开 https://console.cloud.google.com/ ,接受条款(第一次用)。
  2. 顶部创建项目(或选一个已有项目)。名字随意,比如 play-publisher。
  3. 左侧菜单 → API 和服务 → 库,搜索并启用:
    • Google Play Android Developer API ← 这个是关键,不启用后面全废
  4. 左侧菜单 → IAM 和管理 → 服务账号 → 创建服务账号。
    • 名称:play-publisher(随意,便于识别)
    • 角色:可以留空不选(Play 的权限在 Play Console 里管,这里的项目角色不影响上传)
    • 完成后点进这个服务账号
  5. 切到 密钥 标签页 → 添加密钥 → 创建新密钥 → 选 JSON → 创建。
  6. 浏览器会自动下载一个 xxx-xxxx.json 文件。这个文件只能下载一次,丢了只能删掉重建。
  7. 打开它,看两个字段,后面要用:
{
  "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。

5.2 在 Play Console 里给这张通行证授权(最难找的一步)

  1. 打开 https://play.google.com/console/ → 选中你的应用。
  2. 左侧底部 用户和权限(Users and permissions)。⚠️ 这个入口只有账号所有者能操作,普通开发者看不到。
  3. 点 邀请新用户(Invite new users)。
  4. 电子邮件地址:填上一步那个 client_email(play-publisher@....iam.gserviceaccount.com)。
  5. 应用权限:
    • 想省事 → 选管理员(Admin),全套权限。
    • 想最小权限 → 只勾选你的应用,然后在下面勾上「发布到测试轨道」「管理测试轨道」,以及「查看应用信息」。用不上正式轨道就别给,减少误操作风险。
  6. 点 邀请用户 / 发送邀请。
  7. 回到用户列表,确认这个服务账号出现在里面(状态通常直接是「已启用」,不用等对方点确认邮件——它不是真人)。

这一步做完,通行证才真正生效。 只做 5.1 不做 5.2,报错长这样:

The service account ... does not have permission to perform this action.

或者更含蓄的 401 Unauthorized。看到这个报错,先回 Play Console 检查邀请记录,别去怀疑 JSON 本身。

5.3 填进 Secret

把整个 JSON 文件的全部内容(从第一个 { 到最后一个 },包括所有换行)粘进 Secret PLAY_STORE_SERVICE_ACCOUNT_JSON。

流水线里通常直接用 serviceAccountJsonPlainText 传,所以必须保留原始 JSON 的换行格式,不要压成一行、不要做 base64。

如果不想让这个 Secret 成为必需项:很多流水线会先判断它是否存在,存在才执行上传步骤。这样没配的人照样能拿到签名产物手动上传,不会整条流水线变红。写字台项目就是这么做的——第 5 个 Secret 是可选的。


六、Play Console 那边还得做的两件准备

假设你现在 Secret 全配好了、流水线也绿了,但第一次上传仍然可能失败。因为 CI 不能替你完成这两件事:

6.1 先把应用条目建起来

Play Console → 创建应用,填名称、默认语言、应用/游戏、免费/付费,勾选声明。CI 只能往已存在的应用里传包,不能创建应用。 名称重复也没关系,只要 applicationId 没被占就行。

6.2 登记上传密钥的证书

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 —— 一次 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 加密码的人,可以给你的应用签任何内容,而老用户的手机会无差别地信任它(因为签名一致)。

  1. keystore 永不进仓库。 .gitignore 是底线,但只是第一道闸——更要紧的是别把文件放在被 git add . 覆盖的目录里。
  2. base64 和裸密钥一样敏感。 它只是编码不是加密,别因为「看起来是一串乱码」就放松警惕。
  3. 所有密钥只走 GitHub Secrets。 不写进 workflow 明文、不写进 gradle.properties、不发聊天窗口。
  4. CI 用完必须删,失败也要删。 清理步骤要带 if: always():如果构建中途失败、清理被跳过,密钥文件会留在 runner 工作目录里。托管 runner 是一次性的问题不大,换成自托管 runner 就是长期后门。
  5. 密钥只要进过一次 git 历史,就当它已泄漏。 删文件没用,历史里还在。正确处理是:立刻生成新密钥 → 在 Play Console 请求重置上传密钥 → 提交 Google 支持处理。这是最贵的一种事故,成本远高于一开始就配好 Secret。
  6. 过一段时间就轮换服务账号 JSON。 它比 keystore 好换得多:Cloud Console 删旧密钥、建新的、改 Secret,五分钟搞定。所以那个 JSON 应该定期换,keystore 才需要长期稳定。

十、最后串一遍完整顺序

① 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 秒别图快。**把密码记进密码管理器、把文件备份两处、确认有效期够长。剩下的都是照着填。

评论

还没有评论,登录后来说两句。