在原生 Android 项目里,CI/CD 的目标很简单:
- 每次提交代码后,自动检查项目能不能正常构建。
- 每次创建 Pull Request 时,自动验证代码有没有破坏构建。
- 推送到
main分支后,自动生成可发布的 APK 和 AAB。 - 不把
keystore.jks、密码等敏感信息提交到 GitHub 仓库。
这篇文章以一个典型的原生 Android 项目为例,说明如何创建一个 GitHub Actions 工作流文件:
.github/workflows/android-ci-cd.yml
一、先理解 CI/CD 是什么
在 Android 项目里,可以这样理解:
flowchart LR
A["开发者提交代码"] --> B["GitHub Actions 自动运行"]
B --> C["下载代码"]
C --> D["配置 JDK 和 Gradle"]
D --> E["构建 Debug APK"]
E --> F["构建 Release APK"]
F --> G["构建 Release AAB"]
G --> H{"是否是 main 分支或手动触发?"}
H -->|"否,比如 PR"| I["只做构建验证"]
H -->|"是"| J["解码 keystore"]
J --> K["签名 APK 和 AAB"]
K --> L["上传 artifact"]
简单说:
- CI:Continuous Integration,持续集成。主要负责自动构建、测试、检查代码。
- CD:Continuous Delivery/Deployment,持续交付/部署。主要负责自动生成可发布产物,比如 signed APK、signed AAB。
对于 Android 项目来说,常见流程就是:
代码提交 -> GitHub Actions 构建 -> 签名 -> 上传构建产物
二、项目需要具备哪些条件
一个标准原生 Android 项目通常会有这些文件:
.
├── app/
│ ├── build.gradle.kts
│ └── keystore.jks
├── gradlew
├── settings.gradle.kts
└── .github/
└── workflows/
└── android-ci-cd.yml
其中:
gradlew是 Gradle Wrapper。app/build.gradle.kts是 app 模块的 Gradle 配置。app/keystore.jks是本地签名文件,但不要提交到 GitHub。.github/workflows/android-ci-cd.yml是 GitHub Actions 工作流配置。
三、新建 GitHub Actions 工作流文件
在项目根目录创建文件:
.github/workflows/android-ci-cd.yml
完整配置如下:
name: Android CI/CD
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: android-ci-cd-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build Android app
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Make Gradle wrapper executable
run: chmod +x ./gradlew
- name: Build debug APK, release APK, and release AAB
run: ./gradlew --no-daemon :app:assembleDebug :app:assembleRelease :app:bundleRelease
- name: Validate signing secrets
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
missing=0
if [ -z "$ANDROID_KEYSTORE_BASE64" ]; then
echo "::error::Missing required secret: ANDROID_KEYSTORE_BASE64"
missing=1
fi
if [ -z "$ANDROID_KEYSTORE_PASSWORD" ]; then
echo "::error::Missing required secret: ANDROID_KEYSTORE_PASSWORD"
missing=1
fi
if [ -z "$ANDROID_KEY_ALIAS" ]; then
echo "::error::Missing required secret: ANDROID_KEY_ALIAS"
missing=1
fi
if [ -z "$ANDROID_KEY_PASSWORD" ]; then
echo "::error::Missing required secret: ANDROID_KEY_PASSWORD"
missing=1
fi
if [ "$missing" -ne 0 ]; then
exit 1
fi
- name: Sign release APK and AAB
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
set -euo pipefail
SIGNED_DIR="app/build/signed"
KEYSTORE_FILE="$RUNNER_TEMP/release-keystore.jks"
UNSIGNED_APK="app/build/outputs/apk/release/app-release-unsigned.apk"
ALIGNED_APK="$RUNNER_TEMP/app-release-aligned.apk"
SIGNED_APK="$SIGNED_DIR/app-release-signed.apk"
UNSIGNED_AAB="app/build/outputs/bundle/release/app-release.aab"
SIGNED_AAB="$SIGNED_DIR/app-release-signed.aab"
mkdir -p "$SIGNED_DIR"
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$KEYSTORE_FILE"
BUILD_TOOLS_DIR="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)"
ZIPALIGN="$BUILD_TOOLS_DIR/zipalign"
APKSIGNER="$BUILD_TOOLS_DIR/apksigner"
"$ZIPALIGN" -f -p 4 "$UNSIGNED_APK" "$ALIGNED_APK"
"$APKSIGNER" sign \
--ks "$KEYSTORE_FILE" \
--ks-pass "pass:$ANDROID_KEYSTORE_PASSWORD" \
--ks-key-alias "$ANDROID_KEY_ALIAS" \
--key-pass "pass:$ANDROID_KEY_PASSWORD" \
--out "$SIGNED_APK" \
"$ALIGNED_APK"
"$APKSIGNER" verify --verbose "$SIGNED_APK"
jarsigner \
-keystore "$KEYSTORE_FILE" \
-storepass "$ANDROID_KEYSTORE_PASSWORD" \
-keypass "$ANDROID_KEY_PASSWORD" \
-signedjar "$SIGNED_AAB" \
"$UNSIGNED_AAB" \
"$ANDROID_KEY_ALIAS"
jarsigner -verify -verbose "$SIGNED_AAB"
- name: Upload signed release artifacts
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
uses: actions/upload-artifact@v4
with:
name: android-signed-release
path: app/build/signed/*
if-no-files-found: error
retention-days: 30
四、触发条件说明
这一段决定什么时候运行工作流:
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
含义是:
- 推送到
main分支时运行。 - 向
main分支发起 Pull Request 时运行。 - 允许在 GitHub 页面手动点击运行。
也就是:
flowchart TD
A["触发 GitHub Actions"] --> B{"触发类型"}
B --> C["push 到 main"]
B --> D["pull_request 到 main"]
B --> E["workflow_dispatch 手动运行"]
C --> F["构建 + 签名 + 上传产物"]
D --> G["只构建,不签名"]
E --> F
这里特别重要的一点是:
PR 只做构建验证,不执行签名。
原因是签名需要读取 GitHub Secrets,而外部 PR 不应该接触敏感信息。
五、CI 阶段:自动构建 Android 项目
CI 阶段的核心命令是:
./gradlew --no-daemon :app:assembleDebug :app:assembleRelease :app:bundleRelease
它会生成:
app/build/outputs/apk/debug/app-debug.apk
app/build/outputs/apk/release/app-release-unsigned.apk
app/build/outputs/bundle/release/app-release.aab
这里要注意:
assembleDebug生成 Debug APK。assembleRelease生成 Release APK,但默认可能是 unsigned APK。bundleRelease生成 Release AAB,用于 Google Play 发布。
GitHub Actions 里使用 JDK 17:
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
如果你的 Android Gradle Plugin 或项目配置要求 Java 17,这一步必须设置正确。
六、CD 阶段:为什么不能直接提交 keystore
Android Release 包需要签名。
但是下面这些东西不能提交到仓库:
app/keystore.jks
keystore password
key alias
key password
原因很简单:
一旦泄露,别人就可能用你的签名身份发布伪造 APK。
所以正确做法是:
本地 keystore.jks
|
| base64 编码
v
GitHub Secrets
|
| GitHub Actions 运行时解码
v
临时 keystore 文件
|
v
签名 APK / AAB
流程图如下:
sequenceDiagram
participant Dev as 开发者本地
participant GitHub as GitHub Secrets
participant Actions as GitHub Actions Runner
participant Artifact as GitHub Artifact
Dev->>Dev: base64 编码 keystore.jks
Dev->>GitHub: 保存 ANDROID_KEYSTORE_BASE64
Dev->>GitHub: 保存密码和 alias
Actions->>GitHub: 运行时读取 Secrets
Actions->>Actions: 解码 keystore 到临时目录
Actions->>Actions: 使用 apksigner 签名 APK
Actions->>Actions: 使用 jarsigner 签名 AAB
Actions->>Artifact: 上传 signed APK 和 signed AAB
七、需要配置的 GitHub Secrets
需要在 GitHub 仓库里添加 4 个 Secrets:
ANDROID_KEYSTORE_BASE64
ANDROID_KEYSTORE_PASSWORD
ANDROID_KEY_ALIAS
ANDROID_KEY_PASSWORD
进入路径:
GitHub 仓库
-> Settings
-> Secrets and variables
-> Actions
-> New repository secret
1. 获取 ANDROID_KEYSTORE_BASE64
假设你的 keystore 文件在:
app/keystore.jks
在项目根目录运行:
base64 -i app/keystore.jks | pbcopy
这条命令会把 base64 内容复制到剪贴板。
然后在 GitHub Secrets 新建:
Name: ANDROID_KEYSTORE_BASE64
Value: 粘贴剪贴板内容
注意:不要把 base64 内容打印到聊天、文档或提交到仓库里。
2. 获取 ANDROID_KEY_ALIAS
运行:
keytool -list -v -keystore app/keystore.jks
输入 keystore 密码后,找到这一行:
Alias name: your_alias_name
这个 your_alias_name 就是:
ANDROID_KEY_ALIAS
3. 获取 ANDROID_KEYSTORE_PASSWORD
这是创建 keystore 时设置的密码。
它不能从 .jks 文件里反推出明文。
如果你忘记了,只能尝试你当时设置过的密码,或者重新生成 keystore。
4. 获取 ANDROID_KEY_PASSWORD
这是 key alias 对应 key 的密码。
如果你创建 keystore 时没有单独设置 key password,很多情况下它和 keystore password 是同一个。
也就是说,常见配置是:
ANDROID_KEYSTORE_PASSWORD = keystore 的密码
ANDROID_KEY_PASSWORD = key 的密码,可能和 keystore 密码相同
八、签名 APK 的过程
在 workflow 里,APK 签名分两步:
unsigned APK -> zipalign -> apksigner -> signed APK
对应命令:
"$ZIPALIGN" -f -p 4 "$UNSIGNED_APK" "$ALIGNED_APK"
"$APKSIGNER" sign \
--ks "$KEYSTORE_FILE" \
--ks-pass "pass:$ANDROID_KEYSTORE_PASSWORD" \
--ks-key-alias "$ANDROID_KEY_ALIAS" \
--key-pass "pass:$ANDROID_KEY_PASSWORD" \
--out "$SIGNED_APK" \
"$ALIGNED_APK"
为什么要先 zipalign?
因为 Android 官方推荐 Release APK 在签名前先做 zipalign,它可以优化 APK 内部资源对齐方式。
签名完成后再验证:
"$APKSIGNER" verify --verbose "$SIGNED_APK"
如果验证失败,GitHub Actions 会直接失败,不会上传错误产物。
九、签名 AAB 的过程
AAB 使用 jarsigner 签名:
jarsigner \
-keystore "$KEYSTORE_FILE" \
-storepass "$ANDROID_KEYSTORE_PASSWORD" \
-keypass "$ANDROID_KEY_PASSWORD" \
-signedjar "$SIGNED_AAB" \
"$UNSIGNED_AAB" \
"$ANDROID_KEY_ALIAS"
然后验证:
jarsigner -verify -verbose "$SIGNED_AAB"
最终会得到:
app/build/signed/app-release-signed.apk
app/build/signed/app-release-signed.aab
十、上传构建产物
最后一步使用:
- name: Upload signed release artifacts
uses: actions/upload-artifact@v4
with:
name: android-signed-release
path: app/build/signed/*
if-no-files-found: error
retention-days: 30
上传完成后,可以在 GitHub Actions 的运行记录页面下载:
android-signed-release
里面包含:
app-release-signed.apk
app-release-signed.aab
十一、本地验证方式
在提交 workflow 前,建议先本地跑一次构建:
./gradlew --no-daemon :app:assembleDebug :app:assembleRelease :app:bundleRelease
如果本地都构建失败,GitHub Actions 上也大概率会失败。
构建成功后,可以检查这些文件是否存在:
app/build/outputs/apk/debug/app-debug.apk
app/build/outputs/apk/release/app-release-unsigned.apk
app/build/outputs/bundle/release/app-release.aab
下载 GitHub Actions artifact 后,可以验证签名:
apksigner verify --verbose app-release-signed.apk
验证 AAB:
jarsigner -verify -verbose app-release-signed.aab
十二、常见问题
1. PR 为什么不签名?
因为 PR 可能来自外部贡献者。
如果 PR 阶段允许读取 Secrets,就有泄露签名信息的风险。
所以推荐策略是:
PR:只构建
main push:构建 + 签名 + 上传
workflow_dispatch:构建 + 签名 + 上传
2. Secrets 缺失会怎样?
workflow 里有专门的检查:
echo "::error::Missing required secret: ANDROID_KEYSTORE_BASE64"
如果缺少任何一个 Secret,CD 阶段会失败。
这样比静默失败更好,因为你能直接看到缺少哪个配置。
3. keystore.jks 要不要提交?
不要。
应该加入 .gitignore:
*.jks
*.keystore
如果已经提交过 keystore,需要尽快从仓库历史里清理,并考虑更换签名文件。
总结
原生 Android 项目接入 GitHub Actions CI/CD 的核心思路是:
用 GitHub Actions 自动构建
用 GitHub Secrets 保存签名信息
用 apksigner 签 APK
用 jarsigner 签 AAB
用 upload-artifact 上传产物
最终效果是:
- 提 PR 时,自动检查项目是否能构建。
- 推送到
main后,自动生成 signed APK 和 signed AAB。 - keystore 和密码不进入代码仓库。
- 构建失败、Secrets 缺失、签名失败都会明确报错。
这样,一个原生 Android 项目的基础 CI/CD 流程就搭好了。