补齐 AGC 已发布渠道清单只读核对与线上差异取证
Project CI / AI game creator shell Rust crates (push) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m55s
Project CI / Frontend tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled

- 新增 scripts/check-agc-update-channel-manifests.mjs:只读核对 OSS 渠道清单结构、对象存在性、签名对象一致性,AGC_UPDATE_VERIFY_DOWNLOAD=1 时下载产物用内置公钥验签
- package.json 注册 npm run check:agc-update-channel-manifests
- 渠道化里程碑条目 3 勾选:dev-win/dev-mac 产物下载验签通过,dev-win 的 sha256/size 与旧协议指针一致;条目 5 记录 withCredentials 与归档 glob 的静态核对结果
- macOS 里程碑记录线上 dev-mac 清单仍是单架构(0.1.142 / c07c10c0c),universal 需 mac 构建机重发
- pitfalls 记录「单测绿不等于线上清单符合契约」与 dev-mac 更新产物命名待核对
This commit is contained in:
kdletters
2026-09-28 21:56:50 +08:00
parent d5dc95e822
commit a4d4d04d97
5 changed files with 329 additions and 2 deletions
@@ -0,0 +1,301 @@
// 只读核对 OSS 上**已发布**的 AGC 更新渠道清单(不写任何远端对象)。
//
// 对应里程碑「AGC 更新发布管线渠道化」第 3 条与「AGC macOS 渠道更新落地」第 1 条:
// 清单内地址指向已存在的对象、签名对象与清单一致、universal macOS 的两个平台键指向
// 同一对象同一签名,并在允许下载时用产物里烘焙的 updater 公钥验证「签名 ↔ 安装包」。
//
// 用法:
// 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 # 下载上限,超过则该平台标记为未验签
//
// 默认模式不下载安装包,只核对清单结构、对象存在性与签名对象一致性;渠道发布后先用它做快速回归。
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 {
readUpdaterPubkey,
verifyUpdaterSignature,
} from '../apps/ai-game-creator-shell/scripts/verify-updater-signature.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;
const MACOS_PLATFORM_KEYS = ['darwin-aarch64', 'darwin-x86_64'];
const WINDOWS_PLATFORM_KEY = 'windows-x86_64';
let failures = 0;
let skipped = 0;
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;
}
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 ?? '');
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')) {
const present = MACOS_PLATFORM_KEYS.every((key) => platforms[key]);
check(
`${channel} 同时提供两个 macOS 平台键(universal)`,
present,
`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}`,
);
}
} else {
check(
`${channel} 提供 ${WINDOWS_PLATFORM_KEY} 平台键`,
Boolean(platforms[WINDOWS_PLATFORM_KEY]),
`platforms=${platformKeys.join(',')}`,
);
}
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 ?? ''}`,
);
}
}
// 可选:下载产物,核对 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);
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),
);
}
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 });
}
console.log(
failures === 0
? `\n全部通过${skipped > 0 ? `(${skipped} 项跳过)` : ''}`
: `\n${failures} 项失败${skipped > 0 ? `,${skipped} 项跳过` : ''}`,
);
process.exit(failures === 0 ? 0 : 1);