Skip to content

Android Release

Android Release #53

Workflow file for this run

name: Android Release
# 打 Tag(如 v1.0.0)时触发:构建三个签名 Release APK
# - universal : arm64-v8a + x86_64,含 arm + x86 两套容器镜像(通用,体积最大)
# - armsolo : 仅 arm64-v8a,仅含 arm 镜像(真机首选,体积约为 universal 的一半)
# - x86solo : 仅 x86_64,仅含 x86 镜像(x86 模拟器/Chromebook,体积约为 universal 的一半)
# 创建 GitHub Release 并把三个 APK 作为资产发布。
on:
push:
tags:
- 'v*'
# 允许在 Actions 页面手动触发,便于验证构建链路。
workflow_dispatch:
jobs:
build:
name: Build & Release
runs-on: ubuntu-latest
permissions:
contents: write # 创建 Release 与上传资产需要写权限
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# versionCode 由 build.gradle.kts 的 gitCommitCount() 取 git rev-list --count HEAD,
# 浅克隆(默认 depth=1)会得到 1,必须拉完整历史,否则 Release APK 版本号错乱。
fetch-depth: 0
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
with:
# 复用 Gradle 构建缓存,加速后续构建。
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# 打印当前 Tag,Tag 驱动直接生成 versionName。
# 仅打 tag 触发时校验;workflow_dispatch(手动,无 tag)跳过。
- name: Display release tag info
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -e
tag_ver="${GITHUB_REF_NAME#v}" # 1.7.0 / 1.7.0-rc1 / ...
echo "tag version (without v) = $tag_ver"
echo "✓ tag 将直接驱动应用 versionName ($tag_ver)"
# 校验 versionCode 单调递增:versionCode = git commit count,rebase/squash 改写历史
# 可能让 commit count 变小,导致新版本 versionCode < 旧版本、升级判定失效。
# 取按提交时间倒序最近的非当前 tag,算其 commit count,要求当前 HEAD 不小于上个 Release。
# 允许相等:rc 转正式版时常在同一提交上打 tag,versionCode 相等是正常的。
# 仅打 tag 触发时校验;workflow_dispatch(手动,无 tag)跳过。
- name: Verify versionCode monotonic
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -e
cur_vc=$(git rev-list --count HEAD)
prev_tag=$(git tag --sort=-creatordate | grep -vE "^${GITHUB_REF_NAME}$" | head -1)
if [[ -z "$prev_tag" ]]; then
echo "无历史 tag,跳过 versionCode 单调校验(首次发版)"
echo "当前 versionCode(commit count) = $cur_vc"
exit 0
fi
prev_vc=$(git rev-list --count "$prev_tag")
echo "上个 Release($prev_tag) versionCode = $prev_vc"
echo "当前(${GITHUB_REF_NAME}) versionCode = $cur_vc"
if [[ "$cur_vc" -lt "$prev_vc" ]]; then
echo "::error::versionCode 回退: 当前 $cur_vc < 上个 Release($prev_tag) $prev_vc。通常因 rebase/squash 改写历史使 commit count 变小。请确保新版本在旧 tag 之上线性推进。"
exit 1
fi
echo "✓ versionCode 单调递增: $prev_vc -> $cur_vc"
- name: Restore release keystore
env:
# keystore 文件以 base64 存入 Secret,避免二进制文件被破坏。
# 还原到 app/aicode.jks,与 build.gradle.kts 期望的位置一致。
KEYSTORE_BASE64: ${{ secrets.AICODE_KEYSTORE_BASE64 }}
run: |
echo "$KEYSTORE_BASE64" | base64 -d > app/aicode.jks
echo "keystore restored ($(stat -c%s app/aicode.jks) bytes)"
- name: Generate keystore.properties
env:
KEYSTORE_PASSWORD: ${{ secrets.AICODE_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.AICODE_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.AICODE_KEY_PASSWORD }}
run: |
cat > app/keystore.properties <<EOF
storeFile=aicode.jks
storePassword=$KEYSTORE_PASSWORD
keyAlias=$KEY_ALIAS
keyPassword=$KEY_PASSWORD
EOF
echo "app/keystore.properties generated"
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# 发版质量门禁:跑单元测试,失败即中止,避免发出回归包。
# 只跑 universal debug 变体:单测逻辑与 flavor 无关,跑全 flavor 6 变体是重复编译。
- name: Run unit tests
run: ./gradlew :app:testUniversalDebugUnitTest
# 一次性构建全部 flavor 的 release APK(universal/armsolo/x86solo)
# --stacktrace:构建失败时输出完整调用栈,便于在 CI 定位 OOM/内存类根因。
- name: Build all flavor Release APKs
run: ./gradlew assembleRelease --stacktrace
# 给三个 APK 重命名为「包名-架构-版本」格式,架构用真实 ABI 名便于用户识别:
# aicode-arm64-v1.0.0-rc1.apk (armsolo)
# aicode-x86_64-v1.0.0-rc1.apk (x86solo)
# aicode-universal-v1.0.0-rc1.apk (universal,含两套镜像)
- name: Rename APKs by flavor
run: |
mkdir -p dist
ver="${GITHUB_REF_NAME:-manual}" # tag 如 v1.0.0-rc1;手动触发时为 manual-N
declare -A abi=( [armsolo]=arm64 [x86solo]=x86_64 [universal]=universal )
for f in universal armsolo x86solo; do
src="app/build/outputs/apk/$f/release/app-$f-release.apk"
if [[ -f "$src" ]]; then
out="dist/aicode-${abi[$f]}-${ver}.apk"
cp "$src" "$out"
echo "packaged $out"
else
echo "::warning::missing $src"
fi
done
ls -lh dist/
- name: Determine release name
id: release
run: |
# 手动触发(无 tag)时用 run id 作为临时名,避免破坏 tag 发布。
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
else
echo "tag=manual-${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT"
fi
- name: Determine prerelease flag
id: prerelease
run: |
tag="${{ steps.release.outputs.tag }}"
if [[ "$tag" =~ -(rc|dev|beta|alpha)([.0-9]*)?$ ]]; then
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "检测到预发布后缀,标记为 pre-release"
else
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "正式版,不标记 pre-release"
fi
# GitHub 的 generate_release_notes 只列 merged PR,直接 push 的 commit 不会出现。
# 本仓库在 main 上直接 commit,不走 PR 流程,所以手动用 git log 生成 changelog。
- name: Generate changelog from git log
id: changelog
if: startsWith(github.ref, 'refs/tags/v')
run: |
cur_tag="${GITHUB_REF_NAME}"
# 正式版(vX.Y.Z 无后缀)从上一个正式版起算,包含整个发布周期(含全部 RC)的提交,
# 保证应用内检测更新能看到完整日志;RC/预发布从最近创建的 tag 起算(增量)。
if [[ "$cur_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
prev_tag=$(git tag --sort=-creatordate | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$" | grep -vE "^${cur_tag}$" | head -1)
echo "正式版:从上一个正式版 ${prev_tag:-无} 起算 changelog(含全部 RC 提交)"
else
prev_tag=$(git tag --sort=-creatordate | grep -vE "^${cur_tag}$" | head -1)
echo "预发布:从最近 tag ${prev_tag:-无} 起算 changelog"
fi
if [[ -z "$prev_tag" ]]; then
echo "无历史 tag,列出最近 30 条提交"
log=$(git log --oneline --no-decorate -30)
else
echo "生成 ${prev_tag}..${cur_tag} 的 changelog"
log=$(git log --oneline --no-decorate "${prev_tag}..HEAD")
fi
# 转义为 GitHub Actions 输出(多行用 EOF 分隔符)
delimiter="EOF_$(date +%s)"
echo "body<<${delimiter}" >> "$GITHUB_OUTPUT"
echo "## What's Changed" >> "$GITHUB_OUTPUT"
echo "" >> "$GITHUB_OUTPUT"
if [[ -z "$log" ]]; then
echo "- 无新增提交(与上次发布同一提交)" >> "$GITHUB_OUTPUT"
else
echo "$log" | while IFS= read -r line; do
# 提取 commit hash 和 message,格式化为列表项
sha=$(echo "$line" | cut -d' ' -f1)
msg=$(echo "$line" | cut -d' ' -f2-)
echo "- ${msg} (${sha})" >> "$GITHUB_OUTPUT"
done
fi
echo "" >> "$GITHUB_OUTPUT"
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${prev_tag}...${cur_tag}" >> "$GITHUB_OUTPUT"
echo "${delimiter}" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release & Upload assets
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.release.outputs.tag }}
name: Release ${{ steps.release.outputs.tag }}
body: ${{ steps.changelog.outputs.body }}
prerelease: ${{ steps.prerelease.outputs.is_prerelease }}
# 产物名约定 aicode-<abi>-<版本>.apk(见 Rename 步骤),故 glob 把 ABI 段放 aicode- 紧后、版本段用 * 兜底。
# 不要写成 aicode-*-<abi>.apk——那会把 abi 误放末段,与 aicode-<abi>-<ver> 永远匹配不上(rc3 翻车原因)。
files: |
dist/aicode-universal-*.apk
dist/aicode-arm64-*.apk
dist/aicode-x86_64-*.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 同步 Release 附件到 Gitee(面向访问不了 GitHub 的用户):
# Gitee 官方「仓库镜像」只同步分支/tag/提交,不会同步 GitHub Release 的 APK 附件,
# 所以这里在发版后用 Gitee API v5 把三个 APK 传到 Gitee Release,供国内用户直接下载。
# 前置配置:
# 1. Gitee 私人令牌(勾选 projects 权限)→ GitHub Secret: GITEE_TOKEN
# 2. (可选)Gitee 用户名/仓库名 → GitHub Variables: GITEE_OWNER / GITEE_REPO,不配则默认与 GitHub 同名
- name: Sync release assets to Gitee
if: startsWith(github.ref, 'refs/tags/v')
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_OWNER: ${{ vars.GITEE_OWNER }}
GITEE_REPO: ${{ vars.GITEE_REPO }}
GITHUB_REPO: ${{ github.repository }}
TAG: ${{ steps.release.outputs.tag }}
RELEASE_NAME: Release ${{ steps.release.outputs.tag }}
RELEASE_BODY: ${{ steps.changelog.outputs.body }}
PRERELEASE: ${{ steps.prerelease.outputs.is_prerelease }}
run: |
set -euo pipefail
if [[ -z "$GITEE_TOKEN" ]]; then
echo "::warning::未配置 GITEE_TOKEN,跳过 Gitee 同步。请到 Settings→Secrets and variables→Actions 添加(Gitee 私人令牌,需 projects 权限)。"
exit 0
fi
owner="${GITEE_OWNER:-${GITHUB_REPO%/*}}"
repo="${GITEE_REPO:-${GITHUB_REPO#*/}}"
api="https://gitee.com/api/v5/repos/${owner}/${repo}"
echo "目标 Gitee 仓库: ${owner}/${repo}"
# 先验证 token 与仓库可达,避免把鉴权错误误判成镜像延迟
if ! curl -fsS "${api}?access_token=${GITEE_TOKEN}" > /dev/null; then
echo "::error::无法访问 Gitee 仓库 ${owner}/${repo},请检查 GITEE_TOKEN 与 GITEE_OWNER/GITEE_REPO"
exit 1
fi
# 等待 Gitee 官方镜像把 tag 同步过来(镜像最短触发间隔 5 分钟),否则创建 Release 会失败。
echo "等待 Gitee 镜像同步 tag ${TAG} ..."
for i in $(seq 1 40); do
if curl -fsS "${api}/tags?access_token=${GITEE_TOKEN}&per_page=100" | \
TAG="$TAG" python3 -c 'import sys,json,os; data=json.load(sys.stdin); print("found" if any(t.get("name")==os.environ["TAG"] for t in data) else "missing")' | grep -q found; then
echo " ✓ tag 已同步(等待 $((i*30)) 秒)"
break
fi
if [[ $i -eq 40 ]]; then
echo "::error::等待 20 分钟 Gitee 仍未同步 tag ${TAG},请检查仓库镜像配置后手动重跑"
exit 1
fi
sleep 30
done
# 按 tag 找已有 Release(幂等复用);没有则创建
release_id=$(curl -fsS "${api}/releases?access_token=${GITEE_TOKEN}&per_page=100" | \
TAG="$TAG" python3 -c 'import sys,json,os; data=json.load(sys.stdin); print(next((r["id"] for r in data if r.get("tag_name")==os.environ["TAG"]), ""))')
if [[ -z "$release_id" ]]; then
echo "创建 Gitee Release ${TAG}"
payload=$(python3 -c 'import json,os; print(json.dumps({"access_token": os.environ["GITEE_TOKEN"], "tag_name": os.environ["TAG"], "name": os.environ["RELEASE_NAME"], "body": os.environ["RELEASE_BODY"], "prerelease": os.environ["PRERELEASE"].lower()=="true", "target_commitish": "main"}, ensure_ascii=False))')
release_id=$(curl -fsS -X POST -H "Content-Type: application/json" \
-d "$payload" "${api}/releases" | \
python3 -c 'import sys, json; print(json.load(sys.stdin).get("id", ""))')
echo " ✓ 已创建(id=${release_id})"
else
echo "Release ${TAG} 已存在(id=${release_id}),复用"
fi
# 上传 APK 附件(Gitee 社区版附件单文件上限 100MB,超限跳过并告警)
max_bytes=$((100 * 1024 * 1024))
for apk in dist/aicode-*.apk; do
[[ -f "$apk" ]] || continue
size=$(stat -c%s "$apk")
if (( size > max_bytes )); then
echo "::warning::$(basename "$apk") ${size} 字节超过 Gitee 附件 100MB 上限,跳过"
continue
fi
echo "上传 $(basename "$apk")($(numfmt --to=iec "$size"))..."
curl -fsS -X POST \
-F "access_token=${GITEE_TOKEN}" \
-F "file=@${apk}" \
"${api}/releases/${release_id}/attach_files" > /dev/null
echo " ✓ 已上传"
done
echo "完成: https://gitee.com/${owner}/${repo}/releases"
# 按保留策略自动清理 Gitee 过期 Release:保留最新 3 个正式版 + 最新正式版之后的全部 RC,
# 防止免费版 1GB 附件配额被历史版本占满(正式版转正后其 RC 自动过期)。
# 脚本幂等:保留集合内缺失的附件会顺带补齐,已存在的跳过。
- name: Prune outdated Gitee releases
if: startsWith(github.ref, 'refs/tags/v')
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 scripts/sync-gitee-releases.py --prune