diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml new file mode 100644 index 0000000..171b2cc --- /dev/null +++ b/.gitea/workflows/android-build.yml @@ -0,0 +1,163 @@ +name: Android Build & Publish + +# 仅在 android-* 分支 push 时触发 (如 android/release, android-1.0) +# 构建 release APK (keystore 签名) 并上传到 Gitea Generic Package Registry +on: + push: + branches: + - 'android-*' + +jobs: + build-android: + runs-on: ubuntu-latest + # 需要写权限: 上传 packages + permissions: + contents: read + packages: write + + env: + # 固定工具链版本, 便于 actions/cache 命中 + # Android 后端地址 (bake 进 APK); 与 scripts/wsl/build-apk.sh 保持一致 + VITE_API_BASE: http://bh.vps.honor3.com:3001 + + steps: + - name: Checkout + uses: https://gitea.com/actions/checkout@v4 + + - name: Setup Node + uses: https://gitea.com/actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Setup JDK 17 + uses: https://gitea.com/actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + # ---------- 缓存: 命中后跳过下面的安装 ---------- + - name: Cache Android SDK & NDK + id: cache-android + uses: https://gitea.com/actions/cache@v4 + with: + path: ~/android-sdk + key: android-sdk-ndk-27.2-${{ runner.os }} + + - name: Cache Rust toolchain + id: cache-rust + uses: https://gitea.com/actions/cache@v4 + with: + path: | + ~/.cargo + ~/.rustup + # 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk) + key: rust-android-v1-${{ runner.os }} + + - name: Cache Tauri cargo target + uses: https://gitea.com/actions/cache@v4 + with: + path: src-tauri/target + key: tauri-target-android-${{ runner.os }} + + # ---------- 安装工具链 (仅缓存未命中时) ---------- + - name: Install Rust + Android targets + if: steps.cache-rust.outputs.cache-hit != 'true' + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + . "$HOME/.cargo/env" + rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android + cargo install cargo-ndk --locked + + - name: Install Android SDK & NDK + if: steps.cache-android.outputs.cache-hit != 'true' + run: | + export ANDROID_HOME="$HOME/android-sdk" + sudo apt-get update + sudo apt-get install -y --no-install-recommends unzip + mkdir -p "$ANDROID_HOME/cmdline-tools" + wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip + unzip -q /tmp/cmdline-tools.zip -d /tmp/ + mv /tmp/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest" + export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" + yes | sdkmanager --licenses >/dev/null || true + sdkmanager --install \ + "platforms;android-36" \ + "build-tools;36.0.0" \ + "platform-tools" \ + "ndk;27.2.12479018" \ + "cmake;3.22.1" + + - name: Install frontend deps + run: npm ci --no-audit --no-fund + + # ---------- 构建 ---------- + - name: Init Android project + run: | + cd src-tauri + npx tauri android init --ci + + - name: Inject release signing config + run: bash scripts/ci/android-signing.sh + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + + - name: Build release APK + run: | + export ANDROID_HOME="$HOME/android-sdk" + export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018" + export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH" + export CARGO_HOME="$HOME/.cargo" + . "$HOME/.cargo/env" + cd src-tauri + npx tauri android build --apk + # 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中) + if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then + echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2 + exit 1 + fi + + - name: Locate APK + id: apk + run: | + # 优先 universal 已签名 APK, 找不到再回退到其他已签名 APK + APK="" + UNIVERSAL=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name '*.apk' ! -name '*unsigned*' 2>/dev/null | head -1) + if [ -n "$UNIVERSAL" ]; then + APK="$UNIVERSAL" + else + APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*unsigned*' | head -1) + fi + if [ -z "$APK" ]; then + echo "ERROR: 未找到已签名 APK" >&2 + exit 1 + fi + echo "path=$APK" >> $GITHUB_OUTPUT + ls -lh "$APK" + + # ---------- 上传到 Gitea Generic Package ---------- + - name: Publish APK to Gitea packages + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + APK_PATH: ${{ steps.apk.outputs.path }} + run: | + set -euo pipefail + # 包版本: 用分支名去掉 android- 前缀 + 短 SHA, 保证每次 push 唯一 + BRANCH="${GITHUB_REF_NAME#android-}" + PKG_VERSION="${BRANCH}-${GITHUB_SHA:0:8}" + FILE_NAME="iboard-$(basename "$APK_PATH")" + OWNER="${GITHUB_REPOSITORY_OWNER}" + PKG_NAME="iboard" + # Gitea Generic Package Registry 上传端点 (PUT) + # 注意: GITHUB_SERVER_URL 是仓库 web 根地址, 例如 http://git.honor3.com + URL="${GITHUB_SERVER_URL}/api/packages/${OWNER}/generic/${PKG_NAME}/${PKG_VERSION}/${FILE_NAME}" + echo "上传到: ${URL}" + curl -sS --fail-with-body \ + -H "Authorization: token ${GITEA_TOKEN}" \ + --upload-file "$APK_PATH" \ + "${URL}" + echo "" + echo "已发布: ${URL}" diff --git a/scripts/ci/android-signing.sh b/scripts/ci/android-signing.sh new file mode 100644 index 0000000..0313ffb --- /dev/null +++ b/scripts/ci/android-signing.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# CI 专用: 为 Tauri Android release 构建注入 release 签名配置 +# 在 `tauri android init` 之后、`tauri android build` 之前运行。 +# +# 依赖 (由调用方注入环境变量, 不要写死密钥到脚本/仓库): +# ANDROID_KEYSTORE_B64 - base64 编码的 keystore 文件内容 (CI secret) +# ANDROID_KEY_ALIAS - 密钥别名 (CI secret/variable) +# ANDROID_KEY_PASSWORD - store & key 密码 (CI secret; 本脚本两者用同一个) +# +# 作用: +# 1. 把 base64 密钥解码写到 $RUNNER_TEMP/iboard-release.keystore +# 2. 生成 gen/android/keystore.properties (Tauri 签名约定读取此文件) +# 3. 修改 gen/android/app/build.gradle.kts, 添加 signingConfigs.release +# 并让 buildTypes.release 使用它, 产出已签名 APK +set -euo pipefail + +APP_GRADLE="src-tauri/gen/android/app/build.gradle.kts" + +echo "[signing] 注入 Android release 签名配置..." + +# --- 1. 解码 keystore --- +if [ -z "${ANDROID_KEYSTORE_B64:-}" ]; then + echo "[signing] ERROR: ANDROID_KEYSTORE_B64 未设置, 无法签名 release APK" >&2 + exit 1 +fi +: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS 未设置}" +: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD 未设置}" + +KEYSTORE_FILE="${RUNNER_TEMP:-/tmp}/iboard-release.keystore" +echo "${ANDROID_KEYSTORE_B64}" | base64 -d > "$KEYSTORE_FILE" + +# --- 2. 写 keystore.properties (Tauri 官方约定的签名配置文件位置) --- +# 注意: 不能写进 git, 这是运行时生成物 +KEYSTORE_PROPERTIES="src-tauri/gen/android/keystore.properties" +cat > "$KEYSTORE_PROPERTIES" <&2 + exit 1 +fi + +# 幂等: 已注入则跳过 +if grep -q 'create("release")' "$APP_GRADLE" 2>/dev/null; then + echo "[signing] build.gradle.kts 已有 release signingConfigs, 跳过注入" +else + # 在 android { 块内 buildTypes { 之前插入 signingConfigs 块 + # 用 Python 处理 Kotlin DSL, 对模板结构做最小侵入 + python3 - "$APP_GRADLE" <<'PYEOF' +import sys, re + +path = sys.argv[1] +src = open(path, encoding='utf-8').read() + +# 定位 android { 块内的 buildTypes { (注意 Kotlin 嵌套花括号) +# 简化: 找 "android {" 之后的第一个 "buildTypes {" 缩进位置 +m = re.search(r'(\n[ \t]*)(buildTypes\s*\{)', src) +if not m: + print('[signing] 未找到 buildTypes 块, 中止', file=sys.stderr) + sys.exit(1) + +indent = m.group(1) # 含换行, e.g. "\n " +inner_indent = indent + ' ' + +signing_block = ( + f'{inner_indent}signingConfigs {{\n' + f'{inner_indent} create("release") {{\n' + f'{inner_indent} val keystorePropertiesFile = rootProject.file("keystore.properties")\n' + f'{inner_indent} val keystoreProperties = java.util.Properties()\n' + f'{inner_indent} if (keystorePropertiesFile.exists()) {{\n' + f'{inner_indent} keystoreProperties.load(java.io.FileInputStream(keystorePropertiesFile))\n' + f'{inner_indent} }}\n' + f'{inner_indent} keyAlias = keystoreProperties["keyAlias"] as String\n' + f'{inner_indent} keyPassword = keystoreProperties["password"] as String\n' + f'{inner_indent} storeFile = file(keystoreProperties["storeFile"] as String)\n' + f'{inner_indent} storePassword = keystoreProperties["password"] as String\n' + f'{inner_indent} }}\n' + f'{inner_indent}}}\n' +) + +# 在 buildTypes { 前插入 signingConfigs 块 +src = src.replace(m.group(0), signing_block + m.group(0), 1) + +# 让 buildTypes 里的 release 使用该 signingConfig +# 找到 "getByName(\"release\") {" 或 "release {" 并注入 signingConfig +rel = re.search(r'(\n[ \t]*)(getByName\("release"\)\s*\{)', src) +if not rel: + rel = re.search(r'(\n[ \t]*)(release\s*\{)', src) +if rel: + rel_indent = rel.group(1) + ' ' + inject = rel_indent + 'signingConfig = signingConfigs.getByName("release")\n' + src = src.replace(rel.group(0), rel.group(0) + inject, 1) + +open(path, 'w', encoding='utf-8').write(src) +print('[signing] build.gradle.kts 已注入 signingConfigs + release signingConfig') +PYEOF +fi + +echo "[signing] 完成: keystore.properties 已写, build.gradle.kts 已注入"