模板库线上对齐仓库源:递增版本重发并把说明文档纳入发布
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
+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(