Android Release #53
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |