【鸿蒙专题与实战】持续集成:自动化构建与流水线搭建
📚 鸿蒙专题与实战专栏 · 第 26 篇 / 共 30 篇(专题篇 21-30)
上一篇:多模块工程:HAP、HAR、HSP | 下一篇:测试体系:单元测试与 UI 测试
目录
每次发版都要手动点 Build、手动找包、手动发测试?这是团队效率最大的黑洞。这篇把流水线搭起来——提交代码,包自动出来。

一、先搞清楚命令行工具
DevEco Studio 图形界面背后是 hvigorw 命令行。CI 服务器没有 IDE,全靠它:
# 在项目根目录执行(Linux/macOS)
chmod +x hvigorw
构建 HAP(debug)
./hvigorw assembleHap --mode module -p product=default -p buildMode=debug
构建 release 并自动签名
./hvigorw assembleHap --mode module -p product=default -p buildMode=release
-p signingConfig=default
产物路径
entry/build/default/outputs/default/entry-default-signed.hap
二、签名:CI 上最大的拦路虎
签名文件(.p12 / .cer / .p7b)和密码不能进 Git。标准做法是放流水线密钥变量,构建时写入:
# GitHub Actions 示例片段
- name: Setup signing
run: |
echo "${{ secrets.SIGN_P12 }}" | base64 -d > signing/app.p12
echo "${{ secrets.SIGN_CER }}" | base64 -d > signing/app.cer
echo "${{ secrets.SIGN_P7B }}" | base64 -d > signing/app.p7b
name: Build
run: |
./hvigorw assembleHap --mode module -p product=default
-p buildMode=release -p signingConfig=default --no-daemon
⚠️ build-profile.json5 里 signAlg、profile、certPath 的路径要指向这些临时文件。密码通过 -p 参数或环境变量注入, 绝不硬编码。
常见报错与排查
CI 上签名失败,报错信息往往只有一行,但原因就那么几类。下面这张表覆盖了最常见的 4 种情况,遇到问题先对照排查:
| 报错现象 | 典型原因 | 解决方法 |
|---|---|---|
找不到证书文件 / ENOENT: no such file or directory | build-profile.json5 里的 certPath、profile 路径是相对路径,或指向了本地开发机的绝对路径,CI 上不存在 | 统一改为相对项目根目录的路径(如 signing/app.cer),并在构建前加一步 ls -l signing/ 打印目录,确认密钥变量已正确解码落盘 |
密码错误 / keystore password was incorrect | 密钥变量里存的是带换行的 base64,解码后末尾多了空行;或密码变量名拼写不一致 | 用 base64 -d 后加 tr -d '\n' 清理换行;密码统一走环境变量注入,并在流水线里校验变量非空再继续 |
profile 过期 / profile is invalid or expired | 调试或发布证书的 profile 有有效期,到期后签名校验直接失败 | 在 AGC 控制台重新生成 profile 并更新密钥变量;建议在流水线里加一个到期时间检查,提前 30 天告警 |
签名配置不匹配 / signAlg not match | signAlg 与证书类型不一致,或 signingConfig 名称与 build-profile.json5 中定义的不一致 | 核对 build-profile.json5 中 signingConfigs 的 name、signAlg、certPath、profile 四项是否成套对应,release 构建显式传 -p signingConfig=default |
💡 排查顺序建议:先看文件是否存在,再看密码能否解开 keystore,最后看 profile 是否有效。这三步过了,签名基本就不会再出问题。
三、流水线的最小闭环
| 阶段 | 命令/动作 | 失败则 |
|---|---|---|
| 代码检查 | ESLint + Code Linter | 阻止合并 |
| 单元测试 | hvigorw test | 阻止合并 |
| 构建打包 | hvigorw assembleHap | 阻止合并 |
| 产物归档 | 上传 HAP + 版本号 + commit | 发通知 |
把上面四个阶段落到 GitHub Actions 里,就是一份完整的 .github/workflows/build.yml。下面这份示例包含触发条件、签名准备、版本号递增、构建和产物上传五个 step,每一步都加了注释说明作用:
name: HarmonyOS CI Build
触发条件:推送到主干或发版分支、以及手动触发
on:
push:
branches:
- main
- 'release/**'
workflow_dispatch: {}
jobs:
build:
# 鸿蒙构建依赖 Linux 环境,官方推荐 ubuntu 镜像
runs-on: ubuntu-latest
steps:
# 1. 拉取代码,后续所有步骤都基于这份工作区
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史,versionCode 依赖 git commit 数
# 2. 准备签名文件:从密钥变量解码落盘,绝不进 Git
- name: Setup signing
run: |
mkdir -p signing
echo "${{ secrets.SIGN_P12 }}" | base64 -d > signing/app.p12
echo "${{ secrets.SIGN_CER }}" | base64 -d > signing/app.cer
echo "${{ secrets.SIGN_P7B }}" | base64 -d > signing/app.p7b
ls -l signing/ # 打印目录,确认文件已正确落盘
3. 版本号递增:versionCode 取 commit 数,versionName 按 patch 递增
name: Bump version
run: |
chmod +x scripts/bump-version.sh
./scripts/bump-version.sh patch
4. 构建 release HAP,显式指定签名配置
name: Build HAP
run: |
chmod +x hvigorw
./hvigorw assembleHap --mode module
-p product=default
-p buildMode=release
-p signingConfig=default
--no-daemon
5. 上传产物:HAP 包 + 版本信息,便于回溯与内测分发
name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: harmony-hap-${{ github.run_number }}
path: |
entry/build/default/outputs/default/*.hap
AppScope/app.json5
retention-days: 14</code></pre>
💡 注意两点:checkout 必须带 fetch-depth: 0,否则 git rev-list --count HEAD 只会拿到浅克隆的 1 条记录,versionCode 就失去意义;签名密码通过 secrets 注入,build-profile.json5 里的路径统一指向 signing/ 下的临时文件。
四、版本号自动递增
# 用 git commit 数作为 versionCode,避免手动改漏
VERSION_CODE=$(git rev-list --count HEAD)
# 写入 AppScope/app.json5 的 versionCode 字段后再构建
上面只是思路,真正落到流水线里需要一个可执行的脚本。下面这个 scripts/bump-version.sh 会读取 git commit 数写入 versionCode,同时按策略递增 versionName:
#!/usr/bin/env bash
# scripts/bump-version.sh
# 用法:./scripts/bump-version.sh [patch|minor|major]
set -euo pipefail
APP_JSON="AppScope/app.json5"
BUMP_TYPE="${1:-patch}"
1. versionCode:直接用 commit 数,天然单调递增,无需人工干预
VERSION_CODE=$(git rev-list --count HEAD)
2. 读取当前 versionName,例如 1.4.2
CURRENT_NAME=$(grep -oE '"versionName"\s*:\s*"[0-9]+.[0-9]+.[0-9]+"' "$APP_JSON"
| head -n1 | grep -oE '[0-9]+.[0-9]+.[0-9]+')
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_NAME"
3. 按策略递增 versionName
case "$BUMP_TYPE" in
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
patch) PATCH=$((PATCH + 1)) ;;
) echo "未知递增类型: $BUMP_TYPE" >&2; exit 1 ;;
esac
NEW_NAME="${MAJOR}.${MINOR}.${PATCH}"
4. 用 sed 原地替换 versionCode 与 versionName
sed -i -E "s/("versionCode"\s:\s*)[0-9]+/\1${VERSION_CODE}/" "$APP_JSON"
sed -i -E "s/("versionName"\s*:\s*)"[0-9.]+"/\1"${NEW_NAME}"/" "$APP_JSON"
echo "versionCode: ${VERSION_CODE}"
echo "versionName: ${CURRENT_NAME} -> ${NEW_NAME}"
如果团队更习惯 Node 生态,也可以用一段 node 脚本完成同样的写入,避免 sed 在不同平台上的兼容问题:
// scripts/bump-version.js
const fs = require('fs');
const { execSync } = require('child_process');
const APP_JSON = 'AppScope/app.json5';
const bumpType = process.argv[2] || 'patch';
const versionCode = Number(execSync('git rev-list --count HEAD').toString().trim());
let content = fs.readFileSync(APP_JSON, 'utf8');
const match = content.match(/"versionName"\s*:\s*"(\d+).(\d+).(\d+)"/);
if (!match) throw new Error('未找到 versionName 字段');
let [, major, minor, patch] = match.map(Number);
if (bumpType === 'major') { major += 1; minor = 0; patch = 0; }
else if (bumpType === 'minor') { minor += 1; patch = 0; }
else { patch += 1; }
const versionName = ${major}.${minor}.${patch};
content = content
.replace(/"versionCode"\s*:\s*\d+/, "versionCode": ${versionCode})
.replace(/"versionName"\s*:\s*"[^"]*"/, "versionName": "${versionName}");
fs.writeFileSync(APP_JSON, content);
console.log(versionCode: ${versionCode}, versionName: ${versionName});
在流水线里,只需在构建前插入一步即可:
# 每次构建自动递增 patch 版本
./scripts/bump-version.sh patch
./hvigorw assembleHap --mode module -p product=default -p buildMode=release -p signingConfig=default --no-daemon
versionName 的递增策略
versionCode 交给 commit 数自动管理,versionName 则需要人为约定语义,推荐按下面的规则执行:
- patch(修订号):日常提交、Bug 修复、小改动,流水线默认自动 +1,无需人工干预。
- minor(次版本号):新增功能但保持兼容,由开发在合并到主干时手动触发
./scripts/bump-version.sh minor。 - major(主版本号):存在不兼容变更或大版本重构,由负责人在发版分支上手动触发。
💡 关键原则: versionCode 只增不减、由机器保证;versionName 表达语义、由人决定。两者解耦后,既不会出现版本号冲突,也能让测试同学一眼看出这次包是修 Bug 还是加功能。
💡 流水线跑通后,建议再加一步「自动上传 AGC 预发布」——用 AGC 的 API 上传 HAP,测试就能直接内测下载,彻底告别手动传包。
六、本篇小结
把整篇文章压缩成 4 个关键动作,照着做就能把鸿蒙流水线跑起来:
- 命令行工具:CI 上没有 IDE,一切构建都靠
hvigorw,先确认chmod +x hvigorw和assembleHap能在服务器上跑通。 - 签名密钥注入:.p12 / .cer / .p7b 和密码一律不进 Git,用流水线密钥变量在构建时解码落盘,路径统一指向临时文件。
- 最小闭环四阶段:代码检查 → 单元测试 → 构建打包 → 产物归档,前三步失败即阻止合并,最后一步负责通知。
- 版本号自动递增:
versionCode用 git commit 数自动生成,versionName按 patch / minor / major 语义由人决定。
上线前自查清单
流水线正式接入主干前,逐条打勾确认:
- - [ ] 签名文件(.p12 / .cer / .p7b)未提交到 Git,仓库历史中也无残留
- - [ ] 签名密码、keystore 密码全部走环境变量或密钥变量,代码与配置中无硬编码
- - [ ]
build-profile.json5中 certPath、profile 使用相对项目根目录的路径 - - [ ] 构建前有一步校验密钥变量非空,缺失时直接失败而不是静默跳过
- - [ ]
versionCode由git rev-list --count HEAD生成,不再手动修改 - - [ ]
versionName递增策略已与团队约定,并在发版流程中明确由谁触发 - - [ ] 代码检查与单元测试失败会阻止合并,而不是只发通知
- - [ ] 产物归档包含 HAP、版本号与 commit 信息,便于回溯定位
- - [ ] profile 有效期已记录,并配置了到期前告警
💡 这 9 条全部打勾,说明流水线已经具备「提交代码、包自动出来」的基本能力,剩下的就是持续打磨细节。
五、下一篇预告
流水线里有一环叫「跑测试」,但测试代码怎么写?下一篇:测试体系:单元测试与 UI 测试。
专题篇持续更新,点赞 + 收藏 + 关注不迷路。
想深入哪个方向,评论区留言,点赞高的先写。
更多推荐


所有评论(0)