起点:为什么要自己发版
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渲染版本号 + 下载链接"]
整条链路分为四个角色:
- Android 应用仓库:负责构建打包,把产物(APK + 构建元信息)交出去
- 官网仓库:接收产物,上传到 COS,生成版本描述 JSON
- 腾讯云 COS + CDN:存储和分发 APK 和版本信息
- 官网前端:从 COS 读版本信息,动态渲染下载页
应用仓库和官网仓库之间的产物传递,是这套方案里最值得单独讲的部分。
基础设施层:不可变镜像
官网 CI 需要一个工具来上传文件到腾讯云 COS,官方提供了 coscli 这个命令行工具。
最容易想到的做法是:每次 CI 跑的时候临时下载 coscli,装好再用。
这个做法有三个问题:
- 依赖网络,下载这步如果网络抖动,整个 Job 直接失败,和代码本身没有任何关系
- 版本不受控,
latest随时可能是另一个版本,接口可能发生变化(这个项目就踩过这个坑,v1.0.8 删掉了bucket add命令,config init的-m参数也没了) - 每次 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"
这个方法能跑,但有几个问题:
- 需要创建一个有读权限的 Access Token,并把它存为 CI 变量,这个 Token 一旦泄露,可以访问整个 GitLab 实例
- 需要手动维护 project ID 或 path,仓库迁移或重命名就要改配置
- API 鉴权失败时的报错是 HTTP 状态码,不是清晰的 CI 错误信息,排查麻烦
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_NAME | Bucket 名,格式为 name-appid | 否 |
TENCENT_CLOUD_REGION | Bucket 所在地域,如 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 目录根本不出现,或者出现一个空目录,不仔细看日志根本不知道哪里出了问题。
排查顺序:
- 应用仓库
Settings → CI/CD → Token Access,确认官网仓库在白名单里 needs里的project、job、ref三个字段是否和实际配置完全一致(大小写、路径都要对)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% 可复现。