Skip to content
SlippinDylan
Go back

GitLab CI 全链路 APK 自动化发布:跨项目产物传递 + 无状态上传 COS

起点:为什么要自己发版

Android 应用有两条发版路:上架应用商店,或者提供直接下载链接。

上架商店有审核周期,国内各家商店标准各不相同,紧急修复一个 bug 有时候等几天才能过。自己提供下载链接没有这个限制,推送即生效。另外,做 ToB 或内部工具的应用,根本不需要上商店,直接发 APK 是唯一的选择。

这个项目选的是自己托管 APK,放在腾讯云 COS 上,通过 CDN 对外提供下载。COS 上传免费,用户下载走 CDN 节点,流量费用在小规模场景下几乎可以忽略。官网下载页展示版本号、文件大小和下载链接。

问题很快出现了:版本信息要手动维护

每次发版,先构建签名包,然后打开官网仓库,手动改版本号、更新文件大小、改下载链接。两个仓库分开操作,早晚会有一次忘记改,或者改错,导致官网显示的版本和实际 APK 对不上。

这是一个典型的「手动操作迟早出错」的问题,答案是自动化。


为什么是两个仓库

在解释自动化怎么做之前,先说一个更基础的问题:为什么 Android 应用和官网是两个独立的 GitLab 仓库?

最直接的方式是放在一起,一个 monorepo,一条 pipeline 搞定一切。

但这两个项目的发版节奏完全不同:官网改一个文案或者调个样式,不需要重新构建整个 Android 项目;APK 出了新版本,不需要跑一遍前端构建。放在一起,每次任何一边变更都会触发另一边的 CI,资源浪费,而且耦合越来越重。

职责分离的原则同样适用于代码仓库的划分。两个仓库,各自有自己的 pipeline,只在需要协作的时候通过明确的接口传递信息。


整体架构

flowchart TD
  A["git push main\n(Android 应用仓库)"] --> B["apk-build\ngradlew assembleRelease"]
  B --> C["归档 artifacts\napp.apk + metadata.conf"]
  C --> D["trigger-website\n触发官网仓库 pipeline\n携带 UPSTREAM_PIPELINE_ID"]
  D --> E["官网 CI:update-app-assets\nneeds:project 拉取 artifacts"]
  E --> F["解析 metadata.conf\n提取版本 / 日期 / 大小"]
  F --> G["生成 app-info.json"]
  G --> H["coscli cp → 腾讯云 COS"]
  H --> I["官网前端 fetch app-info.json\n渲染版本号 + 下载链接"]

整条链路分为四个角色:

应用仓库和官网仓库之间的产物传递,是这套方案里最值得单独讲的部分。


基础设施层:不可变镜像

官网 CI 需要一个工具来上传文件到腾讯云 COS,官方提供了 coscli 这个命令行工具。

最容易想到的做法是:每次 CI 跑的时候临时下载 coscli,装好再用。

这个做法有三个问题:

  1. 依赖网络,下载这步如果网络抖动,整个 Job 直接失败,和代码本身没有任何关系
  2. 版本不受控latest 随时可能是另一个版本,接口可能发生变化(这个项目就踩过这个坑,v1.0.8 删掉了 bucket add 命令,config init-m 参数也没了)
  3. 每次 Job 都要安装,浪费时间

正确做法是把 coscli 打进 Docker 镜像,一次构建,版本锁死,永远用这个镜像跑 CI。

镜像地址:your-registry.example.com/coscli:v1.0.0
内含版本:coscli v1.0.8

镜像 tag 和 coscli 内部版本号故意不一致,是为了区分”这个镜像的版本”和”镜像里装了什么版本的工具”。镜像 tag 代表的是这个构建单元的版本,不应该和内部某个工具的版本号绑定。

推一次镜像到私有仓库,之后所有 CI Job 都直接 image: your-registry.example.com/coscli:v1.0.0,完全不依赖外网。


Android 应用仓库:生产者

应用仓库的 CI 只负责两件事:构建签名包告诉官网仓库”有新版本了”

构建产物

gradlew assembleRelease 跑完之后,CI 把两个文件归档为 GitLab Artifacts:

artifacts/
├── app-release.apk     # 签名后的正式包
└── metadata.conf       # 构建元信息

Artifacts 是 GitLab 的原生产物存储机制。Job 运行结束后,GitLab 把这些文件保存下来,可以在 UI 上手动下载,也可以被其他 pipeline 通过 API 拉取。后者正是跨仓库传递产物的关键。

