HarmonyOS 7 DevEco CLI + Node.js:Release 构建前元数据对账、版本号漂移检测与流水线阻断【鸿蒙心迹】
这篇不是讲某个系统 API,而是我最近给 HarmonyOS 发布流程补的一道“小闸门”。起因很普通:测试包已经验收到 2.8.1,准备走 release,结果临打包才发现流水线环境里预期的 versionCode 是 20801,本地 AppScope/app.json5 也确实是 20801,但另一个分支合并进来的发布参数还停在旧版本。包能编,应用也能跑,问题偏偏发生在“最不该出错”的发布前。
我后来做了一个 Release Gate Lab。它不替代 DevEco Studio 的构建,也不代替 AppGallery Connect 的上架检查,只做一件事:在真正执行 release 构建前,把应用元数据、流水线期望值和目标 build mode 摊平对账,只要有一项漂移,就直接退出非 0,后面的构建不再继续。

一、最危险的不是配置错,而是配置“各自都对”
HarmonyOS 工程里,应用级的 bundleName、versionName、versionCode 放在 AppScope/app.json5。构建行为又可能因为 product、buildMode、流水线参数不同而变化。真正上线时还会有一份“这次发布应该是什么”的外部期望值。
问题就出在这些信息不是同一个来源。
开发者看到的可能是:
AppScope/app.json5
versionName = 2.8.1
versionCode = 20801
流水线变量可能是:
EXPECTED_VERSION_NAME = 2.8.0
EXPECTED_VERSION_CODE = 20800
TARGET_BUILD_MODE = release
两边单看都像合理配置,但放在一起就是一次版本漂移。我的目标不是“自动帮你改对”,而是让这种不一致在构建前就变成一个明确失败。
本次 Demo 固定数据为:Build ID = release_gate_20261001_08,bundleName = com.leoyou.releasegate,versionName = 2.8.1,versionCode = 20801,product = default,buildMode = release,最终 Drift = 0,Status = PASS。
二、先把“期望发布什么”单独放出来
我没有让脚本直接猜“当前最大的 versionCode 就是要发布的”。发布目标应该是显式输入。
Demo 里增加了 tools/release.expected.json5:
{
buildId: 'release_gate_20261001_08',
bundleName: 'com.leoyou.releasegate',
versionName: '2.8.1',
versionCode: 20801,
product: 'default',
buildMode: 'release'
}
这份文件可以来自发布分支,也可以由 CI 在运行时生成。关键是它代表“本轮发布目标”,而 app.json5 代表“工程当前实际值”。两者职责不同,才有对账的意义。
正式项目里我还会把 buildId 换成 CI 的流水线编号,并附带 git commit。这样以后拿到一个 release 包,能反查“当时按哪份发布目标验的”。Demo 为了让正文和图片一致,固定使用 release_gate_20261001_08。
三、Node 脚本只做读取、比较、退出,不做偷偷修复
这段代码解决的是核心对账。app.json5 不是严格 JSON,所以脚本用 json5 解析;然后把实际值和 expected 一项项比较。
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import JSON5 from 'json5'
function readJson5(filePath) {
const text = fs.readFileSync(path.resolve(filePath), 'utf-8')
return JSON5.parse(text)
}
const app = readJson5('AppScope/app.json5').app
const expected = readJson5('tools/release.expected.json5')
const actual = {
bundleName: app.bundleName,
versionName: app.versionName,
versionCode: app.versionCode,
product: process.env.TARGET_PRODUCT ?? 'default',
buildMode: process.env.TARGET_BUILD_MODE ?? 'debug'
}
我特意没有在 mismatch 时写回文件。发布脚本如果一边检查一边修改,很容易把问题“修没了”,却没人知道源配置原本不一致。这里更适合失败得清楚一点。
接下来用一个很朴素的 assertEqual 累积差异:
const errors = []
function assertEqual(label, actual, expected) {
if (actual !== expected) {
errors.push(`${label}: expected=${expected}, actual=${actual}`)
} else {
console.log(`[ReleaseGate] ${label} OK -> ${actual}`)
}
}
assertEqual('bundleName', actual.bundleName, expected.bundleName)
assertEqual('versionName', actual.versionName, expected.versionName)
assertEqual('versionCode', actual.versionCode, expected.versionCode)
assertEqual('product', actual.product, expected.product)
assertEqual('buildMode', actual.buildMode, expected.buildMode)
if (errors.length > 0) {
console.error('[ReleaseGate] Found drift:')
errors.forEach(item => console.error(` - ${item}`))
process.exit(1)
}
console.log('[ReleaseGate] drift=0')
console.log('[ReleaseGate] RESULT=PASS')
process.exit(0)
这里最重要的是退出码。日志写得再漂亮,如果 mismatch 后还是返回 0,CI 仍然会继续往下构建。脚本只有两个结果:PASS 继续,FAIL 阻断。
四、buildMode 要作为发布条件,而不是开发者默认值
官方构建资料里明确区分了 debug 和 release 两类编译行为,buildMode 也可以用来定制差异。对我来说,这意味着 release gate 不能只对版本号,还必须检查“这次真的准备按 release 走吗”。
开发机上最容易出现的情况是:版本号已经升了,开发者却仍然在 debug 条件下验证,然后把“能运行”误当成“release 配置没问题”。
所以我在调用 gate 时显式传入:
export TARGET_PRODUCT=default
export TARGET_BUILD_MODE=release
node tools/release-gate.mjs
# gate 返回 0 后,再进入项目现有的 hvigor release 构建步骤
./hvigorw --mode module \
-p module=entry@default \
-p product=default \
assembleHap --info --no-daemon
这里我故意把“校验”和“构建”分成两条命令。项目到底通过 build-profile、流水线参数还是 IDE 配置选择 release,可以继续沿用现有方案;gate 只负责确认流水线声明的目标 buildMode 是 release,并确保版本元数据没有漂移。
如果你们团队还有渠道号、环境名称、后端域名、feature flag,也可以放进同一套 expected/actual 对账里,但不要一口气做成几十项“万能配置中心”。我更倾向于只放那些一旦错了就不应该继续发版的硬条件。
五、为什么我还做了一个运行页,而不是只看终端
脚本本身其实几十行就够了,但我还是给 Demo 做了一个 Release Gate Lab 页面。原因很简单:发布检查经常需要跨角色确认。
开发看终端没问题,测试和内容负责人未必会去读 CI 日志。页面把同一组数据直接展示出来:bundleName、versionName、versionCode、product、buildMode、drift、status、checkedAt。

