模板库线上对齐仓库源:递增版本重发并把说明文档纳入发布
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust crates (push) Successful in 1m23s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 4m48s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m38s
Project CI / Frontend tests (push) Successful in 10m0s
Project CI / Native shell tests (push) Successful in 11m37s
Project CI / Repository checks (push) Successful in 8m49s
Project CI / Backend tests (push) Successful in 12m24s
Project CI / AI game creator shell web tests (push) Successful in 4m50s

- 7 个内容已变的模板 templateVersion 升到 0.1.1,blank-2d-canvas 与 blank-web 保持 0.1.0
- 发布脚本把仓库 README.md 覆盖写到 templates/README.md(上限 64 KiB,写在清单之前并回读校验),线上说明文档不再是手工副本
- 修复守卫拒绝时直接 process.exit(1) 触发 libuv 断言崩溃、看不到拒绝原因的问题
- 新增发布用例:说明文档随发布覆盖写且写在清单之前、源目录没有 README 时不写线上文档
- 模板库新增仓库侧 README.md 源文件(发布后与线上逐字节一致)
- 技术方案补说明文档契约与 2026-09-21 重发布记录;决策记录与踩坑记录各补一条
- 验证:node --test 27 项、真实发布 28 个对象回读通过、线上清单与 README 核对一致、AGC 壳 3 项线上用例重跑通过、check:doc-index、check:encoding、eslint、git diff --check
This commit is contained in:
kdletters
2026-09-21 14:51:39 +08:00
parent 19ed1776f1
commit f4ebc45612
13 changed files with 128 additions and 27 deletions
@@ -0,0 +1,10 @@
# AGC 游戏模板库
- 用途:AGC 客户端读取的**游戏模板库**,正文是可直接解压到项目根的 zip(zip 根 == AGC 项目根,例如 `game/index.html`)。
- 位置:bucket `agc-dev`endpoint `oss-rg-china-mainland.aliyuncs.com`)的 `templates/` 前缀,随 bucket 公共读。客户端只读 `templates/index.json`schema `agc-template-library.v1`)。
- 目录结构:`templates/index.json` + `templates/v1/<templateId>/sha256/<内容摘要>/{template.zip,template.json,cover.*}`;条目元数据含 `tags`、封面键与尺寸/摘要、包大小与 SHA-256、`templateVersion`。所有正文按自身字节摘要寻址,重复发布复用同一对象。
- 封面支持 `png / jpg / webp / svg`
- 客户端只信任「受信任 OSS 主机 + 对象键」,清单里的地址字段不参与请求;下载后强校验包大小与 SHA-256,安装完成的唯一判据是模板目录里的 `installed.json`
- 这份文件就是线上 `templates/README.md` 的源:内容随每次发布覆盖写,所以改契约要改这里,不要只改线上对象。
- 契约与验收以仓库文档 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md` 为准;模板源在 `apps/ai-game-creator-shell/template-library/`,发布用 `node scripts/agc-template-library-publish.mjs --source apps/ai-game-creator-shell/template-library [--dry-run] [--only <id,id,...>]`(脚本现场打包 zip、按内容地址上传、逐个回读校验、持锁提交清单)。
- 同一个 `templateVersion` 的 ZIP 字节发生变化时发布会失败关闭,必须递增该模板的 `templateVersion`;发布不删除历史对象,历史版本仍可按旧键下载。
@@ -11,7 +11,7 @@
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
"templateVersion": "0.1.0",
"templateVersion": "0.1.1",
"entry": "game/index.html",
"coverWidth": 960,
"coverHeight": 540
@@ -11,7 +11,7 @@
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"templateVersion": "0.1.1",
"entry": "package.json",
"coverWidth": 960,
"coverHeight": 540
@@ -11,7 +11,7 @@
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"templateVersion": "0.1.1",
"entry": "package.json",
"coverWidth": 960,
"coverHeight": 540
@@ -11,7 +11,7 @@
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"templateVersion": "0.1.1",
"entry": "package.json",
"coverWidth": 960,
"coverHeight": 540
@@ -11,7 +11,7 @@
"runtime": "cocos",
"engine": "cocos-creator",
"engineVersion": "3.8.8",
"templateVersion": "0.1.0",
"templateVersion": "0.1.1",
"entry": "package.json",
"coverWidth": 960,
"coverHeight": 540
@@ -11,7 +11,7 @@
"runtime": "html",
"engine": "phaser",
"engineVersion": "4.2.1",
"templateVersion": "0.1.0",
"templateVersion": "0.1.1",
"entry": "game/index.html",
"coverWidth": 960,
"coverHeight": 540
@@ -11,7 +11,7 @@
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
"templateVersion": "0.1.0",
"templateVersion": "0.1.1",
"entry": "game/index.html",
"coverWidth": 960,
"coverHeight": 540
@@ -1,5 +1,14 @@
# 决策记录
## 2026-09-21 模板库线上产物对齐仓库源:递增版本重发 + 说明文档纳入发布
- 背景:`agc-dev` 上的 `templates/` 产物停在 2026-09-17 发布的那一版,仓库源在那之后改过(`5e4ff54a9` 删掉模板内嵌 `package-lock.json``game.js` / `main.js` 等内容调整),9 个模板里 7 个的 ZIP 与线上不一致;dry-run 被「同一 `templateVersion` 的 ZIP 不得变」门禁拒绝,发布器因此无法把仓库状态发上去。
- 决策:按门禁要求为内容已变的 7 个模板递增 `templateVersion``0.1.1``blank-2d-canvas``blank-web` 内容未变,保持 `0.1.0`),并完成一次真实发布;对象使用内容寻址键 `v1/<id>/sha256/<摘要>/…``index.json` 与说明文档在发布锁内最后提交,历史对象不删除。
- 决策(说明文档):`templates/README.md` 不再是手工副本——它由发布脚本从仓库源 `apps/ai-game-creator-shell/template-library/README.md`(≤64 KiB)覆盖写入并回读校验,写在清单之前;改契约只改仓库源。
- 决策(CLI 行为):发布器遇到门禁拒绝时用 `process.exitCode = 1` 正常退出,不再 `process.exit(1)` 让 Node 在 fetch 句柄未关闭时抛 libuv 断言(此前现场只剩 `Assertion failed`,看不到拒绝原因)。
- 影响范围:`apps/ai-game-creator-shell/template-library/v1/*/meta.json``apps/ai-game-creator-shell/template-library/README.md``scripts/agc-template-library-publish.mjs` 与其测试、本文件与模板库技术方案。
- 验证方式:`node --test scripts/agc-template-library-publish.test.mjs`(27 项,含新增「说明文档随发布覆盖写且写在清单之前」「源目录没有 README 时不写线上文档」);真实发布 28 个对象回读通过;匿名读取线上清单(9 个模板、7 个 `0.1.1`、全部内容寻址键)与 `templates/README.md`(与仓库源逐字节一致);AGC 壳 3 项线上用例(读清单、下载安装、下载并原生建项 Cocos)重跑通过。
## 2026-09-21 macOS 发布改为只出 arm64 单架构(Intel 暂不支持)
- 背景:Mac 发布管线按 `universal-apple-darwin` 构建,但随包 Node 便携运行时只有**单架构官方发行版**(`stage-node-runtime.mjs``process.execPath` 取材),于是 macOS Job #7~#13 连续失败在「Node 运行时不支持发布目标:universal-apple-darwin」。期间出现过一版「按宿主架构放行」的过渡实现,它能骗过通用包自检(`check-macos-bundle.mjs``process.arch` 校验),但 Intel 上那份 arm64 侧车不可执行,并且已发布的 dev-mac 0.1.86 就带着这个缺陷。
@@ -1,5 +1,13 @@
# 踩坑与排障记录
## 发布器守卫拒绝时不要把 process.exit 用在 fetch 句柄未关闭处
- 现象:`agc-template-library-publish.mjs --dry-run` 撞上「同一 `templateVersion` 的 ZIP 不得变」门禁时,终端只剩一句 `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c`,看不到任何拒绝原因,看起来像脚本崩溃而不是被拒绝。
- 原因:`main().catch(...)` 里直接 `process.exit(1)`;此时 dry-run 刚用 fetch 读过公共清单,句柄仍在关闭流程中,Node/libuv 在 Windows 上先抛断言,把真实错误信息挤掉。
- 处理:`catch` 里只设 `process.exitCode = 1`,让事件循环自然退出;同批把说明文档纳入受管发布(见决策记录 2026-09-21 条目)。
- 验证:把模板源复制到临时目录、把某个模板的 `templateVersion` 改回与线上同版本并改动一个字节,`--dry-run` 应打印 `同版本 ZIP 内容或尺寸变化,请递增 templateVersion` 且退出码为 1,不再出现 `Assertion failed`
- 关联:`scripts/agc-template-library-publish.mjs`
## macOS 只出 arm64 单架构,universal 必须失败关闭
`stage-node-runtime.mjs` 只把**构建宿主的 Node**打成便携运行时(官方发行版是单架构,没有 universal 发行版),而 2026-09-21 之前 `build-macos-ci.mjs` 构建的是 `universal-apple-darwin`macOS Job #7~#13 因此在 `stageNodeRuntime` 直接抛「Node 运行时不支持发布目标:universal-apple-darwin」。期间出现过一版「按宿主架构放行」的过渡实现(`targetRuntime` 对 universal 返回宿主架构),它能骗过通用包自检(`check-macos-bundle.mjs``process.arch` 校验),但**Intel Mac 上这份 arm64 侧车不可执行**,等于把坏包发出去。当前决策:macOS 固定只构建 `aarch64-apple-darwin`,清单只登记 `darwin-aarch64``targetRuntime('universal-apple-darwin')` 保持失败关闭。恢复 Intel 的正确路径是先在 staging 支持按架构各带一份**同版本**运行时(另下载另一架构官方发行版)并让通用包自检按架构分别校验,再切回 universal 目标、把 `darwin-x86_64` 键登记回去;不得用「只带宿主架构」充数,也不得把 arm64 产物登记成 x86_64 键。
@@ -68,6 +68,7 @@ templates/
- 模板源在仓库 `apps/ai-game-creator-shell/template-library/``v1/<id>/{meta.json, project/**, cover.(png|jpg|webp|svg)}``template.zip` **不落仓库**,由脚本按 `project/` 现场打包(条目排序、固定时间戳,同内容重复打包摘要一致)。
- 上传与校验由 [`scripts/agc-template-library-publish.mjs`](../../scripts/agc-template-library-publish.mjs) 完成:`--source apps/ai-game-creator-shell/template-library [--dry-run] [--only <id,id,...>]`。ZIP、封面和元数据分别以自身字节的 SHA-256 定位,只创建新对象或复用逐字节校验一致的已有对象;全部对象回读一致后才更新 `index.json`。失败不回收已上传对象,旧清单及其引用始终可读。
- 只更新指定模板时使用 `--only <id,id,...>`,在发布锁内读取最新清单,只替换指定 ID,其余条目和未知扩展字段保留。首次清单 404 可由本次选择初始化;读取异常或清单非法时停止。全量发布也遵守相同锁与版本门禁。
- `templates/README.md` 与上述正文不同:它不是内容寻址对象,而是**覆盖写的说明文档**,源在仓库 `apps/ai-game-creator-shell/template-library/README.md`(上限 64 KiB),由同一次发布在清单之前写入并回读校验。客户端从不读它,改契约只改仓库源即可,不要再手工维护线上副本。
- 正式发布先通过 `GetBucketVersioning` 确认 Bucket 从未开启版本控制,再用 `x-oss-forbid-overwrite: true` 原子创建 `.publish-lock.json`;版本控制 Enabled、Suspended、检查无权限或无法判定时均在写入前停止。所有写同一清单的发布进程必须使用此锁,发布期间不得改变 Bucket 版本控制配置。锁没有自动过期或抢占机制,已被占用时直接失败,重新执行须重新获取锁并读取最新清单。
- 只释放本任务已明确获取且 owner 标识仍一致的锁。获取结果不明时不猜测删除。正文对象不可变,其写入失败可安全释放本任务的锁;清单 PUT 已发起后若遇到断连、超时或服务端 5xx 等不确定结果,必须保留锁并报错,防止旧在途请求晚于下一发布者写入。清单收到确定成功或确定拒绝响应后才进入正常解锁路径;不自动重发清单写入,也不凭一次 GET 猜测在途 PUT 已结束。遗留锁须在确认原请求及进程已终止或完成后由运维处理;释放失败必须报告,不伪装为发布成功。
- `--dry-run` 仅构造和读取合并计划,不读取凭据、不获取锁、不 PUT/DELETE。发布不删除历史对象,也不提供随发布清理的选项;旧客户端缓存和未完成下载可能仍引用旧键。
@@ -117,6 +118,8 @@ Cocos 回归分别覆盖仓库模板和线上真实 ZIP 的安装、连续建项
`2026-09-21` 客户端接入复核:Rust 定向 24 项通过(新增清单来源判定 5 项:合法远端正文优先并标 `network`、远端失败回退缓存并标 `cache`、无缓存时暴露远端错误、远端正文非法时不用缓存掩盖、缓存损坏时失败关闭;新增建项目失败清理 1 项;并在大小/摘要不一致、越界归档两条用例上补「拒绝后不留安装目录、不产生已下载判据」断言)。3 项线上用例(读线上清单、下载安装线上模板、下载并原生建项 Cocos 模板)本轮全部真实执行通过;匿名 `GET https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/index.json` 返回 200、`schemaVersion=agc-template-library.v1`、9 个模板。前端 38 项通过(模型 9、网格 6、页面 12、控制器 11),`appSurface` 首页模板用例 2 项通过(灰度外隐藏入口与推荐位、点击进入模板库不建项),AGC `typecheck` 通过。仍未验证:Creator 内场景运行,以及真机 iOS / Android 观感。
`2026-09-21` 模板库重发布:把线上库从 09-17 的旧产物对齐到仓库当前源。线上 9 个模板里 7 个(`blank-3d-scene``cocos-empty-2d/3d/3d-hq/hello-world``phaser-2d-starter``threejs-3d-starter`)的 ZIP 与仓库不一致(旧产物来自 PR 定稿前的源,例如模板内嵌 `package-lock.json` 已由 `5e4ff54a9` 删除、`game.js` / `main.js` 后来改过),其中 2 个原本一致(`blank-2d-canvas``blank-web`)。按「同版本 ZIP 不得变」门禁,为这 7 个模板递增 `templateVersion``0.1.1` 后重发:28 个内容寻址对象逐个回读校验通过,`index.json``templates/README.md` 在锁内提交;线上清单现在全部指向 `v1/<id>/sha256/<摘要>/` 键,`libraryVersion=1`、9 个模板、无下架条目。发布脚本本次同时补齐说明文档受管写入(源文件即 `apps/ai-game-creator-shell/template-library/README.md`)并修掉「守卫拒绝后直接 `process.exit(1)` 触发 libuv 断言崩溃、看不到原因」的问题;线上 README 与仓库源已逐字节一致。
## 本地压测假数据注入(feature 控制)
模板库的数据源在 Rust 侧(清单校验、安装状态、下载与建项目都在这里),TS 只消费快照做渲染,所以假数据注入也放在 Rust 侧,走与真实完全一致的链路。
+55 -20
View File
@@ -25,6 +25,8 @@ import { deflateRawSync } from 'node:zlib';
const SCHEMA_VERSION = 'agc-template-library.v1';
const TEMPLATE_SCHEMA_VERSION = 'agc-template.v1';
/** 说明文档上限:它随每次发布覆盖写 `templates/README.md`,不做内容寻址。 */
const README_MAX_BYTES = 64 * 1024;
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
const RUNTIMES = new Set(['html', 'unity', 'godot', 'cocos']);
@@ -380,7 +382,14 @@ export function buildLibrary(source, prefix, only = []) {
body: Buffer.from(`${JSON.stringify(indexJson, null, 2)}\n`, 'utf8'),
contentType: 'application/json',
});
return { objects, indexJson };
// 说明文档不是内容寻址对象:它固定挂在 `templates/README.md`,随每次发布覆盖,
// 客户端从不读取它,但运维和后台都需要看到与当次契约一致的说明。
const readmePath = join(source, 'README.md');
const readmeBody = existsSync(readmePath) ? readFileSync(readmePath) : null;
if (readmeBody && readmeBody.length > README_MAX_BYTES) {
throw new Error(`README.md 超过 ${README_MAX_BYTES} 字节上限`);
}
return { objects, indexJson, readmeBody };
}
export function mergeLibraryIndex(existing, selected) {
@@ -769,25 +778,43 @@ export async function publishLibrary(
`${JSON.stringify(indexJson, null, 2)}\n`,
'utf8',
);
// 指针写入结果不明时旧请求可能晚到,必须留锁,不能让后续发布者越过它。
releaseAllowed = false;
const published = await checkedRequest(
() => client.put(indexKey, indexBytes, 'application/json'),
'发布清单',
);
if (published.ok) {
releaseAllowed = true;
} else if (
published.status >= 400 &&
published.status < 500 &&
published.status !== 408
) {
releaseAllowed = true;
throw new Error(`发布清单被拒绝:HTTP ${published.status}`);
} else {
throw new Error(`发布清单结果不明:HTTP ${published.status}`);
// 可变指针(说明文档、库清单)写入结果不明时旧请求可能晚到,必须留锁,
// 不能让后续发布者越过它。清单必须最后写,否则会出现「清单已指新对象、
// 说明还是旧版」的可观测窗口。
const pointerWrites = [
...(library.readmeBody
? [
{
key: `${prefix}/README.md`,
body: library.readmeBody,
contentType: 'text/markdown; charset=utf-8',
},
]
: []),
{ key: indexKey, body: indexBytes, contentType: 'application/json' },
];
for (const pointer of pointerWrites) {
releaseAllowed = false;
const published = await checkedRequest(
() => client.put(pointer.key, pointer.body, pointer.contentType),
`发布 ${pointer.key}`,
);
if (published.ok) {
releaseAllowed = true;
} else if (
published.status >= 400 &&
published.status < 500 &&
published.status !== 408
) {
releaseAllowed = true;
throw new Error(`发布 ${pointer.key} 被拒绝:HTTP ${published.status}`);
} else {
throw new Error(
`发布 ${pointer.key} 结果不明:HTTP ${published.status}`,
);
}
await verifyObject(client, pointer.key, pointer.body);
}
await verifyObject(client, indexKey, indexBytes);
} catch (error) {
failure = error;
} finally {
@@ -858,6 +885,11 @@ async function main() {
for (const object of library.objects.slice(0, -1)) {
console.log(` PUT ${object.key} (${object.body.length} B)`);
}
if (library.readmeBody) {
console.log(
` PUT ${args.prefix}/README.md (${library.readmeBody.length} B,覆盖写)`,
);
}
console.log(
` PUT ${args.prefix}/index.json (${Buffer.byteLength(`${JSON.stringify(indexJson, null, 2)}\n`)} B)`,
);
@@ -871,6 +903,9 @@ if (
) {
main().catch((error) => {
console.error(`[agc-template-library-publish] ${error.message}`);
process.exit(1);
// 直接 `process.exit(1)` 会在 fetch 句柄尚未关闭时触发 libuv 断言崩溃
// Windows 上实测只剩一句 Assertion failed,看不到拒绝原因);
// 交给事件循环自然退出,保留真实错误信息。
process.exitCode = 1;
});
}
@@ -190,6 +190,42 @@ const publish = (built, store) =>
log: () => {},
});
test('说明文档随发布覆盖写线上 README,且写在清单之前', async () => {
const store = memoryOss(oldPublishedLibrary());
const built = {
...library(),
readmeBody: Buffer.from('# AGC 游戏模板库\n\n- 契约说明\n', 'utf8'),
};
await publish(built, store);
assert.equal(
store.objects.get('templates/README.md').toString('utf8'),
built.readmeBody.toString('utf8'),
);
const putKeys = store.calls
.filter((call) => call.method === 'PUT')
.map((call) => call.key);
assert.deepEqual(putKeys.slice(-2), [
'templates/README.md',
'templates/index.json',
]);
});
test('源目录没有 README 时不写线上说明文档', async () => {
const store = memoryOss(oldPublishedLibrary());
await publish({ ...library(), readmeBody: null }, store);
assert.equal(store.objects.has('templates/README.md'), false);
assert.equal(
store.calls.some(
(call) => call.method === 'PUT' && call.key === 'templates/README.md',
),
false,
);
});
test('所有发布正文均使用自身字节摘要定位,重复打包复用ZIP与封面地址', () => {
const built = library();
for (const object of built.objects.filter(