[ci] sign Android release APKs with a fixed publishing key

This commit is contained in:
dijunkun
2026-10-02 03:44:47 +08:00
parent ad343f2f8b
commit 758bfaa7a0
7 changed files with 253 additions and 15 deletions
@@ -93,6 +93,26 @@ class ManifestTest(unittest.TestCase):
with self.assertRaises(ValueError):
self.merge(tag, downloads)
def test_signed_android_release_updates_existing_unsigned_download(self):
unsigned = self.files("crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
current = self.merge("android-v1.6.0-20261002", unsigned)
signed = self.files("crossdesk-android-arm64-v1.6.0-20261002.apk")
result = self.merge("android-v1.6.0-20261002", signed, current)
self.assertEqual(result["downloads"]["android-arm64"]["url"],
"https://downloads.crossdesk.cn/crossdesk-android-arm64-v1.6.0-20261002.apk")
self.assertEqual(result["version"], self.current["version"])
self.assertEqual(result["downloads"]["windows-x64"], self.current["downloads"]["windows-x64"])
self.assertNotIn("platforms", result)
def test_signed_android_is_preferred_when_release_retains_unsigned_apk(self):
downloads = self.files("crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk",
"crossdesk-android-arm64-v1.6.0-20261002.apk")
self.assertEqual(downloads["android-arm64"]["filename"],
"crossdesk-android-arm64-v1.6.0-20261002.apk")
with self.assertRaises(ValueError):
self.files("crossdesk-android-arm64-unsigned-v1.6.0-20261001.apk",
"crossdesk-android-arm64-v1.6.0-20261002.apk")
def test_invalid_current_manifest_is_rejected(self):
downloads = self.files("crossdesk-android-arm64-unsigned-v1.6.0-20261002.apk")
for current in ([], {}, {"downloads": []}):
+9 -3
View File
@@ -17,7 +17,7 @@ ARTIFACTS = {
"linux-amd64": ("linux-amd64", "deb"),
"linux-arm64": ("linux-arm64", "deb"),
"ios-arm64": ("ios-arm64-unsigned", "zip"),
"android-arm64": ("android-arm64-unsigned", "apk"),
"android-arm64": ("android-arm64(?:-unsigned)?", "apk"),
}
@@ -52,9 +52,15 @@ def artifact_downloads(directory):
match = re.fullmatch(rf"crossdesk-{prefix}-v([0-9][0-9.\-]*)\.{extension}", path.name)
if not match:
continue
if key in downloads:
raise ValueError(f"Multiple release artifacts for {key}")
metadata = version_info(match[1])
if key in downloads:
previous = downloads[key]
if (key != "android-arm64" or previous["version"] != metadata["version"]
or ("-unsigned-" in previous["filename"]) == ("-unsigned-" in path.name)):
raise ValueError(f"Multiple release artifacts for {key}")
# Older releases may retain their unsigned APK after adding a signed one.
if "-unsigned-" in path.name:
continue
downloads[key] = {"url": DOWNLOAD_BASE + path.name, "filename": path.name,
"version": metadata["version"], "releaseDate": metadata["releaseDate"]}
break
+1
View File
@@ -18,6 +18,7 @@ on:
- ".github/workflows/build-android.yml"
- ".github/workflows/build.yml"
- ".github/workflows/release-mobile.yml"
- ".github/workflows/sign-android.yml"
- ".gitmodules"
- "LICENSE"
- "apps/android/**"
+16 -3
View File
@@ -556,10 +556,23 @@ jobs:
name: crossdesk-win-x64-portable-${{ env.VERSION_NUM }}
path: ${{ github.workspace }}/crossdesk-win-x64-portable-${{ env.VERSION_NUM }}.zip
sign-android:
name: Sign Android release
needs: build-android
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/sign-android.yml
with:
version: ${{ needs.build-android.outputs.version }}
secrets:
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 }}
release:
name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [build-linux, build-macos, build-ios, build-android, build-windows-x64]
needs: [build-linux, build-macos, build-ios, build-android, sign-android, build-windows-x64]
runs-on: ubuntu-latest
concurrency:
group: publish-release-metadata
@@ -631,7 +644,7 @@ jobs:
[iOS source, modification and rebuild instructions](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/apps/ios/README.md).
The iOS archive also contains the exact source revision and third-party source catalog with archive checksums.
[Android source and rebuild instructions](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/apps/android/README.md).
The Android arm64 Release APK is unsigned; sign it with your own key before installation.
The Android arm64 Release APK is signed with the CrossDesk publishing key and can be installed directly.
It includes the exact application and MiniRTC revisions, license documents and native build recipes.
## Code signing policy
@@ -647,7 +660,7 @@ jobs:
cp artifacts/crossdesk-linux-amd64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-linux-amd64-${{ steps.version.outputs.VERSION_WITH_V }}.deb
cp artifacts/crossdesk-linux-arm64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-linux-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.deb
cp artifacts/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}/* release/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}.zip
cp artifacts/crossdesk-android-arm64-unsigned-${{ needs.build-android.outputs.version }}/*.apk release/crossdesk-android-arm64-unsigned-${{ needs.build-android.outputs.version }}.apk
cp artifacts/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}/*.apk release/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}.apk
cp artifacts/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}.exe
cp artifacts/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}.zip
+18 -5
View File
@@ -68,10 +68,23 @@ jobs:
with:
source_tag: ${{ needs.prepare.outputs.tag }}
sign-android:
name: Sign Android release
needs: [prepare, build-android]
if: needs.prepare.outputs.platform == 'android'
uses: ./.github/workflows/sign-android.yml
with:
version: ${{ needs.build-android.outputs.version }}
secrets:
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 }}
release:
name: Publish mobile release
needs: [prepare, build-ios, build-android]
if: ${{ !cancelled() && (needs.build-ios.result == 'success' || needs.build-android.result == 'success') }}
needs: [prepare, build-ios, build-android, sign-android]
if: ${{ !cancelled() && (needs.build-ios.result == 'success' || needs.sign-android.result == 'success') }}
runs-on: ubuntu-24.04
permissions:
contents: write
@@ -102,7 +115,7 @@ jobs:
- name: Download mobile release artifact
uses: actions/download-artifact@v8
with:
name: crossdesk-${{ env.PLATFORM }}-arm64-unsigned-${{ env.VERSION }}
name: crossdesk-${{ env.PLATFORM }}-arm64${{ env.PLATFORM == 'ios' && '-unsigned' || '' }}-${{ env.VERSION }}
path: release
- name: Prepare release notes
@@ -113,7 +126,7 @@ jobs:
SIGNING_NOTE="The arm64 ZIP contains an unsigned app, source metadata, third-party sources and any generated dSYM. Sign the app before device installation."
else
PLATFORM_NAME="Android"
SIGNING_NOTE="The arm64 Release APK is unsigned. Sign it with your own key before installation. It includes source metadata, license documents and native build recipes."
SIGNING_NOTE="The arm64 Release APK is signed with the CrossDesk publishing key and can be installed directly. It includes source metadata, license documents and native build recipes."
fi
cat > "${RUNNER_TEMP}/mobile-release-notes.md" << EOF
${PLATFORM_NAME} ${VERSION}
@@ -179,4 +192,4 @@ jobs:
- name: Summarize release
shell: bash
run: |
echo "Published [${SOURCE_TAG}](https://github.com/${GITHUB_REPOSITORY}/releases/tag/${SOURCE_TAG}), uploaded its unsigned ${PLATFORM} artifact to the download server, and updated version.json." >> "${GITHUB_STEP_SUMMARY}"
echo "Published [${SOURCE_TAG}](https://github.com/${GITHUB_REPOSITORY}/releases/tag/${SOURCE_TAG}), uploaded its ${PLATFORM} artifact to the download server, and updated version.json." >> "${GITHUB_STEP_SUMMARY}"
+153
View File
@@ -0,0 +1,153 @@
name: Sign Android Release
on:
workflow_call:
inputs:
version:
description: "Android artifact version, for example v1.6.0-20261002."
required: true
type: string
secrets:
ANDROID_KEYSTORE_BASE64:
required: true
ANDROID_KEYSTORE_PASSWORD:
required: true
ANDROID_KEY_ALIAS:
required: true
ANDROID_KEY_PASSWORD:
required: true
workflow_dispatch:
inputs:
source_tag:
description: "Existing Android release to sign without rebuilding, for example android-v1.6.0-20261002."
required: true
type: string
permissions:
contents: read
jobs:
sign:
name: Sign and verify Android APK
runs-on: ubuntu-24.04
timeout-minutes: 15
env:
VERSION: ${{ inputs.version }}
SOURCE_TAG: ${{ inputs.source_tag }}
steps:
- name: Validate signing configuration
shell: python
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: |
import os
names = ("ANDROID_KEYSTORE_BASE64", "ANDROID_KEYSTORE_PASSWORD",
"ANDROID_KEY_ALIAS", "ANDROID_KEY_PASSWORD")
missing = [name for name in names if not os.environ.get(name)]
if missing:
raise ValueError("Missing repository signing secrets: " + ", ".join(missing))
- name: Resolve artifact version
id: version
shell: python
run: |
import datetime
import os
import re
version = os.environ["VERSION"]
if os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch" and not version:
tag = os.environ["SOURCE_TAG"]
if not tag.startswith("android-v"):
raise ValueError("Use an existing android-v<app-version>-YYYYMMDD release")
version = tag.removeprefix("android-")
match = re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+-([0-9]{8})", version)
if not match:
raise ValueError("Invalid Android artifact version")
datetime.datetime.strptime(match[1], "%Y%m%d")
with open(os.environ["GITHUB_OUTPUT"], "a") as stream:
stream.write(f"version={version}\n")
- name: Download unsigned build artifact
if: github.event_name != 'workflow_dispatch' || inputs.version != ''
uses: actions/download-artifact@v8
with:
name: crossdesk-android-arm64-unsigned-${{ steps.version.outputs.version }}
path: unsigned
- name: Download unsigned APK from existing release
if: github.event_name == 'workflow_dispatch' && inputs.version == ''
env:
GH_TOKEN: ${{ github.token }}
APK_VERSION: ${{ steps.version.outputs.version }}
run: |
gh release view "${SOURCE_TAG}" --repo "${GITHUB_REPOSITORY}" \
--json isDraft,isPrerelease > release.json
python3 - <<'PY'
import json
from pathlib import Path
release = json.loads(Path('release.json').read_text())
if release['isDraft'] or release['isPrerelease']:
raise ValueError('Use a published stable Android release')
PY
gh release download "${SOURCE_TAG}" --repo "${GITHUB_REPOSITORY}" \
--pattern "crossdesk-android-arm64-unsigned-${APK_VERSION}.apk" --dir unsigned
- name: Set up Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Set up Android signing tools
uses: android-actions/setup-android@v3
with:
packages: "build-tools;36.0.0"
# This job never checks out or executes application source with the key present.
- name: Align, sign and verify Release APK
env:
APK_VERSION: ${{ steps.version.outputs.version }}
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: |
umask 077
crossdesk_signing_dir="$(mktemp -d "${RUNNER_TEMP}/crossdesk-signing.XXXXXX")"
trap 'rm -rf -- "${crossdesk_signing_dir}"' EXIT
printf '%s' "${ANDROID_KEYSTORE_BASE64}" | base64 --decode > "${crossdesk_signing_dir}/release.keystore"
crossdesk_tools="${ANDROID_HOME}/build-tools/36.0.0"
crossdesk_output="${RUNNER_TEMP}/crossdesk-android-signed"
mkdir -p "${crossdesk_output}"
"${crossdesk_tools}/zipalign" -P 16 -f -v 4 \
"unsigned/crossdesk-android-arm64-unsigned-${APK_VERSION}.apk" \
"${crossdesk_signing_dir}/aligned.apk"
"${crossdesk_tools}/apksigner" sign \
--ks "${crossdesk_signing_dir}/release.keystore" \
--ks-key-alias "${ANDROID_KEY_ALIAS}" \
--ks-pass env:ANDROID_KEYSTORE_PASSWORD --key-pass env:ANDROID_KEY_PASSWORD \
--out "${crossdesk_output}/crossdesk-android-arm64-${APK_VERSION}.apk" \
"${crossdesk_signing_dir}/aligned.apk"
"${crossdesk_tools}/apksigner" verify --verbose --print-certs \
"${crossdesk_output}/crossdesk-android-arm64-${APK_VERSION}.apk"
"${crossdesk_tools}/zipalign" -c -P 16 -v 4 \
"${crossdesk_output}/crossdesk-android-arm64-${APK_VERSION}.apk"
- name: Upload signed Release APK
uses: actions/upload-artifact@v6
with:
name: crossdesk-android-arm64-${{ steps.version.outputs.version }}
path: ${{ runner.temp }}/crossdesk-android-signed/*.apk
if-no-files-found: error
- name: Summarize signing
env:
APK_VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Android ${APK_VERSION}: Release APK signed with the fixed publishing key; APK signature and 16 KB ZIP alignment verified." >> "${GITHUB_STEP_SUMMARY}"
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && -z "${VERSION}" ]]; then
echo "Signed an existing release without rebuilding. Download the signed Actions artifact to publish it." >> "${GITHUB_STEP_SUMMARY}"
fi
+36 -4
View File
@@ -73,7 +73,7 @@ Release 构建使用 `./gradlew :app:assembleRelease`,默认输出未签名 AP
## GitHub Actions
[`Build Android`](../../.github/workflows/build-android.yml) 可在 Actions 页面单独手动运行;修改 Android、MiniRTC、共享协议或许可证资料的 Pull Request 也会触发。仓库原有的 [`Build and Release`](../../.github/workflows/build.yml) 在分支推送、标签推送及手动运行时调用同一工作流,`v` 标签发布会等待安卓构建成功,并将未签名 Release APK 加入 GitHub Release 和现有下载服务器的产物集合。推送主仓库前,先确保它引用的 MiniRTC 提交已推送到子模块远程仓库,否则 CI 无法完成检出。
[`Build Android`](../../.github/workflows/build-android.yml) 可在 Actions 页面单独手动运行;修改 Android、MiniRTC、共享协议或许可证资料的 Pull Request 也会触发。仓库原有的 [`Build and Release`](../../.github/workflows/build.yml) 在分支推送、标签推送及手动运行时调用同一工作流,`v` 标签发布会等待安卓构建及签名成功,并将已签名 Release APK 加入 GitHub Release 和现有下载服务器的产物集合。推送主仓库前,先确保它引用的 MiniRTC 提交已推送到子模块远程仓库,否则 CI 无法完成检出。
单独发布使用 [`Release Mobile`](../../.github/workflows/release-mobile.yml)。先提交 `app/build.gradle` 中需要发布的 `versionName`,再为该提交创建 `android-v<versionName>-YYYYMMDD` 标签,例如:
@@ -82,7 +82,7 @@ git tag android-v1.6.0-20261002
git push origin android-v1.6.0-20261002
```
该标签只触发安卓构建,将未签名 Release APK 发布到独立的 GitHub Release 和现有下载服务器。标签版本必须与应用版本一致,日期必须有效。重试发布时,在 Actions 页面手动运行 `Release Mobile`,将已有标签填入 `source_tag`;即使从其他分支触发,也会检出该标签的源码。APK 的源码资料记录安卓标签及准确的应用、MiniRTC 提交。
该标签只触发安卓构建,使用固定发布密钥签名并验证 Release APK 后,再发布到独立的 GitHub Release 和现有下载服务器。签名失败或缺少签名 Secrets 时停止发布,不回退到未签名 APK。标签版本必须与应用版本一致,日期必须有效。重试发布时,在 Actions 页面手动运行 `Release Mobile`,将已有标签填入 `source_tag`;即使从其他分支触发,也会检出该标签的源码。APK 的源码资料记录安卓标签及准确的应用、MiniRTC 提交。
移动端独立发布保留桌面 `latest` 标签和 GitHub 的最新 Release 选择。APK 上传完成后,将服务器下载地址合并到共享 `https://version.crossdesk.cn/version.json` 的 `downloads.android-arm64`,并在该下载项中记录安卓版本和发布日期;保留顶层桌面版本信息和其他平台的下载项。下载服务器上传保留其他平台文件,复用仓库已有的 `SERVER_HOST`、`SERVER_USER`、`SERVER_KEY` Secrets。普通桌面版本标签仍通过 `Build and Release` 发布全部平台并更新各平台下载项;所有发布任务更新版本文件时使用同一队列。单独运行 `Build Android` 仍只上传 Actions 构建产物。
@@ -94,11 +94,43 @@ CI 构建 Debug、Release 和设备测试 APK,执行 Debug / Release Lint、
产物使用 Android 工程自己的 `versionName`,例如 `v1.6.0-20261002`,不跟随桌面版本号。构建日期采用上海时区;带日期的版本标签沿用标签中的日期,与 iOS 一致。Actions 提供:
- `crossdesk-android-arm64-unsigned-<版本>.apk`:未签名 Release APK,标签发布只收集此 APK,安装前需自行签名。
- `crossdesk-android-arm64-<版本>.apk`:已签名 Release APK,独立安卓发布和全平台标签发布收集此 APK,可直接安装。
- `crossdesk-android-arm64-unsigned-<版本>.apk`:构建工作流的中间产物,也可用于自行签名和验证修改后的构建。
- `crossdesk-android-arm64-debug-<版本>.apk` 和对应的 `-test.apk`:可安装的调试应用和设备测试包,保留 14 天。
- `crossdesk-android-reports`:Lint 报告,保留 14 天。
当前 CI 不需要签名 Secrets。Debug APK 使用每次运行的临时调试证书,可能无法覆盖手机上已有的其他签名版本;卸载原应用会清除本机连接记录和密码。需要持续覆盖升级时,应使用同一私钥签名 Release APK,并在发布新版本时递增 `app/build.gradle` 中的 `versionCode`。未签名 APK 已完成对齐,可通过 SDK 的 `apksigner sign --ks <自己的密钥库> --out <已签名.apk> <未签名.apk>` 签名,再用 `apksigner verify` 检查。
Debug APK 使用每次运行的临时调试证书,可能无法覆盖手机上已有的其他签名版本;卸载原应用会清除本机连接记录和密码。需要持续覆盖升级时,应使用同一私钥签名 Release APK,并在发布新版本时递增 `app/build.gradle` 中的 `versionCode`。未签名 APK 已完成对齐,可通过 SDK 的 `apksigner sign --ks <自己的密钥库> --out <已签名.apk> <未签名.apk>` 签名,再用 `apksigner verify` 检查。
### Release 签名
[`Sign Android Release`](../../.github/workflows/sign-android.yml) 在独立 job 中签名,不检出或执行应用源码。构建 job 和 Pull Request 构建不接收发布密钥。签名 job 使用 Build Tools 36.0.0,先执行 `zipalign -P 16`,再通过环境变量向 `apksigner` 提供密码,验证签名和 16 KB 对齐后上传已签名 APK。临时密钥库在步骤结束时清理,产物中不包含密钥或密码。
首次发布前,在仓库 Settings → Secrets and variables → Actions 配置:
| Secret | 内容 |
| --- | --- |
| `ANDROID_KEYSTORE_BASE64` | 发布密钥库文件的 Base64 内容 |
| `ANDROID_KEYSTORE_PASSWORD` | 密钥库密码 |
| `ANDROID_KEY_ALIAS` | 发布私钥的 alias |
| `ANDROID_KEY_PASSWORD` | 发布私钥密码 |
APK 签名不需要 Google Play 开发者账号。若没有已有发布密钥,可使用 JDK 的 `keytool` 创建一次,密钥库保存在工程之外,并备份密钥和密码:
```sh
keytool -genkeypair -storetype PKCS12 -keystore /path/outside/repo/crossdesk-release.p12 \
-alias crossdesk-release -keyalg RSA -keysize 4096 -validity 10000
```
PKCS12 使用同一个密钥库和私钥密码,将它配置到两个密码 Secrets。可用 `gh secret set` 从标准输入写入 Secrets,避免把密码放进命令行参数。macOS 上传密钥库示例:
```sh
base64 -i /path/outside/repo/crossdesk-release.p12 | gh secret set ANDROID_KEYSTORE_BASE64 --repo kunkundi/crossdesk
gh secret set ANDROID_KEYSTORE_PASSWORD --repo kunkundi/crossdesk
gh secret set ANDROID_KEY_ALIAS --repo kunkundi/crossdesk
gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
```
后续版本沿用这些 Secrets,不能在每次构建时生成新密钥,也不能将密钥或密码提交到 Git。已有未签名安卓 Release 可单独运行 `Sign Android Release` 并填写其 `source_tag`,无需重新构建;该手动任务只上传已签名 Actions 产物,不修改已发布 Release。普通发布流程会自动签名、上传下载服务器,并将 `downloads.android-arm64` 指向已签名 APK。旧 Release 同时保留已签名和未签名 APK 时,版本文件优先选择相同版本的已签名文件。
## 使用与连接安全