这次终端输出固定为:
[ReleaseGate] bundleName OK -> com.leoyou.releasegate
[ReleaseGate] versionName=2.8.1
[ReleaseGate] versionCode=20801
[ReleaseGate] buildMode=release
[ReleaseGate] drift=0
[ReleaseGate] RESULT=PASS
DevEco 图里我把 versionCode 那行和 PASS 特意标出来,因为实际出问题最多的往往不是 bundleName,而是版本号在分支、流水线和发布单之间没同步。
六、失败场景比 PASS 更值得设计
Gate 工具真正有价值的不是 PASS,而是失败时能不能让人十秒内知道问题在哪。
我测试过三类失败:
第一类是 versionName 已经改成 2.8.1,但 versionCode 还停在 20800。脚本会只报 versionCode drift,不把所有项都刷成失败。
第二类是工程配置已经是 2.8.1 / 20801,但流水线 expected 仍然是旧值。这时不是自动改工程,而是让发布负责人确认“到底哪份是正确发布目标”。
第三类是版本完全一致,但 TARGET_BUILD_MODE=debug。这种情况同样直接 FAIL,因为当前流程的目标就是 release 构建。
正式项目里我还会要求失败日志带上 commit SHA 和 branch,避免同一个脚本在多个工作目录跑时搞不清来源。
七、手机运行结果只证明“这次对账通过”,不替代上架审核
最终 Demo 页面的数据是:
Build ID = release_gate_20261001_08Bundle Name = com.leoyou.releasegateVersion Name = 2.8.1Version Code = 20801Product = defaultBuild Mode = releaseExpected Version = 2.8.1 / 20801Drift = 0Status = PASSChecked At = 08:05:26

