[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