Files
openless/.github/workflows/android-apk.yml
T
HKLHaoBinandCursor 0fe5fbfdcf feat(android): align release pipeline with desktop updater rules
Add signed release APK builds on v*-tauri tags, minisign manifests per ABI, custom Android in-app updater, and CI cargo check for aarch64-linux-android.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 19:48:07 +08:00

387 lines
15 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Android APK (debug)
# Triggers:
# - push v*-tauri tag → release APK (signed), updater manifest, attach to GitHub Release
# - workflow_dispatch → debug APK only, upload artifact (no release)
#
# Scope: full overlay/accessibility APK for ADB testing and tag releases.
on:
push:
tags:
- 'v*-tauri'
workflow_dispatch:
jobs:
build-android-apk:
permissions:
contents: write
runs-on: ubuntu-latest
env:
CI: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
OPENLESS_RELEASE_CHANNEL: ${{ endsWith(github.ref_name, '-beta-tauri') && 'beta' || 'stable' }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Detect build mode
id: mode
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then
echo "mode=release" >> "$GITHUB_OUTPUT"
echo "label=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
else
echo "mode=debug" >> "$GITHUB_OUTPUT"
echo "label=run-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
fi
- name: Check updater signing availability (tag release)
if: steps.mode.outputs.mode == 'release'
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "::error::TAURI_SIGNING_PRIVATE_KEY is required for signed Android release artifacts."
exit 1
fi
- name: Check Android keystore secrets (tag release)
if: steps.mode.outputs.mode == 'release'
shell: bash
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=()
for name in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do
if [ -z "${!name:-}" ]; then
missing+=("$name")
fi
done
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::Android release signing secrets are required for tag builds: ${missing[*]}"
echo "::error::Configure ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD in repo secrets."
exit 1
fi
- name: Setup Java 17 (Zulu)
uses: actions/setup-java@v4
with:
distribution: zulu
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Install NDK and accept SDK licenses
shell: bash
run: |
set -euo pipefail
sdkmanager "ndk;26.1.10909125"
ndk_dir="$ANDROID_HOME/ndk/26.1.10909125"
if [ ! -d "$ndk_dir" ]; then
echo "::error::NDK not found at $ndk_dir"
exit 1
fi
echo "ANDROID_NDK_HOME=$ndk_dir" >> "$GITHUB_ENV"
echo "NDK_HOME=$ndk_dir" >> "$GITHUB_ENV"
set +o pipefail
yes | sdkmanager --licenses
sdkmanager_exit=${PIPESTATUS[1]}
set -o pipefail
if [ "$sdkmanager_exit" -ne 0 ]; then
echo "::error::sdkmanager --licenses failed with exit code $sdkmanager_exit"
exit "$sdkmanager_exit"
fi
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: openless-all/app/package-lock.json
- uses: dtolnay/rust-toolchain@stable
with:
targets: >-
aarch64-linux-android,
armv7-linux-androideabi,
i686-linux-android,
x86_64-linux-android
- name: Cache Cargo
uses: swatinem/rust-cache@v2
with:
workspaces: openless-all/app/src-tauri -> target
- uses: gradle/actions/setup-gradle@v4
- name: Install npm deps
working-directory: openless-all/app
run: npm ci
- name: Build frontend
working-directory: openless-all/app
run: npm run build
- name: Initialize Android project
working-directory: openless-all/app
run: npm run tauri -- android init --ci
- name: Copy Android scaffolding (Kotlin + XML)
working-directory: openless-all/app
run: node scripts/copy-android-scaffolding.mjs
- name: Merge APK v1 manifest permissions
working-directory: openless-all/app
run: node scripts/merge-android-v1-manifest.mjs
- name: Merge overlay / accessibility manifest
working-directory: openless-all/app
run: node scripts/merge-android-overlay-manifest.mjs
- name: Merge updater / install manifest
working-directory: openless-all/app
run: node scripts/merge-android-updater-manifest.mjs
- name: Configure Android release signing
if: steps.mode.outputs.mode == 'release'
working-directory: openless-all/app
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: node scripts/configure-android-release-signing.mjs
- name: Prime Gradle wrapper
working-directory: openless-all/app
shell: bash
run: |
set -euo pipefail
for attempt in 1 2 3; do
if src-tauri/gen/android/gradlew --project-dir src-tauri/gen/android --version --no-daemon; then
exit 0
fi
if [ "$attempt" -lt 3 ]; then
sleep $([ "$attempt" -eq 1 ] && echo 15 || echo 30)
fi
done
exit 1
- name: Build Android debug APK
if: steps.mode.outputs.mode == 'debug'
working-directory: openless-all/app
run: npm run tauri:android:build:debug
- name: Build Android release APK
if: steps.mode.outputs.mode == 'release'
working-directory: openless-all/app
run: npm run tauri:android:build:release
- name: Free disk before artifact upload
shell: bash
working-directory: openless-all/app
run: |
set -euo pipefail
rm -rf src-tauri/target
rm -rf ~/.cargo/registry ~/.cargo/git ~/.gradle/caches
df -h
- name: Collect split APKs
id: apk
shell: bash
working-directory: openless-all/app
env:
OPENLESS_APK_MODE: ${{ steps.mode.outputs.mode }}
OPENLESS_APK_LABEL: ${{ steps.mode.outputs.label }}
run: |
set -euo pipefail
python - <<'PY'
import json
import os
import shutil
import sys
import zipfile
from pathlib import Path
expected = {
"arm64-v8a": "arm64_v8a",
"armeabi-v7a": "armeabi_v7a",
"x86": "x86",
"x86_64": "x86_64",
}
arch_map = {
"arm64-v8a": "aarch64",
"armeabi-v7a": "armv7",
"x86": "i686",
"x86_64": "x86_64",
}
root = Path("src-tauri/gen/android")
mode = os.environ["OPENLESS_APK_MODE"]
label = os.environ["OPENLESS_APK_LABEL"]
version = json.loads(Path("package.json").read_text(encoding="utf-8"))["version"]
out_dir = Path(os.environ["RUNNER_TEMP"]) / f"openless-android-{mode}-split"
out_dir.mkdir(parents=True, exist_ok=True)
found = {}
candidates = [
apk for apk in sorted(root.rglob("*.apk"))
if "outputs" in apk.parts
]
if not candidates:
print("::error::No APK found under src-tauri/gen/android/**/outputs/")
for apk in sorted(root.rglob("*.apk")):
print(apk)
sys.exit(1)
for apk in candidates:
with zipfile.ZipFile(apk) as archive:
abis = sorted({
name.split("/")[1]
for name in archive.namelist()
if name.startswith("lib/") and len(name.split("/")) >= 3
})
if len(abis) != 1:
print(f"::error::{apk} contains ABI directories {abis}; expected exactly one ABI per APK")
sys.exit(1)
abi = abis[0]
if abi not in expected:
print(f"::error::{apk} contains unexpected ABI {abi}")
sys.exit(1)
if abi in found:
print(f"::error::Duplicate APKs for ABI {abi}: {found[abi]} and {apk}")
sys.exit(1)
if mode == "release":
dest = out_dir / f"OpenLess_{version}_{abi}.apk"
else:
dest = out_dir / f"OpenLess-android-debug-{abi}-{label}.apk"
shutil.copy2(apk, dest)
found[abi] = dest
print(f"Collected {abi}: {apk} -> {dest}")
missing = sorted(set(expected) - set(found))
if missing:
print(f"::error::Missing split APKs for ABI(s): {', '.join(missing)}")
sys.exit(1)
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
for abi, key in expected.items():
output.write(f"{key}_path={found[abi]}\n")
output.write(f"{key}_arch={arch_map[abi]}\n")
output.write(f"out_dir={out_dir}\n")
output.write("release_files<<EOF\n")
for abi in expected:
output.write(f"{found[abi]}\n")
output.write("EOF\n")
PY
- name: Sign release APKs (minisign)
if: steps.mode.outputs.mode == 'release'
working-directory: openless-all/app
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
node scripts/sign-android-apks.mjs \
"${{ steps.apk.outputs.arm64_v8a_path }}" \
"${{ steps.apk.outputs.armeabi_v7a_path }}" \
"${{ steps.apk.outputs.x86_path }}" \
"${{ steps.apk.outputs.x86_64_path }}"
- name: Write Android updater manifests
if: steps.mode.outputs.mode == 'release'
working-directory: openless-all/app
env:
OPENLESS_UPDATE_APK_DIR: ${{ steps.apk.outputs.out_dir }}
OPENLESS_UPDATE_REPO: appergb/openless
OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com
OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }}
OPENLESS_RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
for arch in aarch64 armv7 i686 x86_64; do
OPENLESS_UPDATE_TARGET=android OPENLESS_UPDATE_ARCH="$arch" \
node scripts/write-updater-manifest.mjs
done
- name: Append release files (manifests + signatures)
if: steps.mode.outputs.mode == 'release'
id: release_assets
shell: bash
run: |
set -euo pipefail
out_dir="${{ steps.apk.outputs.out_dir }}"
{
echo "${{ steps.apk.outputs.release_files }}"
for f in "$out_dir"/*.apk.sig "$out_dir"/latest-android-*.json; do
[ -e "$f" ] && echo "$f"
done
} > "$RUNNER_TEMP/android-release-files.txt"
echo "files<<EOF" >> "$GITHUB_OUTPUT"
cat "$RUNNER_TEMP/android-release-files.txt" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Upload Android APK artifact (arm64-v8a)
uses: actions/upload-artifact@v4
with:
name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-arm64-v8a' || 'openless-android-debug-arm64-v8a' }}
path: ${{ steps.apk.outputs.arm64_v8a_path }}
if-no-files-found: error
- name: Upload Android APK artifact (armeabi-v7a)
uses: actions/upload-artifact@v4
with:
name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-armeabi-v7a' || 'openless-android-debug-armeabi-v7a' }}
path: ${{ steps.apk.outputs.armeabi_v7a_path }}
if-no-files-found: error
- name: Upload Android APK artifact (x86)
uses: actions/upload-artifact@v4
with:
name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-x86' || 'openless-android-debug-x86' }}
path: ${{ steps.apk.outputs.x86_path }}
if-no-files-found: error
- name: Upload Android APK artifact (x86_64)
uses: actions/upload-artifact@v4
with:
name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-x86_64' || 'openless-android-debug-x86_64' }}
path: ${{ steps.apk.outputs.x86_64_path }}
if-no-files-found: error
- name: Prepare Android release body
if: steps.mode.outputs.mode == 'release'
shell: bash
run: |
cat > "$RUNNER_TEMP/android-release-body.md" << 'EOF'
### Android 安装说明
- **推荐**:真机下载 `OpenLess_<version>_arm64-v8a.apk`(arm64 设备)。
- **模拟器**:x86_64 模拟器用 `OpenLess_<version>_x86_64.apk`。
- 首次安装需在系统设置中允许「安装未知应用」;应用内更新会拉起系统安装器。
- adb 调试:`adb install -r OpenLess_<version>_arm64-v8a.apk`
### Android 应用内更新
- Stable 用户检查 `latest-android-aarch64.json`(及 mirror 变体)。
- Beta 用户(设置 → 高级 → 加入 Beta)检查带 `-beta` 后缀的 manifest。
EOF
- name: Attach Android assets to GitHub Release
if: steps.mode.outputs.mode == 'release'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: OpenLess ${{ github.ref_name }}
draft: false
prerelease: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }}
append_body: true
body_path: ${{ runner.temp }}/android-release-body.md
files: ${{ steps.release_assets.outputs.files }}