这里的 PASS 只表示“我们定义的发布元数据一致性规则通过”。它不代表应用一定能过市场审核,也不代表签名、隐私、权限、兼容性都已经没问题。这个边界一定要讲清楚,否则一个绿色 PASS 很容易被误解成“全部合规”。
我的做法是把它放在正式构建之前,后面继续走现有的签名、构建、安装验证、测试和上架自检。它更像一道很窄的门:只拦版本元数据和构建目标明显不一致的情况。
八、Gate 还要防一类很隐蔽的“环境漂移”
我后来又遇到一种更隐蔽的情况:仓库里的 app.json5 没问题,release.expected.json5 也没问题,真正出错的是 CI 节点沿用了上一次任务的环境变量。比如上一条流水线构建测试渠道,把 TARGET_PRODUCT 留成 qa;下一条 release 任务复用了同一个 shell,脚本如果只看配置文件,就会给出一个虚假的 PASS。
所以实际接入时,我不会让 gate 自己去“猜”环境。需要参与发布判断的环境变量必须显式传进来,并在日志中完整打印。这样失败时能看到 expected=default, actual=qa,而不是只得到一句“构建失败”。
此外,敏感变量不要写入日志。Gate 需要的是 product、buildMode、版本号这类发布元数据,不应该顺手把签名密码、token、client secret 一起 dump。工具越接近流水线,越要控制日志边界。
九、我把失败输出做成机器和人都能读的两层结果
终端文本适合人看,但流水线后续如果还要生成报告,最好同时产出一个结构化结果。Demo 的正式版会在 tools/out/release-gate-result.json 写入:
{
"buildId": "release_gate_20261001_08",
"drift": 0,
"status": "PASS",
"checkedAt": "08:05:26",
"checks": {
"bundleName": true,
"versionName": true,
"versionCode": true,
"product": true,
"buildMode": true
}
}
失败时同样写文件,只是 status=FAIL,并记录 mismatch 数组。这样 CI 可以在后续阶段直接上传这份报告,而不是再去正则解析 console。
这里还有一个工程取舍:结果文件必须生成在临时产物目录,不要提交回仓库。它属于一次构建的证据,不是源代码配置。否则多人并行发版时,仓库会不停出现无意义的时间戳和 buildId 变更。
十、接入团队流程后,谁来维护 expected 也要讲清楚
工具做完以后,我专门和测试同事讨论过“expected 到底谁改”。如果谁都能随手改,那 gate 只是把口头确认换成了文件确认,价值有限。
我们最后更倾向于让发布单或流水线参数生成 expected,开发仓库只保留读取逻辑。版本负责人确定本轮 2.8.1 / 20801,CI 把它注入;工程当前值如果不一致,开发去改工程并重新提交。这样“发布目标”和“代码实际配置”来自两个独立来源,才真正形成交叉校验。
对于个人项目,可以简单一点,expected 文件跟 release 分支一起维护就够了。但即使只有一个人,我也建议保留这种双来源思路,因为它能防住最常见的复制旧分支、忘记升 versionCode、debug/release 模式混淆。
十一、这套 Gate 最容易被忽略的是“测试用例”
脚本看起来只是几个字符串比较,但我还是给它补了最基本的自动测试。原因是发布工具一旦自己出错,后果比业务页面 bug 更难发现。
我至少覆盖五组输入:完全一致时返回 0;versionCode 少 1 时返回 1;versionName 漂移时只报告对应字段;buildMode=debug 时阻断;expected 文件缺字段时直接报配置错误,而不是把 undefined 当成一个普通值继续比较。
还有一组我认为很重要:versionCode 在 JSON5 里必须保持 number。有人为了方便从环境变量写入,最后变成字符串 '20801'。如果比较逻辑使用宽松等于,这种类型问题会被悄悄放过。所以 Demo 里使用严格比较,并在读取 expected 后做 schema 校验。发布工具宁可在数据类型不明确时失败,也不要“智能兼容”。
实际接 CI 后,我会把 gate 的单元测试放在工具自身的提交检查里。改 release-gate.mjs 的人,必须先让测试通过;真正发版时再用真实 app.json5 和 expected 跑一次集成校验。这样工具逻辑和项目配置分开验证,不会等到发版窗口才发现脚本本身坏了。
十二、版本回滚也不能简单把 versionCode 改回去
发布事故里另一个容易踩的点是“回滚”。业务想回到 2.8.0 的代码,不代表可以把 versionCode 也退回 20800。版本管理通常要求新提交继续使用新的版本序列,因此回滚代码和回滚版本号是两件事。
这也是 expected 文件比“从 git tag 猜版本”更可靠的地方:本轮即使回滚到旧功能,也可以明确声明 versionName=2.8.0-hotfix、versionCode=20802。Gate 不评价版本策略本身,只检查工程实际配置是不是和已经批准的发布目标一致。
换句话说,它不是版本号生成器,而是一致性检查器。把职责收窄,反而更适合长期留在流水线里。
十三、最后保留下来的不是一个脚本,而是一条发布习惯
这个小工具做完以后,我反而更在意流程上的变化。
以前发版前大家会口头确认:“版本号改了吧?”“应该是 release 吧?”“包名没动吧?”这些问题每次都问,却没有一个机器可验证的答案。
现在我希望发布动作是确定的:先生成本轮 expected,跑 gate;drift = 0 才继续;任何 mismatch 都在真正编译之前停下来。这样失败得更早,也更便宜。
脚本本身没有复杂算法,价值在于把“记得检查”变成“没通过就不能继续”。对 HarmonyOS 项目来说,随着 product、buildMode、渠道和多模块越来越多,这种小型工程闸门很适合放进日常工具链里。
如果后面继续扩展,我会优先加两项:一是检查当前 git 是否干净,避免带未提交修改出包;二是把校验结果生成 JSON 归档到流水线产物中。但依旧遵守同一个原则——只检查和记录,不在发布时偷偷帮人改配置。
更多推荐




所有评论(0)