metadata.conf 是一个简单的 key=value 文件,记录这次构建的元信息:

versionName=1.0.0
buildTime=2024-01-01T13:39:00Z

为什么不直接用 CI 变量传版本号?

CI 变量可以在触发下游 pipeline 时传入,但文件大小没有办法通过变量传——文件大小只有在 APK 实际构建完成后才能知道,而且每次 CI 环境都是干净的容器,构建完成后官网 CI 无法访问应用仓库的文件系统。把版本信息写进一个和 APK 一起归档的文件,是最干净的做法:所有信息随产物走,不需要额外的传递机制。

触发下游

stages:
  - build
  - release

apk-build:
  stage: build
  script:
    - ./gradlew assembleRelease
    - mkdir -p artifacts
    - cp app/build/outputs/apk/release/app-release.apk artifacts/app-release.apk
    - echo "versionName=$(cat app/version.txt)" > artifacts/metadata.conf
    - echo "buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> artifacts/metadata.conf
  artifacts:
    paths:
      - artifacts/
    expire_in: 7 days

trigger-website:
  stage: release
  trigger:
    project: your-group/website
    branch: main
    strategy: depend       # 等待下游 pipeline 完成,下游失败则上游也标红
  variables:
    TRIGGER_SOURCE: APP_RELEASE          # 告诉官网 CI 这次是谁触发的
    UPSTREAM_PIPELINE_ID: $CI_PIPELINE_ID  # 记录上游 pipeline ID,方便排查血缘关系

strategy: depend 是一个容易被忽略的参数。不加这个,trigger-website job 触发下游 pipeline 后立刻标绿完成,不管官网 CI 是否成功。加了之后,上游 pipeline 会等待下游跑完,下游失败会导致上游整条 pipeline 也标红,从 GitLab UI 上就能一眼看出问题。


跨仓库产物传递:needs:project

这是整套方案里技术含量最高的一步,也是最容易走弯路的地方。

直觉解法的问题

第一反应是用 curl 调 GitLab API 拉 artifacts:

curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  "https://gitlab.example.com/api/v4/projects/123/jobs/artifacts/main/download?job=apk-build"

这个方法能跑,但有几个问题:

GitLab 原生:needs:project + CI Job Token

GitLab 提供了一个更优雅的机制:needs:project,利用 CI Job Token 做跨项目授权,不需要任何额外的 Token。

CI Job Token 是 GitLab 在每次 CI Job 运行时自动生成的临时令牌,生命周期和 Job 一样,Job 结束即失效。它的权限范围可以精确控制:在应用仓库的 Settings → CI/CD → Token Access 里,把官网仓库加入白名单,这样官网的 CI Job 就能用它自己的 Job Token 去拉应用仓库的 artifacts,不需要任何人工管理的长期 Token。

# 官网仓库 .gitlab-ci.yml
update-app-assets:
  stage: deploy
  image: your-registry.example.com/coscli:v1.0.0
  rules:
    - if: '$TRIGGER_SOURCE == "APP_RELEASE"'   # 只在应用仓库触发时才执行这个 Job
  needs:
    - project: your-group/app
      job: apk-build
      ref: main
      artifacts: true    # 声明需要拉取产物,缺这行不会自动下载
  script:
    # artifacts 目录会自动出现在工作区,直接用
    - ls -l artifacts/

rules: if: '$TRIGGER_SOURCE == "APP_RELEASE"' 是另一个关键设计。官网仓库自己也有普通的 push 触发,比如改了前端代码。加了这个条件,只有来自应用仓库的触发才会执行上传 APK 的 Job,普通的前端改动不会跑这个 Job,节省资源,也避免误触发。


官网仓库:消费者

拿到产物之后,这个 Job 做三件事:解析元信息、生成 JSON、上传 COS。

解析构建元信息

# alpine 镜像默认不带完整 coreutils,需要先装
apk add --no-cache grep gawk coreutils

# 从 metadata.conf 提取版本号
VERSION=$(grep "^versionName=" artifacts/metadata.conf | cut -d'=' -f2)

# 提取构建日期(ISO 时间戳只取日期部分)
# buildTime=2024-01-01T13:39:00Z → 2024-01-01
DATE=$(grep "^buildTime=" artifacts/metadata.conf | cut -d'=' -f2 | cut -d'T' -f1)

