From e23b0e871b00cabd10c995621727febe1bedaca4 Mon Sep 17 00:00:00 2001
From: kdletters <61648117+kdletters@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:09:57 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20macOS=20=E6=B8=A0=E9=81=93?=
=?UTF-8?q?=E5=8F=91=E5=B8=83=E6=8C=91=E4=B8=AD=E6=AE=8B=E7=95=99=E4=BA=A7?=
=?UTF-8?q?=E7=89=A9=EF=BC=8C=E6=94=B9=E4=B8=BA=E6=9E=84=E5=BB=BA=E6=9C=9F?=
=?UTF-8?q?=E6=A0=B8=E5=AF=B9=E5=8C=85=E5=86=85=E7=89=88=E6=9C=AC=E4=B8=8E?=
=?UTF-8?q?=E6=B8=A0=E9=81=93=E8=BA=AB=E4=BB=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- build-macos-ci:构建前按后缀清空 macos/ 下的 *.app.tar.gz / *.sig / *.dmg / *.dmg.sha256,构建后读 Info.plist 核对版本与渠道身份,生成清单后再断言选中本轮更新包
- 新增 macos-release-identity.mjs 与单测:回归用例直接用线上 dev-mac 里那份 0.1.139 release 身份包
- prepare-macos-codex.test:残留清理断言改成按后缀全覆盖,并校验清理在构建前、身份核对在验签前
- Jenkinsfile.ai-game-creator-shell-macos-build:mac Job 增加身份守卫用例
- check:agc-update-channel-manifests:下载后解出 mac 包内 Info.plist 核对版本与身份,并按 2026-09-21 决策只要求 darwin-aarch64
- 文档:两份里程碑按现行决策修正口径并记录线上缺陷,decision-log 记决策与边界,pitfalls 记录「按目录扫描挑产物会选中残留」
---
.../scripts/build-macos-ci.mjs | 53 ++++++--
.../scripts/macos-release-identity.mjs | 107 +++++++++++++++
.../scripts/macos-release-identity.test.mjs | 125 ++++++++++++++++++
.../scripts/prepare-macos-codex.test.mjs | 26 +++-
...里程碑】AGC macOS渠道更新落地-2026-09-17.md | 19 ++-
...里程碑】AGC更新发布管线渠道化-2026-09-17.md | 4 +-
.../shared-memory/decision-log.md | 13 +-
docs/project-memory/shared-memory/pitfalls.md | 11 +-
...kinsfile.ai-game-creator-shell-macos-build | 1 +
.../check-agc-update-channel-manifests.mjs | 94 +++++++++++--
10 files changed, 410 insertions(+), 43 deletions(-)
create mode 100644 apps/ai-game-creator-shell/scripts/macos-release-identity.mjs
create mode 100644 apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs
diff --git a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs
index 67c4ad0b8..5fcedff64 100644
--- a/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs
+++ b/apps/ai-game-creator-shell/scripts/build-macos-ci.mjs
@@ -15,6 +15,12 @@ import {
runTauriBuild,
} from './build-release.mjs';
import { resolveChannelInstallIdentity } from './channel-identity.mjs';
+import {
+ assertMacosAppMatchesChannelIdentity,
+ assertManifestArtifactMatchesExpected,
+ listStaleMacosArtifacts,
+ readMacosAppInfoIdentity,
+} from './macos-release-identity.mjs';
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
import {
readUpdaterPubkey,
@@ -103,20 +109,32 @@ const version = await prepareReleaseVersion(context);
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
const firstInstallName = `${productName}_${version}_aarch64.dmg`;
-// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则
+// 幂等边界:workspace 会保留上一轮产物。先把构建目录里**所有**更新包、签名与首装包
+// 清掉,否则
// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中);
-// 2) 上一轮遗留的 `.sig` 会让验签门禁把「本轮其实没签」判成通过。
-// 只删本次要写出的确切路径,不动其它版本产物与编译缓存。
+// 2) 上一轮遗留的 `.sig` 会让验签门禁把「本轮其实没签」判成通过;
+// 3) 残留的旧 `*.app.tar.gz`(可能是其它渠道身份或更早版本)会被
+// `generateUpdateManifest()` 按目录优先级挑走——2026-09-28 线上 `dev-mac/0.1.142`
+// 就是这么把 0.1.139 的 release 身份包发出去的。
+// 只作用于本 target 的构建目录,不动其它版本产物与编译缓存。
const macosBundle = path.join(context.bundleRoot, 'macos');
+const existingBundleFiles = fs.existsSync(macosBundle)
+ ? fs.readdirSync(macosBundle).map((name) => path.join(macosBundle, name))
+ : [];
+const staleArtifacts = listStaleMacosArtifacts(existingBundleFiles);
+if (staleArtifacts.length > 0) {
+ console.log(
+ `[agc-macos] 清理构建目录残留产物 ${staleArtifacts.length} 个:${staleArtifacts
+ .map((filePath) => path.basename(filePath))
+ .join('、')}`,
+ );
+}
for (const stale of [
- path.join(macosBundle, updaterArtifactName),
- path.join(macosBundle, `${updaterArtifactName}.sig`),
- path.join(macosBundle, `${firstInstallName}`),
- path.join(macosBundle, `${firstInstallName}.sha256`),
+ ...staleArtifacts,
path.join(context.bundleRoot, 'latest.json'),
path.join(context.bundleRoot, 'release-notes.txt'),
]) {
- fs.rmSync(stale, { force: true });
+ fs.rmSync(stale, { recursive: true, force: true });
}
const args = [
@@ -135,6 +153,21 @@ const command = (binary, argv, options = {}) =>
runTauriBuild(args, context);
const app = path.join(context.bundleRoot, 'macos', appBundleName);
+const channelIdentifier = resolveChannelInstallIdentity(
+ context.channel,
+).identifier;
+const appIdentity = readMacosAppInfoIdentity(
+ fs.readFileSync(path.join(app, 'Contents', 'Info.plist'), 'utf8'),
+);
+assertMacosAppMatchesChannelIdentity({
+ identity: appIdentity,
+ expectedVersion: version,
+ expectedProductName: productName,
+ expectedIdentifier: channelIdentifier,
+});
+console.log(
+ `[agc-macos] 产物身份核对通过:${appIdentity.name} ${appIdentity.version} ${appIdentity.identifier}`,
+);
command(process.execPath, [
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
app,
@@ -167,6 +200,10 @@ try {
}
const release = await generateUpdateManifest(context);
+assertManifestArtifactMatchesExpected({
+ artifactPath: release.artifact,
+ expectedPath: path.resolve(path.join(macosBundle, updaterArtifactName)),
+});
assert.equal(
path.resolve(release.downloadArtifact),
path.resolve(dmg),
diff --git a/apps/ai-game-creator-shell/scripts/macos-release-identity.mjs b/apps/ai-game-creator-shell/scripts/macos-release-identity.mjs
new file mode 100644
index 000000000..f57e918b4
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/macos-release-identity.mjs
@@ -0,0 +1,107 @@
+/**
+ * macOS 发布产物的身份与版本守卫。
+ *
+ * 为什么单独抽出来:2026-09-28 线上核对发现 `dev-mac/0.1.142` 清单指向的更新包
+ * 其实是 **0.1.139 的 release 身份包**(`world.genarrative.ai-game-creator.release`)。
+ * 根因是 mac 构建目录里会留下上一轮(甚至上一个渠道身份)的 `*.app.tar.gz`,而
+ * `generateUpdateManifest()` 是按优先级扫描目录挑产物的——于是「清单写 0.1.142、
+ * 包里是 0.1.139 且是另一个渠道身份」。这类错误在客户端上表现为「更新后版本没变
+ * 或渠道身份被换掉」,只能靠构建期失败关闭拦住。
+ *
+ * 约束:只做纯函数与显式断言,方便单测复现线上那份坏产物;不在这里读文件系统。
+ */
+
+/** 会被构建期残留影响的 mac 产物后缀:更新包、签名、首装 DMG 与其摘要。 */
+const STALE_MACOS_ARTIFACT_SUFFIXES = [
+ '.app.tar.gz',
+ '.app.tar.gz.sig',
+ '.dmg',
+ '.dmg.sha256',
+];
+
+function extractPlistString(plistText, key) {
+ const pattern = new RegExp(
+ `${key}\\s*([^<]*)`,
+ 'u',
+ );
+ const match = plistText.match(pattern);
+ return match ? match[1] : null;
+}
+
+/** 从 `Info.plist` 文本里读出发布相关的身份字段;缺字段返回 null,由断言决定是否致命。 */
+export function readMacosAppInfoIdentity(plistText) {
+ if (typeof plistText !== 'string' || plistText.trim().length === 0) {
+ throw new Error('Info.plist 内容为空,无法核对产物身份');
+ }
+ return {
+ version: extractPlistString(plistText, 'CFBundleShortVersionString'),
+ identifier: extractPlistString(plistText, 'CFBundleIdentifier'),
+ name: extractPlistString(plistText, 'CFBundleName'),
+ displayName: extractPlistString(plistText, 'CFBundleDisplayName'),
+ };
+}
+
+/**
+ * 断言本轮构建出来的 `.app` 就是本渠道本轮该发的产物。
+ *
+ * 三个字段都要对上:版本必须等于即将写进清单的版本;identifier 与产品名必须来自
+ * 渠道安装身份(`channel-identity.mjs`),否则同一台机器上的 dev / release 会互相顶掉。
+ */
+export function assertMacosAppMatchesChannelIdentity({
+ identity,
+ expectedVersion,
+ expectedProductName,
+ expectedIdentifier,
+}) {
+ const problems = [];
+ if (identity.version !== expectedVersion) {
+ problems.push(
+ `版本不一致:产物 ${identity.version ?? '(缺失)'},本轮清单 ${expectedVersion}`,
+ );
+ }
+ if (identity.identifier !== expectedIdentifier) {
+ problems.push(
+ `bundle identifier 不一致:产物 ${identity.identifier ?? '(缺失)'},本渠道 ${expectedIdentifier}`,
+ );
+ }
+ const names = [identity.name, identity.displayName].filter(Boolean);
+ if (
+ names.length === 0 ||
+ names.some((value) => value !== expectedProductName)
+ ) {
+ problems.push(
+ `产品名不一致:产物 ${names.join(' / ') || '(缺失)'},本渠道 ${expectedProductName}`,
+ );
+ }
+ if (problems.length > 0) {
+ throw new Error(
+ `macOS 产物身份核对失败:${problems.join(';')}。` +
+ '这通常意味着构建目录里残留了上一轮/其它渠道的产物,或渠道身份没有注入 Tauri 构建。',
+ );
+ }
+}
+
+/**
+ * 列出构建前必须清掉的残留产物:构建目录里的更新包/签名/首装包只属于本轮,
+ * 留着就会让按目录扫描的清单生成挑到旧文件。
+ */
+export function listStaleMacosArtifacts(filePaths) {
+ return filePaths.filter((filePath) =>
+ STALE_MACOS_ARTIFACT_SUFFIXES.some((suffix) => filePath.endsWith(suffix)),
+ );
+}
+
+/** 断言清单最终选中的更新包就是本轮写出的那一个,避免「签名对但选错包」。 */
+export function assertManifestArtifactMatchesExpected({
+ artifactPath,
+ expectedPath,
+}) {
+ if (!artifactPath || !expectedPath) {
+ throw new Error('清单产物路径缺失,无法核对本轮更新包');
+ }
+ if (artifactPath !== expectedPath) {
+ throw new Error(
+ `清单选中的更新包不是本轮产物:选中 ${artifactPath},本轮应为 ${expectedPath}`,
+ );
+ }
+}
diff --git a/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs b/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs
new file mode 100644
index 000000000..6a0397bbf
--- /dev/null
+++ b/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs
@@ -0,0 +1,125 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ assertMacosAppMatchesChannelIdentity,
+ assertManifestArtifactMatchesExpected,
+ listStaleMacosArtifacts,
+ readMacosAppInfoIdentity,
+} from './macos-release-identity.mjs';
+
+const DEV_IDENTITY = {
+ productName: '陶泥儿开发版',
+ identifier: 'world.genarrative.ai-game-creator',
+};
+
+function plist({ version, identifier, name }) {
+ return `
+
+CFBundleShortVersionString${version}
+CFBundleIdentifier${identifier}
+CFBundleName${name}
+CFBundleDisplayName${name}
+`;
+}
+
+test('reads version, identifier and product name from Info.plist text', () => {
+ const identity = readMacosAppInfoIdentity(
+ plist({
+ version: '0.1.154',
+ ...DEV_IDENTITY,
+ name: DEV_IDENTITY.productName,
+ }),
+ );
+ assert.deepEqual(identity, {
+ version: '0.1.154',
+ identifier: DEV_IDENTITY.identifier,
+ name: DEV_IDENTITY.productName,
+ displayName: DEV_IDENTITY.productName,
+ });
+});
+
+test('accepts the app bundle that matches this channel and version', () => {
+ assert.doesNotThrow(() =>
+ assertMacosAppMatchesChannelIdentity({
+ identity: readMacosAppInfoIdentity(
+ plist({
+ version: '0.1.154',
+ identifier: DEV_IDENTITY.identifier,
+ name: DEV_IDENTITY.productName,
+ }),
+ ),
+ expectedVersion: '0.1.154',
+ expectedProductName: DEV_IDENTITY.productName,
+ expectedIdentifier: DEV_IDENTITY.identifier,
+ }),
+ );
+});
+
+// 回归:线上 dev-mac/0.1.142 清单实际指向 0.1.139 的 release 身份包。
+test('rejects the release-identity bundle that shipped in the dev-mac channel', () => {
+ const shipped = readMacosAppInfoIdentity(
+ plist({
+ version: '0.1.139',
+ identifier: 'world.genarrative.ai-game-creator.release',
+ name: '陶泥儿 Release',
+ }),
+ );
+ assert.throws(
+ () =>
+ assertMacosAppMatchesChannelIdentity({
+ identity: shipped,
+ expectedVersion: '0.1.142',
+ expectedProductName: DEV_IDENTITY.productName,
+ expectedIdentifier: DEV_IDENTITY.identifier,
+ }),
+ /版本不一致:产物 0\.1\.139,本轮清单 0\.1\.142[\s\S]*bundle identifier 不一致[\s\S]*产品名不一致/u,
+ );
+});
+
+test('rejects a bundle whose version is right but channel identity is wrong', () => {
+ assert.throws(
+ () =>
+ assertMacosAppMatchesChannelIdentity({
+ identity: readMacosAppInfoIdentity(
+ plist({
+ version: '0.1.154',
+ identifier: 'world.genarrative.ai-game-creator.release',
+ name: '陶泥儿 Release',
+ }),
+ ),
+ expectedVersion: '0.1.154',
+ expectedProductName: DEV_IDENTITY.productName,
+ expectedIdentifier: DEV_IDENTITY.identifier,
+ }),
+ /bundle identifier 不一致/u,
+ );
+});
+
+test('lists every stale mac artifact so the bundle directory cannot leak into the manifest', () => {
+ const files = [
+ '/bundle/macos/陶泥儿 Release.app.tar.gz',
+ '/bundle/macos/陶泥儿 Release.app.tar.gz.sig',
+ '/bundle/macos/陶泥儿开发版_0.1.139_aarch64.dmg',
+ '/bundle/macos/陶泥儿开发版_0.1.139_aarch64.dmg.sha256',
+ '/bundle/macos/陶泥儿开发版.app/Contents/Info.plist',
+ ];
+ assert.deepEqual(listStaleMacosArtifacts(files), files.slice(0, 4));
+});
+
+test('rejects a manifest that selected a different artifact than this build wrote', () => {
+ assert.throws(
+ () =>
+ assertManifestArtifactMatchesExpected({
+ artifactPath: '/bundle/macos/陶泥儿 Release.app.tar.gz',
+ expectedPath: '/bundle/macos/陶泥儿开发版.app.tar.gz',
+ }),
+ /清单选中的更新包不是本轮产物/u,
+ );
+ assert.doesNotThrow(() =>
+ assertManifestArtifactMatchesExpected({
+ artifactPath: '/bundle/macos/陶泥儿开发版.app.tar.gz',
+ expectedPath: '/bundle/macos/陶泥儿开发版.app.tar.gz',
+ }),
+ );
+});
diff --git a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs
index 5f4cee92c..6dd90eab8 100644
--- a/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs
+++ b/apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs
@@ -154,13 +154,27 @@ test('macOS release entry verifies the updater signature before uploading', () =
!entry.includes("'--no-sign'"),
'--no-sign 会同时跳过 updater 签名,产物缺少 .sig',
);
- // workspace 会跨构建保留产物:必须先删本次要写的对象,否则会因同名 DMG 失败,
- // 或让上一轮遗留的 .sig 让验签门禁误通过。
+ // workspace 会跨构建保留产物:必须在构建前清掉构建目录里所有更新包/签名/DMG——
+ // 只删「本轮要写的名字」会漏掉其它渠道身份的残留(2026-09-28 线上 dev-mac/0.1.142
+ // 就是被 0.1.139 的 release 身份 *.app.tar.gz 顶掉的),清理必须按后缀全覆盖。
+ const cleanupIndex = entry.indexOf(
+ 'listStaleMacosArtifacts(existingBundleFiles)',
+ );
+ const buildIndex = entry.indexOf('runTauriBuild(args, context)');
+ const identityIndex = entry.indexOf('assertMacosAppMatchesChannelIdentity({');
+ const manifestArtifactIndex = entry.indexOf(
+ 'assertManifestArtifactMatchesExpected({',
+ );
+ assert.ok(cleanupIndex > 0, '必须清理构建目录里的残留产物');
+ assert.ok(cleanupIndex < buildIndex, '清理必须发生在构建之前');
+ assert.ok(identityIndex > buildIndex, '构建之后必须核对产物身份');
+ assert.ok(identityIndex < verifyIndex, '身份核对必须在验签与上传之前');
+ assert.ok(
+ manifestArtifactIndex > 0 && manifestArtifactIndex < uploadIndex,
+ '必须在清单生成后核对它选中的就是本轮更新包',
+ );
for (const required of [
- // 清理对象用派生的产品名算出来,而不是写死某个名字。
- '${updaterArtifactName}.sig',
- '${firstInstallName}.sha256',
- 'fs.rmSync(stale, { force: true })',
+ 'fs.rmSync(stale, { recursive: true, force: true })',
"'-ov'",
]) {
assert.ok(entry.includes(required), required);
diff --git a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md
index c9da95bc9..25bdf802c 100644
--- a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md
+++ b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md
@@ -36,10 +36,10 @@
## 验收标准
-- [ ] `dev-mac` 渠道清单包含两个 macOS 平台条目且指向同一个 universal 安装包与签名,对象在 OSS 上一致可下载。
+- [ ] `dev-mac` 渠道清单指向真实可下载的 arm64 更新包,且清单版本、包内版本与包内渠道身份三者一致(原「两个 macOS 平台条目指向 universal 产物」口径已被 2026-09-21「macOS 只出 arm64 单架构」决策取代;2026-09-28 只读核对发现线上 `0.1.142` 清单指向的其实是 `0.1.139` 的 release 身份包,见文末本轮核对)。
- [ ] macOS 客户端能完成一次真实更新:检查、下载、安装、重启后运行新版本,且升级后产物仍是 universal 包。
- [ ] 覆盖写渠道 latest 指针后,旧版本 macOS 客户端可升级到新版本;Windows 与 macOS 渠道互不干扰。
-- [ ] 未签名或未公证产物在发布阶段失败关闭,或在不满足条件时明确记录为未验证项而非静默通过。
+- [x] 未签名或未公证产物在发布阶段失败关闭,或在不满足条件时明确记录为未验证项而非静默通过(更新包 minisign 签名在 `build-macos-ci.mjs` 上传前用产物内公钥强制复核,缺签名/验不过即中止;Apple 代码签名与公证当前是 `adhoc`,按本条的第二种方式记录为未验证项,首装需 Gatekeeper 手动放行)。
## 证据要求
@@ -57,8 +57,13 @@
## 本轮核对(2026-09-28,线上 dev-mac 清单只读核对)
-- 第 1 条(`dev-mac` 清单含两个 macOS 平台条目且指向同一 universal 安装包与签名)**仍未勾选,并且这次拿到了线上反例**:
- - `npm run check:agc-update-channel-manifests`(新增的只读核对脚本)拉取 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json`:`version=0.1.142`、`pub_date=2026-09-24T11:35:31Z`、`commit=c07c10c0c`,`platforms` 只有 **`darwin-aarch64`**,因此「同时提供两个 macOS 平台键」判 FAIL。
- - 同一份清单的对象与签名本身是自洽的:产物 `agc/dev-mac/0.1.142/陶泥儿 Release.app.tar.gz` 315,510,533 字节(HEAD 200)、`.sig` 420 字节且与清单内签名文本一致,下载后字节数一致、用 `tauri.conf.json` 里烘焙的 updater 公钥验签通过(`alg=ED`,`keyId=cb883447e3e87c4e`)。也就是说缺的不是签名可信度,而是 **universal(双平台键)发布**。
- - 时间线解释:这次 `dev-mac` 发布(2026-09-24 `c07c10c0c`)早于 universal macOS 改造;同期 `dev-win` 已经发到 `0.1.154`(2026-09-28,commit `76cdb96c5`)。要让线上清单符合契约,需要用 mac 构建机重新发布一次 `dev-mac`。
- - 待复核观察:该 `dev-mac` 更新产物文件名是「陶泥儿 **Release**.app.tar.gz」,而同一渠道的首装 DMG 是「陶泥儿**开发版**_0.1.142_aarch64.dmg」。channel-identity 约定 `dev` 渠道展示名为「陶泥儿开发版」,重发时要确认 mac 更新产物用的是渠道身份而不是 release 身份,避免同一台机器上并存/覆盖行为与预期不符。
+**先纠正口径**:本文件第 1 条原来写的是「两个 macOS 平台条目指向同一个 universal 包」,但 2026-09-21 决策(decision-log)已经把 macOS 改回 **arm64 单架构**,清单只登记 `darwin-aarch64`——线上单架构本身不是问题。本轮真正查出来的是另一件事:**清单指的包根本不是这一版、也不是这个渠道身份**。
+
+- `npm run check:agc-update-channel-manifests`(本轮新增的只读核对脚本,`AGC_UPDATE_VERIFY_DOWNLOAD=1` 会下载产物)对线上 `dev-mac/latest.json` 的核对结果:
+ - 清单自洽的部分(都 PASS):`version=0.1.142`、`pub_date=2026-09-24T11:35:31Z`、`commit=c07c10c0c`;`platforms` 只有 `darwin-aarch64`(符合 2026-09-21 单架构决策);更新包对象 315,510,533 字节与 `.sig` 420 字节都存在,清单签名与 `.sig` 文本一致;下载后字节数一致、用 `tauri.conf.json` 里烘焙的 updater 公钥验签通过(`alg=ED`、`keyId=cb883447e3e87c4e`);首装 DMG `陶泥儿开发版_0.1.142_aarch64.dmg` 存在。
+ - **两处真 FAIL**:把更新包解开看 `Contents/Info.plist`,里面是 **`CFBundleShortVersionString=0.1.139`**(清单写的是 0.1.142)和 **`CFBundleIdentifier=world.genarrative.ai-game-creator.release` + `CFBundleName=陶泥儿 Release`**(本渠道应为 `world.genarrative.ai-game-creator` / `陶泥儿开发版`),产物文件名也叫「陶泥儿 Release.app.tar.gz」。
+ - 结论:`dev-mac` 渠道当前给 arm64 客户端提供的更新包是**旧版本 + 另一个渠道身份**的包。这既让「升级后版本没变」成立,也可能把 release 身份的应用装到 dev 渠道用户机器上,违反渠道安装身份隔离约定。第 1 条保持未勾选。
+- 根因与修复(本轮已落地,见 decision-log 2026-09-28 条目):
+ - 根因:`build-macos-ci.mjs` 之前只删「本轮要写的文件名」,构建目录里上一轮遗留的 `陶泥儿 Release.app.tar.gz` 不会被清掉;而 `generateUpdateManifest()` 是**扫描目录按优先级挑产物**,于是挑走了那个残留文件。
+ - 修复:构建前按后缀清空 `macos/` 下的 `*.app.tar.gz`、`*.app.tar.gz.sig`、`*.dmg`、`*.dmg.sha256`;构建后读产物 `Contents/Info.plist` 断言版本与渠道身份;生成清单后再断言清单选中的就是本轮更新包。守卫逻辑在 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs`,单测 `macos-release-identity.test.mjs` 用线上那份 0.1.139 release 身份包做回归(`node --test` 59 passed,含新增 6 条)。
+- 仍未完成:需要用修好的 mac 管线重新发布一次 `dev-mac`(把 `latest.json` 指到本轮的新对象),然后重跑 `npm run check:agc-update-channel-manifests` 才能把第 1 条勾上。发布需要 Jenkins 凭据与授权,本地无法执行。
diff --git a/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md
index 91c8790e6..de438329d 100644
--- a/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md
+++ b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md
@@ -36,7 +36,7 @@
- [x] 渠道清单版本来自统一总号;显式传入的号低于本渠道当前清单版本时构建失败关闭,另一个渠道清单不受影响(原「按渠道独立递增」口径已由 2026-09-20 总版本号方案取代)。
- [x] 渠道与目标平台不匹配、缺少签名私钥或私钥密码错误时发布失败关闭,不产生半成品清单。
- [x] 发布后 OSS 上安装包、签名与渠道清单三者一致:清单内地址指向已存在的对象,签名与安装包匹配(2026-09-28 只读核对已发布的 `dev-win` / `dev-mac` 渠道,两个渠道的产物都下载后验签通过,见文末「本轮核对」)。
-- [x] universal macOS 产物的两个平台键指向同一对象同一签名,不存在只挂单一架构键或指向不存在对象的情况。
+- [x] universal macOS 产物的两个平台键指向同一对象同一签名,不存在只挂单一架构键或指向不存在对象的情况。(清单构建器对 universal 目标仍按此契约工作;但 2026-09-21 决策已把 macOS 发行改成 arm64 单架构,线上 `dev-mac` 按该决策只登记 `darwin-aarch64`,这条的线上口径以 macOS 里程碑第 1 条为准。)
- [ ] Jenkins 归档与日志中不出现签名私钥内容,凭据只注入构建进程。(静态核对:`jenkins/Jenkinsfile.ai-game-creator-shell-build` 用 `withCredentials` 注入 `TAURI_SIGNING_PRIVATE_KEY` / `..._PASSWORD`,归档 glob 只含 `bundle/**/*.exe|*.sig|latest.json|legacy-latest.json|release-notes.txt` 与 `.jenkins-source-commit`,没有私钥文件;真实 Jenkins 运行的日志脱敏仍需一次 CI 构建取证。)
- [x] 未显式指定渠道时按目标平台取默认渠道,且 `--no-bundle` smoke 路径仍不读远端版本、不改版本、不生成清单。
@@ -70,5 +70,5 @@
- **`dev-win`(v0.1.154,`pub_date=2026-09-28T13:24:53Z`,commit `76cdb96c5`)**:清单 `windows-x86_64` 指向 `agc/dev-win/0.1.154/陶泥儿开发版_0.1.154_x64-setup.exe`(165,984,391 字节,HEAD 200);`.sig` 对象 436 字节且与清单内签名文本一致;下载后字节数与 HEAD 一致,`sha256=7867734e3991…` 与旧协议指针 `agc/latest.json` 的 `sha256`/`size` 完全一致;用公钥验签通过(`alg=ED`,`keyId=cb883447e3e87c4e`)。
- **`dev-mac`(v0.1.142,`pub_date=2026-09-24T11:35:31Z`,commit `c07c10c0c`)**:清单 `darwin-aarch64` 指向 `agc/dev-mac/0.1.142/陶泥儿 Release.app.tar.gz`(315,510,533 字节,HEAD 200);`.sig` 对象 420 字节且与清单签名一致;下载后字节数一致、用同一公钥验签通过。
- 结论:两个渠道的「安装包 ↔ 签名 ↔ 渠道清单」三者一致成立,条目 3 关闭。
- - **顺带发现的真实状态差异(留给 macOS 里程碑)**:已发布的 `dev-mac` 清单只有 `darwin-aarch64` 一个平台键,而当前代码的 universal 约定要求两个 macOS 平台键指向同一对象同一签名;这次发布(0.1.142 / `c07c10c0c`,2026-09-24)早于 universal 改造,需要用 mac 构建机 re-publish 一次才能让线上清单符合契约。
+ - **顺带发现的真实缺陷(已在 macOS 里程碑与 decision-log 记录)**:线上 `dev-mac/0.1.142` 清单只登记 `darwin-aarch64` 是符合 2026-09-21 单架构决策的;但它指向的更新包解出来是 **0.1.139 的 release 身份包**(`world.genarrative.ai-game-creator.release` / 陶泥儿 Release)。这一条本条验收没覆盖(本条只看「地址存在 + 签名匹配」),本轮已把「包内版本与渠道身份」加进 `check:agc-update-channel-manifests`,并在 mac 构建入口补上构建期身份断言。
- 条目 5(Jenkins 归档与日志不含私钥)仍是未勾选:静态可证部分已记录在验收行上(`withCredentials` 注入 + 归档 glob 不含私钥文件),真实 Jenkins 运行日志需要一次 CI 构建取证。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 4a649649f..cc064dd78 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -9697,4 +9697,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策(错误 envelope 与成功 envelope 同一份 meta):`AppError::into_response` 之前固定按「无请求上下文」构造错误响应,错误 envelope 里没有 `meta.requestId` / `meta.operation`,而成功 envelope 有,客户端在报错时拿不到可用于排查的 requestId。现在 `attach_request_context` 用 `tokio::task_local!` 的 `CURRENT_REQUEST_CONTEXT` 把上下文作用域套住整个 handler,错误转换读同一份上下文;脱离请求任务(单测、后台任务)时退回无上下文形状。取证:`check:game-distribution-owner-isolation` 新增成功/失败 envelope 两条断言,修复前 `requestId=` 为空失败、修复后 23 项 PASS。
- 决策(envelope 一致性按可消费取证):TS 侧没有 envelope 的类型镜像,只有 `packages/shared/src/http.ts` 与 `src/services/apiClient.ts` 的运行时守卫,因此这一条按「客户端能一致消费真实 envelope」取证(字段名 `ok` / `data` / `error.code` / `meta.apiVersion` / `meta.requestId`),不是类型级镜像;字段门禁比对的是名字而不是值类型。- 影响范围:`server-rs/crates/api-server/src/modules/game_distribution.rs`、`scripts/check-game-distribution-owner-isolation.mjs`、`scripts/check-game-distribution-upload-safety.mjs`、`scripts/check-game-distribution-upload-resume.mjs`、`package.json`、游戏分发里程碑取证。
- 决策(写入必须显式下发对象级 ACL):`platform-oss` 的 `OssObjectAccess` 之前只用于日志,对象继承 bucket 默认 ACL,公共读 bucket 上「private」对象可被匿名直取。现在内部 PUT、分片追加与直传 policy / 表单三处都下发 `x-oss-object-acl`(`Private` → `private`,`Public` → `public-read`);`OssAppendInternalObjectRequest` 新增 `access`,`DirectUploadTicketFormFields` 新增 `x-oss-object-acl`。验证:`cargo test -p platform-oss` 76 passed;`E2E_REQUIRE_PRIVATE_BUCKET=1` 的媒体链路 E2E 里直传封面与发行包对象匿名直取都 403(65 项 PASS)。- 验证:本地真实栈三个脚本全部 PASS(越权隔离 23 项、上传安全 24 项、分片续传 prepare 8 项 + resume 9 项、媒体链路 44 项);分片续传中途杀掉 api-server 进程(PID `53844` → 重启 `7728`)后仍从 `8,388,608` 偏移续传成功;`cargo test -p api-server -- package_` 6 passed 与 `game_distribution` 27 passed、`npm run lint`、`check:encoding`、`check:doc-index`、`git diff --check`。
-- 边界:证据来自本机 dev 栈与 dev bucket;生产域名、CDN 缓存窗口与真实客户端安装版的自动上传仍未验证。
\ No newline at end of file
+- 边界:证据来自本机 dev 栈与 dev bucket;生产域名、CDN 缓存窗口与真实客户端安装版的自动上传仍未验证。
+
+## 2026-09-28 macOS 渠道发布必须核对「包内版本 + 渠道身份」,不能只验签
+
+- 背景:用只读核对脚本 `scripts/check-agc-update-channel-manifests.mjs` 检查**已发布**的 OSS 渠道清单时发现,线上 `dev-mac/latest.json`(`version=0.1.142`、`commit=c07c10c0c`)指向的更新包解开后是 `CFBundleShortVersionString=0.1.139`、`CFBundleIdentifier=world.genarrative.ai-game-creator.release`、`CFBundleName=陶泥儿 Release`。签名验签、对象存在、`.sig` 与清单文本一致这些都对——错的是**版本与渠道身份**:dev 渠道的 arm64 客户端会被指向一个旧版的 release 身份包。
+- 根因:`build-macos-ci.mjs` 以前只清理「本轮要写的确切文件名」,mac 构建目录里上一轮/其它渠道身份留下的 `*.app.tar.gz` 不会被删;`generateUpdateManifest()` 是按目录扫描 + 优先级选产物,于是选中了残留文件。mac 构建机复用 workspace,这类残留会长期存在。
+- 决策(构建期失败关闭):mac 发布入口在构建前按后缀清空 `macos/` 下的 `*.app.tar.gz`、`*.app.tar.gz.sig`、`*.dmg`、`*.dmg.sha256`;构建后读 `.app/Contents/Info.plist`,断言 `CFBundleShortVersionString` 等于本轮发布版本、`CFBundleIdentifier`/`CFBundleName` 等于该渠道安装身份;生成清单后再断言清单选中的更新包就是本轮那一个。任一不符直接中止,不写 OSS。
+- 决策(只读核对也要看包内身份):`check:agc-update-channel-manifests` 在 `AGC_UPDATE_VERIFY_DOWNLOAD=1` 时下载 mac 更新包、解出 `Info.plist` 做同样断言;同时按 2026-09-21 决策断言 mac 渠道只登记 `darwin-aarch64`(不再要求 universal 双键)。
+- 决策(口径回归):macOS 现行契约是 arm64 单架构(2026-09-21 决策),里程碑里「两个 macOS 平台键指向 universal 产物」的旧文字按现行决策改写;不得据此重新切回 universal,除非按该决策给出的恢复路径补齐按架构的 Node 运行时。
+- 影响范围:`apps/ai-game-creator-shell/scripts/build-macos-ci.mjs`、新增 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs` 与其 `.test.mjs`、`apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs`、`jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`、`scripts/check-agc-update-channel-manifests.mjs`、两份里程碑与本文件、pitfalls。
+- 验证:`node --test macos-release-identity.test.mjs prepare-macos-codex.test.mjs verify-updater-signature.test.mjs build-release.test.mjs cargo-features.test.mjs` → 59 passed(新增 6 条,回归用例直接喂线上那份 0.1.139 release 身份 plist,必须抛错);`npm run check:production-ops`、`check:encoding`、`check:doc-index`、prettier、eslint、`git diff --check` 通过;只读核对对线上 `dev-win` 全 PASS(含 158 MiB 产物下载验签与旧协议 sha256 一致),对线上 `dev-mac` 精确报出上面两条 FAIL。
+- 边界(未完成):修复只保证「以后再发不会再错」,线上 `dev-mac/latest.json` 仍指向那份坏包;需要一次带 Jenkins 凭据与授权的 mac 重新发布,然后重跑只读核对才算了结。Apple 代码签名与公证仍是 `adhoc`。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 161bf4707..034d210b9 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -6099,9 +6099,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **根因**:这个 harness 用桩 `systemctl` 驱动巡检脚本,Windows 上 `spawn systemctl` 直接 `ENOENT`,服务态检查不可能通过;它验证的是 Linux + systemd 的生产形态。
- **做法**:本机只跑 `npm run check:production-health-patrol-env`(本轮 OK)确认巡检变量口径;`check:production-health-patrol` 留给服务器/CI 复核,别据此判定巡检脚本本身坏了。
-## 2026-09-28 渠道清单的单元测试绿不等于线上清单符合契约:`dev-mac` 线上仍是单架构
+## 2026-09-28 线上 dev-mac 的更新包是「上一版 + 另一个渠道身份」:清单扫描选中了构建目录残留产物
-- **现象**:`build-release` 的单元测试里 `universal uses the Mac channel and the same signed artifact for both architectures` 一直是绿的,但线上 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json`(`version=0.1.142`、`pub_date=2026-09-24`、`commit=c07c10c0c`)的 `platforms` 只有 `darwin-aarch64`。测试证明的是「代码会这么发」,不能证明「线上已经这么发」。
-- **判据**:用只读脚本核对已发布对象,而不是看单测——`npm run check:agc-update-channel-manifests`(`AGC_UPDATE_VERIFY_DOWNLOAD=1` 时下载产物验签)。本轮实测:`dev-win` 与 `dev-mac` 的安装包都存在、`.sig` 与清单签名文本一致、两个产物都能用 `tauri.conf.json` 的公钥验签通过(`alg=ED`,`keyId=cb883447e3e87c4e`),但 `dev-mac` 的「两个 macOS 平台键」判 FAIL。
-- **做法**:任何「线上对象/清单契约」条款都用只读核对脚本取证;发现线上落后时,先确认这次发布对应的 commit 与时间(本轮 mac 发布早于 universal 改造),再用目标平台的构建机重发,不要在 Windows 上伪造 mac 清单。
-- **附注**:该 `dev-mac` 更新产物叫「陶泥儿 Release.app.tar.gz」,而渠道首装 DMG 叫「陶泥儿开发版_0.1.142_aarch64.dmg」;`dev` 渠道展示名约定是「陶泥儿开发版」,重发时一并核对 mac 更新产物的渠道身份。
+- **现象**:线上 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json`(`version=0.1.142`、`commit=c07c10c0c`)把更新包指向 `陶泥儿 Release.app.tar.gz`;下载解开看 `Contents/Info.plist`:`CFBundleShortVersionString=0.1.139`、`CFBundleIdentifier=world.genarrative.ai-game-creator.release`、`CFBundleName=陶泥儿 Release`。同一份清单的首装 DMG 却是 `陶泥儿开发版_0.1.142_aarch64.dmg`。也就是说签名是真的、对象也在,但**版本与渠道身份都是错的**。
+- **根因**:`build-macos-ci.mjs` 以前只 `rmSync` 「本轮要写的确切文件名」,构建目录里上一轮(或其它渠道身份)留下的 `*.app.tar.gz` 不会被清;而 `generateUpdateManifest()` 是**扫描构建目录、按优先级挑产物**(同名优先级再按字典序),于是挑走了残留的 release 身份包。mac 构建机是复用 workspace 的,这类残留会长期存在。
+- **判据**:只读核对要**打开产物看身份**,不能只看「地址存在 + 签名匹配」。`npm run check:agc-update-channel-manifests`(`AGC_UPDATE_VERIFY_DOWNLOAD=1`)现在会解出 mac 包的 `Info.plist`,断言「包内版本 == 清单版本」且「包内 identifier/产品名 == 本渠道身份」;本轮对线上取样得到两条 FAIL,正是这个缺陷。
+- **处理(2026-09-28 已修)**:构建前按后缀清空 `macos/` 下的 `*.app.tar.gz`、`*.app.tar.gz.sig`、`*.dmg`、`*.dmg.sha256`;构建后读 `.app/Contents/Info.plist` 断言版本/identifier/产品名;生成清单后再断言 `release.artifact` 就是本轮那一个。守卫在 `apps/ai-game-creator-shell/scripts/macos-release-identity.mjs`,回归用例直接用线上那份 0.1.139 release 身份包(`node --test` 59 passed)。
+- **教训**:凡是「按目录扫描挑产物」的发布步骤,都要么先清空同类产物、要么按本轮预期路径断言;只删「本轮要写的名字」等于把上一轮的坏包留在候选集里。还有一条更一般的:核对线上清单时,先看 decision-log 的现行口径(这里 macOS 已是 arm64 单架构),别拿过期里程碑文字当契约。
diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build
index 496332140..ae31d1ad9 100644
--- a/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build
+++ b/jenkins/Jenkinsfile.ai-game-creator-shell-macos-build
@@ -170,6 +170,7 @@ pipeline {
node --test apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs
node --test apps/ai-game-creator-shell/scripts/verify-updater-signature.test.mjs
node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs
+ node --test apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs
node apps/ai-game-creator-shell/scripts/build-macos-ci.mjs
if command -v sccache >/dev/null 2>&1; then
echo '[agc-macos] sccache 统计(自 server 启动累计):'
diff --git a/scripts/check-agc-update-channel-manifests.mjs b/scripts/check-agc-update-channel-manifests.mjs
index 1dd1d4057..6cc0060d2 100644
--- a/scripts/check-agc-update-channel-manifests.mjs
+++ b/scripts/check-agc-update-channel-manifests.mjs
@@ -1,8 +1,9 @@
// 只读核对 OSS 上**已发布**的 AGC 更新渠道清单(不写任何远端对象)。
//
// 对应里程碑「AGC 更新发布管线渠道化」第 3 条与「AGC macOS 渠道更新落地」第 1 条:
-// 清单内地址指向已存在的对象、签名对象与清单一致、universal macOS 的两个平台键指向
-// 同一对象同一签名,并在允许下载时用产物里烘焙的 updater 公钥验证「签名 ↔ 安装包」。
+// 清单内地址指向已存在的对象、签名对象与清单一致;macOS 渠道按 2026-09-21 决策只登记
+// `darwin-aarch64`(不再登记 `darwin-x86_64`);允许下载时用产物里烘焙的 updater 公钥
+// 验证「签名 ↔ 安装包」,并解出 mac 包内 `Info.plist` 核对版本与渠道身份。
//
// 用法:
// npm run check:agc-update-channel-manifests
@@ -12,6 +13,8 @@
// AGC_UPDATE_DOWNLOAD_LIMIT_MB=600 # 下载上限,超过则该平台标记为未验签
//
// 默认模式不下载安装包,只核对清单结构、对象存在性与签名对象一致性;渠道发布后先用它做快速回归。
+// 打开下载后,macOS 渠道会额外核对「清单版本 == 包内版本」「包内 identifier/产品名 == 本渠道身份」——
+// 2026-09-28 线上 `dev-mac/0.1.142` 就是「清单写 0.1.142、包里是 0.1.139 的 release 身份包」。
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
@@ -20,6 +23,10 @@ import path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
+import * as tar from 'tar';
+
+import { resolveChannelInstallIdentity } from '../apps/ai-game-creator-shell/scripts/channel-identity.mjs';
+import { readMacosAppInfoIdentity } from '../apps/ai-game-creator-shell/scripts/macos-release-identity.mjs';
import {
readUpdaterPubkey,
verifyUpdaterSignature,
@@ -41,7 +48,10 @@ const DOWNLOAD_LIMIT_BYTES =
1024 *
1024;
-const MACOS_PLATFORM_KEYS = ['darwin-aarch64', 'darwin-x86_64'];
+// 2026-09-21 决策:macOS 只出 arm64 单架构,清单只登记 `darwin-aarch64`。
+// 登记 `darwin-x86_64` 会把 arm64 产物发给 Intel 客户端(曾发生在 dev-mac 0.1.86)。
+const MACOS_ARM64_PLATFORM_KEY = 'darwin-aarch64';
+const MACOS_INTEL_PLATFORM_KEY = 'darwin-x86_64';
const WINDOWS_PLATFORM_KEY = 'windows-x86_64';
let failures = 0;
@@ -95,6 +105,59 @@ async function fileSize(filePath) {
return (await readFile(filePath)).length;
}
+/** 从 tar.gz 里读出某个条目(macOS 更新包是 `<产品名>.app.tar.gz`)。 */
+async function readTarEntry(filePath, predicate) {
+ const entries = [];
+ await tar.t({
+ file: filePath,
+ onentry: (entry) => {
+ entries.push(entry.path);
+ },
+ });
+ const target = entries.find(predicate);
+ if (!target) throw new Error('压缩包内没有目标文件');
+ const chunks = [];
+ await tar.t({
+ file: filePath,
+ filter: (entryPath) => entryPath === target,
+ onentry: (entry) => {
+ entry.on('data', (chunk) => chunks.push(chunk));
+ },
+ });
+ return Buffer.concat(chunks).toString('utf8');
+}
+
+/**
+ * macOS 更新包必须既是本轮版本、也是本渠道身份。
+ * 只核对「地址存在 + 签名匹配」会漏掉「签名对但装的是别的渠道、别的版本」。
+ */
+async function verifyMacosBundleIdentity(channel, key, manifest, artifactPath) {
+ const channelName = channel.replace(/-mac$/u, '');
+ const expected = resolveChannelInstallIdentity(channelName);
+ let identity;
+ try {
+ identity = readMacosAppInfoIdentity(
+ await readTarEntry(artifactPath, (entryPath) =>
+ entryPath.endsWith('/Contents/Info.plist'),
+ ),
+ );
+ } catch (error) {
+ check(`${channel}/${key} 更新包可读出 Info.plist`, false, String(error));
+ return;
+ }
+ check(
+ `${channel}/${key} 更新包版本与清单一致`,
+ identity.version === String(manifest.version),
+ `bundle=${identity.version ?? ''} manifest=${manifest.version ?? ''}`,
+ );
+ check(
+ `${channel}/${key} 更新包身份属于本渠道`,
+ identity.identifier === expected.identifier &&
+ identity.name === expected.productName,
+ `bundle=${identity.identifier ?? ''}/${identity.name ?? ''} expected=${expected.identifier}/${expected.productName}`,
+ );
+}
+
async function verifyChannel(channel, tempDir) {
console.log(`\n--- 渠道 ${channel} ---`);
const manifestUrl = `${OSS_BASE_URL}/${channel}/latest.json`;
@@ -178,20 +241,20 @@ async function verifyChannel(channel, tempDir) {
}
if (channel.endsWith('-mac')) {
- const present = MACOS_PLATFORM_KEYS.every((key) => platforms[key]);
check(
- `${channel} 同时提供两个 macOS 平台键(universal)`,
- present,
+ `${channel} 只登记 darwin-aarch64(2026-09-21 单架构决策)`,
+ Boolean(platforms[MACOS_ARM64_PLATFORM_KEY]) &&
+ !platforms[MACOS_INTEL_PLATFORM_KEY],
`platforms=${platformKeys.join(',')}`,
);
- if (present) {
- const [first, second] = MACOS_PLATFORM_KEYS.map((key) => platforms[key]);
- check(
- `${channel} 两个 macOS 平台键指向同一对象与同一签名`,
- first.url === second.url && first.signature === second.signature,
- `sameUrl=${first.url === second.url} sameSignature=${first.signature === second.signature}`,
- );
- }
+ const extraMacKeys = platformKeys.filter(
+ (key) => key.startsWith('darwin-') && key !== MACOS_ARM64_PLATFORM_KEY,
+ );
+ check(
+ `${channel} 没有多余的 macOS 平台键`,
+ extraMacKeys.length === 0,
+ `extra=${extraMacKeys.join(',')}`,
+ );
} else {
check(
`${channel} 提供 ${WINDOWS_PLATFORM_KEY} 平台键`,
@@ -281,6 +344,9 @@ async function verifyChannel(channel, tempDir) {
String(error),
);
}
+ if (key.startsWith('darwin-') && artifactPath.endsWith('.app.tar.gz')) {
+ await verifyMacosBundleIdentity(channel, key, manifest, artifactPath);
+ }
await rm(artifactPath, { force: true });
await rm(signaturePath, { force: true });
}