补齐 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
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:
@@ -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);
|
||||
Reference in New Issue
Block a user