# 计算 APK 文件大小(字节 → MB,保留两位小数)
SIZE=$(stat -c %s artifacts/app-release.apk | awk '{printf "%.2fMB", $1/1048576}')

文件大小在这一步才计算,因为 APK 真正落地之后才有准确的大小。如果在上游 CI 里计算完再传过来,多一个环节多一个出错点。

生成版本描述 JSON

echo "{
  \"version\": \"v${VERSION}\",
  \"size\": \"${SIZE}\",
  \"updateTime\": \"${DATE}\",
  \"url\": \"https://cdn.example.com/release/app-release.apk\"
}" > artifacts/app-info.json

输出:

{
  "version": "v1.0.0",
  "size": "10.21MB",
  "updateTime": "2024-01-01",
  "url": "https://cdn.example.com/release/app-release.apk"
}

官网前端只需要读这一个 JSON 文件,不需要知道 CI 的任何细节,发版即更新。

无状态上传 COS

这是整个方案里另一个值得单独讲的设计:无状态

传统的 coscli 使用方式是先跑 coscli config init 把鉴权信息写进配置文件,然后再跑 upload 命令。这个方式在 CI 环境里有两个问题:

第一,config init 是一个交互式命令,在 CI 容器里没有终端(non-TTY 环境),它会阻塞等待用户输入,永远不会结束,Job 超时挂掉。

第二,即便能通过某种方式绕过交互,这个初始化步骤产生了一个状态:配置文件。CI 环境是无状态的,每次 Job 都是全新的容器,上一步写的配置文件不会带到下一步(除非挂卷或者走 artifacts,但这完全没必要)。

coscli v1.0.8 的 cp 命令支持直接在命令行传入鉴权参数:

coscli cp artifacts/app-release.apk \
  cos://${TENCENT_CLOUD_BUCKET_NAME}/release/app-release.apk \
  -i $TENCENT_CLOUD_SECRET_ID \
  -k $TENCENT_CLOUD_SECRET_KEY \
  -e cos.${TENCENT_CLOUD_REGION}.myqcloud.com

每条命令完全独立,没有任何前置状态依赖。失败了直接重跑,幂等,安全。

CI 变量配置(在官网仓库 Settings → CI/CD → Variables 里设置):

变量名说明是否 Masked
TENCENT_CLOUD_SECRET_ID腾讯云 API 密钥 ID✅ 必须
TENCENT_CLOUD_SECRET_KEY腾讯云 API 密钥 Key✅ 必须
TENCENT_CLOUD_BUCKET_NAMEBucket 名,格式为 name-appid
TENCENT_CLOUD_REGIONBucket 所在地域,如 ap-guangzhou

Masked 变量在 CI 日志里会被打码,密钥不会明文出现在构建日志里。

完整 Job 配置

update-app-assets:
  stage: deploy
  image: your-registry.example.com/coscli:v1.0.0
  rules:
    - if: '$TRIGGER_SOURCE == "APP_RELEASE"'
  needs:
    - project: your-group/app
      job: apk-build
      ref: main
      artifacts: true
  script:
    - apk add --no-cache grep gawk coreutils

    - ls -l artifacts/

    - VERSION=$(grep "^versionName=" artifacts/metadata.conf | cut -d'=' -f2)
    - DATE=$(grep "^buildTime=" artifacts/metadata.conf | cut -d'=' -f2 | cut -d'T' -f1)
    - SIZE=$(stat -c %s artifacts/app-release.apk | awk '{printf "%.2fMB", $1/1048576}')
    - echo "Version:${VERSION}, Date:${DATE}, Size:${SIZE}"

    - echo "{\"version\":\"v$VERSION\",\"size\":\"$SIZE\",\"updateTime\":\"$DATE\",\"url\":\"https://cdn.example.com/release/app-release.apk\"}" > artifacts/app-info.json

    - coscli cp artifacts/app-release.apk cos://$TENCENT_CLOUD_BUCKET_NAME/release/app-release.apk -i $TENCENT_CLOUD_SECRET_ID -k $TENCENT_CLOUD_SECRET_KEY -e cos.$TENCENT_CLOUD_REGION.myqcloud.com

    - coscli cp artifacts/app-info.json cos://$TENCENT_CLOUD_BUCKET_NAME/release/app-info.json -i $TENCENT_CLOUD_SECRET_ID -k $TENCENT_CLOUD_SECRET_KEY -e cos.$TENCENT_CLOUD_REGION.myqcloud.com

    - echo "APK 同步完成"

