Files
Genarrative/scripts/check-agc-update-channel-manifests.mjs
T
kdletters 93af29314c
Project CI / AI game creator shell Rust smoke (push) Successful in 1m21s
Project CI / AI game creator shell Rust crates (push) Successful in 1m37s
Project CI / Backend tests (push) Successful in 3m49s
Project CI / Frontend tests (push) Successful in 1m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m20s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m39s
Project CI / Native shell tests (push) Successful in 5m50s
Project CI / AI game creator shell web tests (push) Successful in 2m18s
Project CI / Repository checks (push) Successful in 2m49s
只读核对补旧协议指针的分区断言
- check:agc-update-channel-manifests:非 dev 的 Windows 渠道核对时断言 agc/latest.json 仍指向 dev-win 分区(全局单对象只属于 dev)
- 渠道隔离里程碑与 decision-log 记录线上证据:release-win 核对通过,指针指向 dev-win 0.1.155
2026-09-28 22:30:15 +08:00

527 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 只读核对 OSS 上**已发布**的 AGC 更新渠道清单(不写任何远端对象)。
//
// 对应里程碑「AGC 更新发布管线渠道化」第 3 条与「AGC macOS 渠道更新落地」第 1 条:
// 清单内地址指向已存在的对象、签名对象与清单一致;macOS 渠道按 2026-09-21 决策只登记
// `darwin-aarch64`(不再登记 `darwin-x86_64`);允许下载时用产物里烘焙的 updater 公钥
// 验证「签名 ↔ 安装包」,并解出 mac 包内 `Info.plist` 核对版本与渠道身份。
//
// 用法:
// npm run check:agc-update-channel-manifests
// AGC_UPDATE_CHANNELS=dev-win,dev-mac # 要核对的渠道分区,默认两个 dev 分区
// AGC_UPDATE_OSS_BASE_URL=https://.../agc # 覆盖 OSS 基址
// AGC_UPDATE_VERIFY_DOWNLOAD=1 # 额外下载产物验签(默认只 HEAD 与读 .sig)
// 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';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import * as tar from 'tar';
import { compareVersions } from '../apps/ai-game-creator-shell/scripts/agc-global-version.mjs';
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,
} from '../apps/ai-game-creator-shell/scripts/verify-updater-signature.mjs';
import { readPortableExecutableVersionInfo } from './pe-version-info.mjs';
const OSS_BASE_URL = (
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() ||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'
).replace(/\/+$/u, '');
const CHANNELS = (process.env.AGC_UPDATE_CHANNELS?.trim() || 'dev-win,dev-mac')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const VERIFY_DOWNLOAD = /^(1|true)$/iu.test(
process.env.AGC_UPDATE_VERIFY_DOWNLOAD?.trim() ?? '',
);
const DOWNLOAD_LIMIT_BYTES =
Number(process.env.AGC_UPDATE_DOWNLOAD_LIMIT_MB?.trim() || '600') *
1024 *
1024;
// 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;
let skipped = 0;
/** 下载过的更新包摘要:用于「不同渠道不能共用同一份产物字节」这条隔离断言。 */
const downloadedArtifacts = [];
/** 各渠道清单版本:用于和统一总号(`agc/global-version.json`)比对。 */
const channelVersions = new Map();
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
function skip(name, detail) {
skipped += 1;
console.log(`SKIP ${name}${detail ? ` :: ${detail}` : ''}`);
}
function decodeMinisignText(value, label) {
const trimmed = String(value ?? '').trim();
if (trimmed.length === 0) throw new Error(`${label} 为空`);
if (trimmed.startsWith('untrusted comment:')) return trimmed;
const decoded = Buffer.from(trimmed, 'base64').toString('utf8').trim();
if (!decoded.startsWith('untrusted comment:')) {
throw new Error(`${label} 不是 minisign 内容`);
}
return decoded;
}
async function head(url) {
const response = await fetch(url, { method: 'HEAD' });
const length = Number(response.headers.get('content-length') ?? '0');
return { status: response.status, length };
}
async function download(url, destination) {
const response = await fetch(url);
if (!response.ok || !response.body) {
throw new Error(`下载失败 ${response.status}:${url}`);
}
await pipeline(
Readable.fromWeb(response.body),
createWriteStream(destination),
);
}
async function sha256Of(filePath) {
const hash = createHash('sha256');
hash.update(await readFile(filePath));
return hash.digest('hex');
}
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}`,
);
}
/**
* Windows 安装包同理:PE 版本资源里的产品名与版本必须对得上清单。
* 只核对文件名等于相信文件没被换过——文件名可以改,PE 资源是产物自己写的。
*/
async function verifyWindowsBundleIdentity(
channel,
key,
manifest,
artifactPath,
) {
const channelName = channel.replace(/-win$/u, '');
const expected = resolveChannelInstallIdentity(channelName);
let info;
try {
info = readPortableExecutableVersionInfo(await readFile(artifactPath));
} catch (error) {
check(`${channel}/${key} 安装包可读出 PE 版本资源`, false, String(error));
return;
}
const manifestVersion = String(manifest.version);
check(
`${channel}/${key} 安装包版本与清单一致`,
info.fileVersion === `${manifestVersion}.0` ||
info.strings.FileVersion === manifestVersion,
`pe=${info.fileVersion}/${info.strings.FileVersion ?? ''} manifest=${manifestVersion}`,
);
check(
`${channel}/${key} 安装包身份属于本渠道`,
info.strings.ProductName === expected.productName,
`pe=${info.strings.ProductName ?? ''} expected=${expected.productName}`,
);
}
async function verifyChannel(channel, tempDir) {
console.log(`\n--- 渠道 ${channel} ---`);
const manifestUrl = `${OSS_BASE_URL}/${channel}/latest.json`;
const response = await fetch(manifestUrl);
check(
`${channel} 渠道清单可读`,
response.status === 200,
`status=${response.status} ${manifestUrl}`,
);
if (response.status !== 200) return;
const raw = await response.text();
let manifest;
try {
manifest = JSON.parse(raw);
} catch (error) {
check(`${channel} 渠道清单是合法 JSON`, false, String(error));
return;
}
const version = String(manifest.version ?? '');
channelVersions.set(channel, version);
check(
`${channel} 清单版本与发布元数据齐备`,
/^\d+\.\d+\.\d+$/u.test(version) &&
!Number.isNaN(Date.parse(String(manifest.pub_date ?? ''))) &&
typeof manifest.commit === 'string' &&
manifest.commit.trim().length > 0,
`version=${version} pub_date=${manifest.pub_date ?? ''} commit=${String(manifest.commit ?? '').slice(0, 10)}`,
);
const platforms = manifest.platforms ?? {};
const platformKeys = Object.keys(platforms);
check(
`${channel} 清单至少有一个平台条目`,
platformKeys.length > 0,
`platforms=${platformKeys.join(',')}`,
);
const expectedPrefix = `${OSS_BASE_URL}/${channel}/${version}/`;
for (const key of platformKeys) {
const entry = platforms[key] ?? {};
const url = String(entry.url ?? '');
check(
`${channel}/${key} 地址指向本渠道版本目录`,
url.startsWith(expectedPrefix),
`url=${url}`,
);
const artifactHead = await head(url);
const sizeOk = artifactHead.status === 200 && artifactHead.length > 0;
check(
`${channel}/${key} 安装包对象存在`,
sizeOk,
`status=${artifactHead.status} bytes=${artifactHead.length}`,
);
const signatureHead = await head(`${url}.sig`);
check(
`${channel}/${key} 签名对象存在`,
signatureHead.status === 200 && signatureHead.length > 0,
`status=${signatureHead.status} bytes=${signatureHead.length}`,
);
if (signatureHead.status === 200) {
const signatureText = await (await fetch(`${url}.sig`)).text();
const manifestSignature = decodeMinisignText(
entry.signature,
`${channel}/${key} 清单签名`,
);
const objectSignature = decodeMinisignText(
signatureText,
`${channel}/${key} 签名对象`,
);
check(
`${channel}/${key} 清单签名与签名对象一致`,
manifestSignature === objectSignature,
);
}
const downloadUrl = String(manifest.downloads?.[key]?.url ?? '');
check(
`${channel}/${key} 首装下载地址指向已存在对象`,
downloadUrl.startsWith(expectedPrefix) &&
(await head(downloadUrl)).status === 200,
`url=${downloadUrl}`,
);
}
if (channel.endsWith('-mac')) {
check(
`${channel} 只登记 darwin-aarch64(2026-09-21 单架构决策)`,
Boolean(platforms[MACOS_ARM64_PLATFORM_KEY]) &&
!platforms[MACOS_INTEL_PLATFORM_KEY],
`platforms=${platformKeys.join(',')}`,
);
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} 平台键`,
Boolean(platforms[WINDOWS_PLATFORM_KEY]),
`platforms=${platformKeys.join(',')}`,
);
// 渠道身份会写进产物文件名的产品名,这里先用清单里的地址核对(下载后再验签与摘要)。
const expectedIdentity = resolveChannelInstallIdentity(
channel.replace(/-win$/u, ''),
);
const artifactNames = platformKeys.map((key) =>
decodeURIComponent(new URL(String(platforms[key].url ?? '')).pathname),
);
check(
`${channel} 安装包文件名带本渠道产品名`,
artifactNames.every((name) =>
name.includes(expectedIdentity.productName),
),
`names=${artifactNames.map((name) => path.basename(name)).join(',')} expected=${expectedIdentity.productName}`,
);
}
if (channel === 'dev-win') {
const bridge = await fetch(`${OSS_BASE_URL}/latest.json`);
const bridgeOk = bridge.status === 200;
check(`${channel} 旧协议迁移指针可读`, bridgeOk);
if (bridgeOk) {
const bridgeJson = await bridge.json();
const windowsEntry = platforms[WINDOWS_PLATFORM_KEY] ?? {};
check(
`${channel} 旧协议指针指向同一批已发布对象`,
String(bridgeJson.downloadUrl ?? '') ===
String(windowsEntry.url ?? '') &&
(await head(String(bridgeJson.downloadUrl ?? ''))).status === 200,
`sha256=${String(bridgeJson.sha256 ?? '').slice(0, 12)} size=${bridgeJson.size ?? ''}`,
);
}
} else if (channel.endsWith('-win')) {
// 旧协议迁移指针是**全局单对象**,只属于 dev 渠道的 Windows 系统;其它渠道发布
// 不得改写它,否则 dev 用户会被推去装别的渠道的包。
const bridge = await fetch(`${OSS_BASE_URL}/latest.json`);
if (bridge.status === 200) {
const bridgeJson = await bridge.json();
const bridgeUrl = String(bridgeJson.downloadUrl ?? '');
check(
`${channel} 没有改写 dev 的旧协议迁移指针`,
bridgeUrl.includes('/agc/dev-win/'),
`bridge=${bridgeUrl}`,
);
} else {
skip(
`${channel} 没有改写 dev 的旧协议迁移指针`,
`旧协议指针不可读:status=${bridge.status}`,
);
}
}
// 可选:下载产物,核对 sha256/size 并用产物内公钥验签。
for (const key of platformKeys) {
const entry = platforms[key] ?? {};
const url = String(entry.url ?? '');
const artifactHead = await head(url);
if (!VERIFY_DOWNLOAD) {
skip(
`${channel}/${key} 签名 ↔ 安装包匹配`,
'未设置 AGC_UPDATE_VERIFY_DOWNLOAD=1,只做了对象存在性核对',
);
continue;
}
if (artifactHead.length > DOWNLOAD_LIMIT_BYTES) {
skip(
`${channel}/${key} 签名 ↔ 安装包匹配`,
`产物 ${artifactHead.length} 字节超过下载上限 ${DOWNLOAD_LIMIT_BYTES}`,
);
continue;
}
const fileName = `${channel}-${key}-${path.basename(decodeURIComponent(url))}`;
const artifactPath = path.join(tempDir, fileName);
const signaturePath = `${artifactPath}.sig`;
await download(url, artifactPath);
await writeFile(
signaturePath,
decodeMinisignText(entry.signature, `${channel}/${key} 清单签名`),
'utf8',
);
const size = await fileSize(artifactPath);
check(
`${channel}/${key} 下载对象字节数与 HEAD 一致`,
size === artifactHead.length,
`downloaded=${size} head=${artifactHead.length}`,
);
const sha256 = await sha256Of(artifactPath);
downloadedArtifacts.push({
channel,
platformKey: key,
sha256,
size,
url,
});
if (channel === 'dev-win' && key === WINDOWS_PLATFORM_KEY) {
const bridgeJson = await (
await fetch(`${OSS_BASE_URL}/latest.json`)
).json();
check(
`${channel} 旧协议指针 sha256/size 与实际产物一致`,
String(bridgeJson.sha256 ?? '').toLowerCase() === sha256 &&
Number(bridgeJson.size ?? 0) === size,
`sha256=${sha256.slice(0, 12)} size=${size}`,
);
}
try {
const result = verifyUpdaterSignature({
artifactPath,
signaturePath,
pubkey: readUpdaterPubkey(),
});
check(
`${channel}/${key} 签名 ↔ 安装包匹配(产物内公钥验签)`,
true,
`alg=${result.algorithm} keyId=${result.keyId}`,
);
} catch (error) {
check(
`${channel}/${key} 签名 ↔ 安装包匹配(产物内公钥验签)`,
false,
String(error),
);
}
if (key.startsWith('darwin-') && artifactPath.endsWith('.app.tar.gz')) {
await verifyMacosBundleIdentity(channel, key, manifest, artifactPath);
}
if (key.startsWith('windows-') && artifactPath.endsWith('.exe')) {
await verifyWindowsBundleIdentity(channel, key, manifest, artifactPath);
}
await rm(artifactPath, { force: true });
await rm(signaturePath, { force: true });
}
}
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'agc-channel-check-'));
try {
for (const channel of CHANNELS) await verifyChannel(channel, tempDir);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
// 渠道隔离:同一次核对里如果下载到多个渠道的更新包,它们不能是同一份字节——
// 不同渠道的 productName / identifier 不同,产物就不会相同(2026-09-28 的 dev-mac
// 事故正是「dev 分区里放的其实是 release 渠道那份包」,字节级完全相同)。
const channelNames = [
...new Set(
downloadedArtifacts.map((item) => item.channel.replace(/-(win|mac)$/u, '')),
),
];
if (!VERIFY_DOWNLOAD) {
skip(
'不同渠道的更新包互不相同(渠道隔离)',
'需要 AGC_UPDATE_VERIFY_DOWNLOAD=1 才能比对产物字节',
);
} else if (channelNames.length < 2) {
skip(
'不同渠道的更新包互不相同(渠道隔离)',
`本次只核对到 ${channelNames.length} 个渠道(${channelNames.join(',')});用 AGC_UPDATE_CHANNELS=dev-win,dev-mac,release-win,release-mac 可覆盖全量`,
);
} else {
const byHash = new Map();
for (const item of downloadedArtifacts) {
const bucket = byHash.get(item.sha256) ?? [];
bucket.push(item);
byHash.set(item.sha256, bucket);
}
const collisions = [...byHash.entries()].filter(
([, items]) =>
new Set(items.map((item) => item.channel.replace(/-(win|mac)$/u, '')))
.size > 1,
);
check(
'不同渠道的更新包互不相同(渠道隔离)',
collisions.length === 0,
collisions
.map(
([hash, items]) =>
`${hash.slice(0, 12)}: ${items.map((item) => `${item.channel}/${item.platformKey}`).join(' = ')}`,
)
.join(';'),
);
}
// 统一总号:`agc/global-version.json` 是发号唯一事实源,任何渠道清单都不允许高于它;
// 总号高于渠道是正常的(渠道可能正在构建或尚未发布),这里只提示,不判失败。
{
const response = await fetch(`${OSS_BASE_URL}/global-version.json`);
check(
'统一总号对象可读',
response.status === 200,
`status=${response.status} ${OSS_BASE_URL}/global-version.json`,
);
if (response.status === 200) {
const global = await response.json();
const globalVersion = String(global.version ?? '');
check(
'统一总号字段齐备',
/^\d+\.\d+\.\d+$/u.test(globalVersion) &&
!Number.isNaN(Date.parse(String(global.updatedAt ?? ''))) &&
String(global.channel ?? '').trim().length > 0,
`version=${globalVersion} updatedAt=${global.updatedAt ?? ''} channel=${global.channel ?? ''}`,
);
for (const [channel, version] of channelVersions) {
if (!/^\d+\.\d+\.\d+$/u.test(version)) continue;
check(
`${channel} 清单版本不高于统一总号`,
compareVersions(version, globalVersion) <= 0,
`channel=${version} global=${globalVersion}`,
);
if (compareVersions(version, globalVersion) < 0) {
console.log(
`INFO ${channel} 还没发到总号版本(渠道 ${version} < 总号 ${globalVersion};总号最近由 ${global.channel} 在 ${global.updatedAt} 取号,构建可能仍在进行)`,
);
}
}
}
}
console.log(
failures === 0
? `\n全部通过${skipped > 0 ? `(${skipped} 项跳过)` : ''}`
: `\n${failures} 项失败${skipped > 0 ? `,${skipped} 项跳过` : ''}`,
);
process.exit(failures === 0 ? 0 : 1);