准备提交一个 HarmonyOS 应用时,最令人头疼的情况并不是“构建失败”本身,而是构建包已经生成,到了发布环节才发现版本号没有递增、包名与平台登记不一致,或者某个 product 根本没有关联预期签名。这三类问题通常散落在不同配置文件里,单靠最后点一次 Release 按钮,发现得太晚。

这一问题可以做成一个独立的小工程:给发布流程增加一道可重复执行的静态预检。它不申请账户权限,不向应用市场提交任何数据,也不取代官方构建与签名验证;只是在开始打包前读取配置,并尽早拦截明确的逻辑错误。与讨论“如何申请证书”的教程相比,这更适合团队协作:它把容易遗忘的配置约束变成每次都能运行的检查。

HarmonyOS 发布构建预检技术封面

三份配置说的是不同层面的事情

首先看工程目录。AppScope/app.json5 管应用身份与版本号,其中 app.bundleName 是应用标识,app.version.code 是系统使用的整数版本号,app.version.name 是展示给用户的版本名称。发布升级时,必须结合上一个已经发布的版本检查 code 是否递增;只看名字从 1.2.0 变成 1.3.0 并不能代替这项约束。包名则要与应用开发者中心登记的一致,不能让不同构建环境各自拼一个后缀而没有产品约定。

工程级 build-profile.json5 负责 product、target、编译模式以及签名绑定等配置。app.signingConfigs 声明可供引用的签名配置,app.products 中的 signingConfig 选择对应名称。签名配置存在不等于它被当前 product 使用;反过来,product 引用了一个不存在的名字,也不能指望构建工具自动猜出正确证书。最后,DevEco Studio 的签名界面与 AppGallery Connect 中的证书、Profile 和权限信息,负责实际签名材料与发布身份的有效性,静态脚本并不知道私钥是否正确、Profile 是否过期,也不应读取私钥内容。

应用身份、版本号、product 与签名配置关系图

下面是能够独立讨论的示例配置片段。它们不是要求读者覆盖现有工程:保持自己项目的既有包名、target 和 SDK 约束,仅核对字段间的关系。version.code 使用示例整数,signingConfig 名称采用 releaseProfile,正式工程须以实际已有配置为准。

// AppScope/app.json5 的 app 节点片段
{
  "app": {
    "bundleName": "com.example.workboard",
    "version": { "code": 1203, "name": "1.2.3" }
  }
}
// 工程级 build-profile.json5 的 app 节点片段
{
  "app": {
    "signingConfigs": [
      { "name": "releaseProfile", "type": "HarmonyOS" }
    ],
    "products": [
      { "name": "default", "signingConfig": "releaseProfile" }
    ]
  }
}

第二段只展示字段关系,省略真实工程中的签名材料和完整 product 配置,不能直接当作可构建的完整 build-profile.json5。尤其不要把 .p12 的密码、私钥或本机绝对路径粘贴到公开文档和仓库。需要部署到 CI 的时候,按实际构建平台的凭据管理机制注入材料,并限制只有发布岗位可以访问。检查脚本只读取签名配置名称,避免把私密信息写进日志。

把“容易忘”变成构建前的拒绝规则

这道预检至少回答五个问题:包名是否符合约定且与注册信息相同;版本号是否为合法整数;本次版本号是否高于已发布基线;当前 product 是否存在;该 product 引用的签名配置是否在 signingConfigs 中定义。还有一个项目治理选择:对于需要发布的 product,如果没有声明 signingConfig,直接阻止进入后续发布链。这个判断只是项目规则,不代表所有调试工程都必须签名。

脚本采用 Node.js,因为它运行在开发机或 CI 上,处理的是 JSON5 配置文本,不属于 ArkTS 应用运行时代码。它使用明确的 json5 解析依赖,而不是自己用正则删除注释。JSON5 允许注释和尾逗号,仅靠 JSON.parse() 并不稳妥。创建一个独立检查脚本(这里仅在文章中展示,最终发布素材目录不附带脚本文件),在工程根目录安装开发依赖:

npm install --save-dev json5
# 示例:检查 default product,上一已发布版本 code 为 1202
node scripts/check-release.cjs default 1202 com.example.workboard

将以下内容保存为 scripts/check-release.cjs。程序不会调用 hvigor 或签名服务;出现问题时设置非零退出码,使本地脚本或 CI 可以依据结果决定是否进入下一阶段。

// scripts/check-release.cjs(Node.js CommonJS)
const fs = require('node:fs');
const JSON5 = require('json5');
const productName = process.argv[2] || 'default';
const baseline = Number(process.argv[3]);
const registeredBundleName = process.argv[4] || '';
const issues = [];

function readJson5(path) {
  try { return JSON5.parse(fs.readFileSync(path, 'utf8')); }
  catch (error) {
    issues.push(`${path} 无法读取或解析:${String(error)}`);
    return {};
  }
}