官网前端:读 JSON,不关心 CI

官网的前端代码完全不需要知道 CI 的存在,只需要在页面加载时 fetch 一次 COS 上的 JSON 文件:

const res = await fetch('https://cdn.example.com/release/app-info.json');
const appInfo = await res.json();

document.getElementById('version').textContent = appInfo.version;
document.getElementById('size').textContent = appInfo.size;
document.getElementById('download-btn').href = appInfo.url;

每次 CI 完成后,app-info.json 被覆盖写入 COS,CDN 缓存刷新后,官网下一次加载自动展示最新版本。前后端完全解耦,官网不需要发版,不需要改代码,发 APK 就同步。


完整执行时序

sequenceDiagram
  participant Dev as 研发人员
  participant App as Android 应用仓库 CI
  participant Web as 官网仓库 CI
  participant COS as 腾讯云 COS + CDN
  participant Site as 官网前端

  Dev->>App: git push main
  App->>App: gradlew assembleRelease
  App->>App: 归档 artifacts(apk + metadata.conf)
  App->>Web: trigger pipeline(携带 UPSTREAM_PIPELINE_ID)
  Web->>App: needs:project 拉取 artifacts(CI Job Token 免密鉴权)
  Web->>Web: 解析 metadata.conf → VERSION / DATE / SIZE
  Web->>Web: 生成 app-info.json
  Web->>COS: coscli cp app-release.apk
  Web->>COS: coscli cp app-info.json
  Site->>COS: fetch app-info.json(CDN 加速)
  COS-->>Site: 返回版本信息
  Site->>Site: 渲染版本号 + 下载按钮

踩过的坑

coscli 版本不同,命令接口完全变了

这个项目最大的教训之一。最开始直接 apt install coscli 拉最新版,某次更新之后发现 bucket add 命令消失了,config init -m-m 参数也没了。花了不少时间排查才发现是工具版本的 breaking change。

结论:凡是在 CI 里用到的外部工具,必须锁版本,必须打进镜像。

needs:project 拿不到 artifacts,一定先检查 Token Access 白名单

needs:project 鉴权失败的报错信息不够清晰,有时候表现为 artifacts 目录根本不出现,或者出现一个空目录,不仔细看日志根本不知道哪里出了问题。

排查顺序:

  1. 应用仓库 Settings → CI/CD → Token Access,确认官网仓库在白名单里
  2. needs 里的 projectjobref 三个字段是否和实际配置完全一致(大小写、路径都要对)
  3. artifacts: true 有没有写(漏掉这行,GitLab 不会拉 artifacts,只会跑 Job 的依赖关系)

CDN 缓存导致旧版本信息一直显示

CI 跑完了,COS 上的文件也更新了,但官网显示的还是旧版本号。

原因是 CDN 缓存了旧的 app-info.json,TTL 没到不会主动刷新。解决方案有两个:在 coscli 上传时带上禁止缓存的 Header(Cache-Control: no-cache),或者每次发版后手动刷新 CDN 缓存。前者适合版本信息 JSON,后者适合 APK 包体本身(不需要实时刷新)。


关键决策记录

为什么用 needs:project,不用 curl GitLab API

方案优点问题
curl GitLab API灵活,可跨实例需要维护 Access Token,泄露风险高;API 参数复杂;鉴权失败报错难排查
needs:project(采用)原生支持,零 Token 配置;GitLab 内部通道,速度快;失败信息清晰只能用于同一 GitLab 实例

两个仓库都在自托管的同一个 GitLab 实例上,needs:project 没有任何限制,选它。

为什么不用 coscli config init

config init 是交互式命令,在 CI 的 non-TTY 环境里会阻塞。即使绕过了,初始化产生的配置文件在 CI 容器里是临时的,下一步 Job 根本不存在。

Global Flags 直接传鉴权参数,每条命令独立,无状态,幂等,是在 CI 环境里用命令行工具的正确姿势。

为什么锁镜像 tag,不用 latest

latest 意味着任何时候 CI 跑的工具版本都可能不一样。这个项目就因为 coscli 接口变化吃过亏。锁定具体 tag,工具升级前先在本地验证,验证通过再推新镜像、更新 CI 配置。多一个步骤,换来 CI 行为 100% 可复现。


Share this post on:

Next Post
一加 13T 刷入 OxygenOS + KernelSU Root 全流程记录