const app = readJson5('AppScope/app.json5').app || {};
const buildApp = readJson5('build-profile.json5').app || {};
const name = app.bundleName;
const code = app.version && app.version.code;
const displayName = app.version && app.version.name;

if (typeof name !== 'string' ||
    !/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(name)) {
  issues.push('bundleName 缺失或格式不符合基本约束');
}
if (registeredBundleName.length === 0) {
  issues.push('请传入应用市场登记的 bundleName');
} else if (name !== registeredBundleName) {
  issues.push('bundleName 与平台登记不一致');
}
if (!Number.isSafeInteger(code) || code < 0) {
  issues.push('version.code 应为非负整数');
}
if (typeof displayName !== 'string' || displayName.trim() === '') {
  issues.push('version.name 不能为空');
}
if (!Number.isSafeInteger(baseline) || baseline < 0) {
  issues.push('需要有效的已发布基线版本 code');
} else if (Number.isSafeInteger(code) && code <= baseline) {
  issues.push('version.code 没有高于已发布基线');
}

const configs = Array.isArray(buildApp.signingConfigs)
  ? buildApp.signingConfigs : [];
const products = Array.isArray(buildApp.products)
  ? buildApp.products : [];
const product = products.find(item => item.name === productName);
if (!product) {
  issues.push(`找不到 product:${productName}`);
} else if (typeof product.signingConfig !== 'string' ||
           product.signingConfig.length === 0) {
  issues.push(`product ${productName} 未声明签名配置`);
} else if (!configs.some(item => item.name === product.signingConfig)) {
  issues.push(`签名配置 ${product.signingConfig} 不存在`);
}

for (const issue of issues) console.error('预检问题:' + issue);
if (issues.length > 0) process.exitCode = 1;
else console.log(`配置静态预检通过:${productName}`);

这里有一个故意保守的设计:如果没有提供上一版 code 与平台登记包名,程序不放行。CI 中可以把这些值从发布记录或人工审批的只读配置注入,而不是从当前源码里“自己推断上一个正式版本”。发布记录属于流程事实,如果用本次待发布的版本号充当基线,检测就失去了意义。同样,构建成功后也不能把这段输出解释成签名材料校验已完成;它只证明五项可静态读取的关联规则通过。

版本与签名之间还有一层容易忽略的发布渠道差异。同一工程可能有正式产品与内部测试产品两个 product,它们的包名、证书与构建模式未必相同;但是一个 product 的版本策略应当能独立追溯。脚本按 productName 明确选择对象,避免把第一个元素默认为发布对象。如果项目允许 product.bundleName 覆盖应用级包名,还要把这个有效包名带入平台登记校验,不能只检查 AppScope/app.json5。以上基础示例面向没有 product 级包名覆盖的工程,存在覆盖时应该扩展规则,而不是默默忽略差异。

另一个细节是证书材料的管理周期。证书有效期与 Profile 中登记的包名、能力和权限都不体现在脚本的五项字符串比较中。团队可以把到期提醒和授权范围核对作为独立发布检查,由具备权限的发布人员完成;不要为了让静态脚本“看起来全面”,把签名密码写成命令行参数。终端历史、CI 控制台和审计日志都会扩大这类参数的暴露范围。静态规则越窄,越容易确认它没有越权。

一次发布检查应当如何定位问题

先故意把 version.code 调小到与已发布基线相同,预检应明确拒绝;恢复版本后,把 products[0].signingConfig 改为一个未定义名称,应进入另一条错误路径;再恢复正确名称,并核对 bundleName。这些都是配置可重复检查的测试输入,不需要编造打包耗时或真实终端截图。更稳妥的做法是在临时分支中逐一验证,而不是直接修改生产发布分支。

版本递增、签名关联与正式发布检查清单

静态预检通过只是整个发布门禁的开始。下一步使用 DevEco Studio/Hvigor 的实际 release 模式构建,检查生成的目标产物、签名信息、包名、权限清单和真实设备安装表现,并核对 AppGallery Connect 对证书、Profile、受限权限和资质的要求。官方签名文档明确说明自动签名与关联注册应用的条件,不同 IDE 版本、地区和调试环境可能存在差别,不能把“勾选自动签名”概括成所有正式应用的通用发布方案。出现签名报错时,还应检查工程级 signingConfigs.name 与 products.signingConfig 是否匹配,再检查证书有效期及签名材料。

应用上架还涉及隐私信息、资源素材、设备兼容性、受限权限等检查,本篇的静态工具只解决配置关系与版本控制这一窄问题,不擅自替发布平台下结论。实际项目可以把脚本挂在构建入口之前:规则失败就停止,规则通过再由 Hvigor、签名工具和平台审核继续把关。它让发布链变得可复查,也让排障不必从最后一张构建失败截图开始倒推。

官方资料:定制构建模式、自动签名、签名报错问题定位、AppGallery Connect 应用身份配置。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