Files
Genarrative/apps/ai-game-creator-shell/scripts/build-release.test.mjs
T
kdletters 485ed50b26
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend 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 shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
AGC 七项交付效率优化并升级捆绑 Codex 到 0.155.1 (#439)
## 背景

净月潭案例(2026-09-19 21:53 → 09-20 01:26,3 小时 33 分)的问题不是模型慢,而是环境未就绪、验收靠模型自述、预算只约束单个工具入口、工具调用被 SDK 全局串行闸门卡住。本 PR 落地确认后的七项交付效率合同,并把捆绑 Codex 升到当前 npm latest。

## 结果

- 新建 Web 游戏在正式生成前由宿主自动预检:捆绑 Node/npm、真实 Vite 构建、受限浏览器桌面/移动截图;失败不启动生成或付费素材。
- 首个副作用前冻结交付合同,宿主保存权威证据与预算;证据齐全后先封口、排空并取得执行器完整退出证明,再产出交付报告,模型回复不再当作验收。
- 原生 shell、内置浏览器与托管命令、第三方 MCP 共用同一执行许可和累计执行时间;`validation.maxRuns` 改为执行/返修批次语义,另设 `maxTurnSeconds` 墙钟上限。
- 所有工具可并发:移除 SDK 全局串行的 `apply_patch`/`update_plan` 注册,改由宿主 `agc_apply_patch`(官方 parser + 当前回合写许可 + 受控进程树 + 短项目事务)和 `agc_update_plan` 提供等价能力;真实请求目录里已无串行注册,长 MCP 与补丁/计划实测在同一响应内重叠。
- 付费提交与本地写入绑定原回合原租约:容量与同动作锁等待可取消,封口、取消或预算耗尽后零新增提交;已越过提交边界的请求保留 operation ID 走 GET 对账,不自动重放。
- 新增请求与工具分段计时、有界并行批读和首轮上下文预取,未知耗时不补零。
- 捆绑 Codex 0.147.0 → 0.155.1:固定版本收敛到 `build_support/codex_bundle.rs` 单一声明,vendor 解析源码按 `rust-v0.155.1` 逐字节重取并更新 UPSTREAM 证据,适配 0.155 统一 exec(`exec_command`/`write_stdin`);macOS 侧车最小系统版本仍为 15.0。

## 验证

- 生产 CLI → 捆绑 Codex 0.155.1 → 本地 Responses/MCP 夹具 9/9:补丁、完成收尾、批次、只读/可写 MCP 并发、原生命令并发、原生资源并发、统一 exec 会话、期限终止(真实重叠 775 / 764 / 999 ms)。
- 真实模型目录 2/2;vendor 上游库 111 项;宿主定向与回归 106 项;真实 Windows 进程与取消 21 项;Node 侧门禁 52 项。
- 发行载荷:artifact-only 重建 NSIS,解包验证侧车清单 `codex-cli 0.155.1`、6 个组件哈希、打包后 `bin/codex.exe --version`、Node 运行时 2130 文件与双端 PNG、`--environment-check` ready。
- `check-config`、TypeScript、编码(4991 文件)、文档索引、`git diff --check`、`cargo fmt --check` 全部通过。

## 未覆盖

- 真实陶泥儿登录态 Provider 生成尚未执行(本机 `--llm-status` 返回 `authentication-required`)。
- macOS 侧车只在本机做静态 Mach-O 与清单解析,未在 macOS 上跑 `check-macos-bundle.mjs`。
- 全仓库聚合套件在本机仍因负载敏感的后台 mock-provider 用例而红,与本次改动无关:改动前的旧二进制同样失败,换单线程后相关用例 3/3 通过。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/439
2026-09-21 02:29:29 +08:00

1078 lines
34 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.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import {
agcReleasePathPatterns,
buildRelease,
buildTauriBuildArguments,
collectRecentReleaseCommits,
collectReleaseCommits,
compareVersions,
createChannelConfig,
createLegacyUpdateManifest,
createUpdateManifest,
formatRecentReleaseNotes,
formatReleaseNotes,
generateUpdateManifest,
nextPatchVersion,
resolveManifestPlatformKeys,
resolvePreviousReleaseCommit,
resolveReleaseChannel,
resolveReleaseContext,
resolveReleasePartition,
resolveRemoteHighWaterVersion,
runTauriBuild,
selectFirstInstallArtifact,
selectReleaseArtifact,
updateManifestUrl,
} from './build-release.mjs';
const windowsTarget = 'x86_64-pc-windows-msvc';
const universalTarget = 'universal-apple-darwin';
const packageVersion = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
).version;
function createDmgFixture(root, target, version = packageVersion) {
const architecture = target.startsWith('aarch64')
? 'aarch64'
: target === universalTarget
? 'universal'
: 'x64';
const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`);
writeFileSync(dmg, 'first installation disk image');
return dmg;
}
test('native sidecar builds accept universal and each macOS architecture', () => {
for (const target of [
universalTarget,
'aarch64-apple-darwin',
'x86_64-apple-darwin',
]) {
assert.deepEqual(buildTauriBuildArguments([], target), [
'build',
'--target',
target,
]);
}
});
function withEnv(overrides, run) {
const previous = new Map();
for (const [key, value] of Object.entries(overrides)) {
previous.set(key, process.env[key]);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
return run();
} finally {
for (const [key, value] of previous) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
function withSignedArtifact(fileName, run) {
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
try {
const artifact = path.join(directory, fileName);
writeFileSync(artifact, 'installation package');
writeFileSync(`${artifact}.sig`, 'signature-content\n');
return run(artifact);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
function jsonResponse(body, status = 200) {
return {
status,
ok: status >= 200 && status < 300,
json: async () => body,
};
}
function withStubbedFetch(handler, run) {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => handler(String(url));
return Promise.resolve(run()).finally(() => {
globalThis.fetch = originalFetch;
});
}
test('selects an explicit release artifact when configured', () => {
const artifactPath = fileURLToPath(
new URL('../package.json', import.meta.url),
);
withEnv({ AGC_UPDATE_ARTIFACT: artifactPath }, () => {
assert.equal(selectReleaseArtifact([]), artifactPath);
});
});
test('does not select unsupported files', () => {
assert.equal(
selectReleaseArtifact(['/tmp/latest.json', '/tmp/readme.txt']),
null,
);
});
test('channels are independent of platform and accept release and custom names', () => {
assert.equal(resolveReleaseChannel({}), 'dev');
for (const channel of ['dev', 'release', 'beta-2', 'a'.repeat(32)]) {
assert.equal(
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }),
channel,
);
assert.equal(
resolveReleasePartition(channel, windowsTarget),
`${channel}-win`,
);
assert.equal(
resolveReleasePartition(channel, 'aarch64-apple-darwin'),
`${channel}-mac`,
);
}
assert.equal(
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: ' release ' }),
'release',
);
for (const channel of [
'',
' ',
'win',
'mac',
'windows',
'macos',
'darwin',
'linux',
'dev-win',
'dev-mac',
'Release',
'../dev',
'a/b',
'a_b',
'-beta',
'beta-',
'1beta',
'a'.repeat(33),
]) {
assert.throws(
() => resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }),
/发布渠道无效/u,
);
}
});
test('channel manifest URL and build-time endpoint follow the channel', () => {
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
assert.equal(
updateManifestUrl('dev', windowsTarget),
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
);
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
plugins: {
updater: {
endpoints: [
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json',
],
},
},
});
assert.equal(
updateManifestUrl('release', windowsTarget),
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/release-win/latest.json',
);
assert.equal(
createChannelConfig('beta-2', 'x86_64-apple-darwin').plugins.updater
.endpoints[0],
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/beta-2-mac/latest.json',
);
});
});
test('packaged renderer receives the same channel as the updater manifest', () => {
const context = resolveReleaseContext([], {
AGC_BUILD_TARGET: windowsTarget,
AGC_UPDATE_CHANNEL: 'release',
});
let spawnOptions;
runTauriBuild([], context, {
spawn: (_binary, _args, options) => {
spawnOptions = options;
return { status: 0 };
},
});
assert.equal(spawnOptions?.env?.VITE_AGC_PLATFORM_CHANNEL, 'release');
});
test('macOS manifests advertise exactly the architectures actually built', () => {
assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
'darwin-aarch64',
'darwin-x86_64',
]);
assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
'darwin-aarch64',
]);
assert.deepEqual(resolveManifestPlatformKeys('x86_64-apple-darwin'), [
'darwin-x86_64',
]);
assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [
'windows-x86_64',
]);
});
test('release context resolves explicit targets before environment/default and fails closed', () => {
for (const args of [
['--target', 'aarch64-apple-darwin'],
['--target=aarch64-apple-darwin'],
['-t', 'aarch64-apple-darwin'],
]) {
for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) {
const context = resolveReleaseContext(args, env);
assert.equal(context.target, 'aarch64-apple-darwin');
assert.equal(context.channel, 'dev');
assert.match(
context.bundleRoot.replaceAll('\\', '/'),
/target\/aarch64-apple-darwin\/release\/bundle$/,
);
assert.ok(Object.isFrozen(context));
}
assert.throws(
() => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }),
/发布渠道无效/,
);
}
assert.equal(resolveReleaseContext([], {}).target, windowsTarget);
assert.equal(
resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' })
.channel,
'dev',
);
for (const args of [
['--target'],
['--target='],
['--target', '--no-bundle'],
['--target', windowsTarget, '--target=aarch64-apple-darwin'],
['--target', 'unknown'],
])
assert.throws(() => resolveReleaseContext(args, {}));
});
test('explicit macOS target drives version lookup, Tauri endpoint, artifact and manifest together', async () => {
const calls = [];
const seenContexts = [];
await withStubbedFetch(
(url) => {
calls.push(url);
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({ version: '0.1.67' });
},
() =>
withEnv(
{ AGC_BUILD_TARGET: undefined, AGC_UPDATE_CHANNEL: undefined },
() =>
buildRelease(['--target', 'aarch64-apple-darwin'], {
prepareVersion: async (context) => {
seenContexts.push(context);
assert.equal(
await resolveRemoteHighWaterVersion(
context.channel,
context.target,
),
'0.1.67',
);
},
build: (args, context) => {
seenContexts.push(context);
runTauriBuild(args, context, {
stageRuntime: () => {},
spawn: (_binary, command) => {
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
readFileSync(command[configIndex + 1], 'utf8'),
);
assert.match(
config.plugins.updater.endpoints[0],
/\/dev-mac\/latest\.json$/,
);
assert.ok(command.includes('aarch64-apple-darwin'));
assert.ok(
!command.includes('--features=cocos-editor-execute'),
);
return { status: 0 };
},
});
},
generateManifest: (context) => {
seenContexts.push(context);
withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => {
assert.equal(
selectReleaseArtifact(
['/tmp/win.exe', artifact, '/tmp/mac.dmg'],
context.target,
),
artifact,
);
const manifest = createUpdateManifest(artifact, {
...context,
downloadArtifact: createDmgFixture(
path.dirname(artifact),
context.target,
),
});
assert.deepEqual(Object.keys(manifest.platforms), [
'darwin-aarch64',
]);
assert.match(
manifest.platforms['darwin-aarch64'].url,
/\/dev-mac\//,
);
});
},
}),
),
);
assert.equal(calls.length, 1, 'Mac 不应读取 Windows 迁移指针');
assert.equal(seenContexts.length, 3);
assert.ok(seenContexts.every((context) => context === seenContexts[0]));
});
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
test(`real manifest writer publishes the ${target} updater and first installer separately`, async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
try {
const artifact = path.join(root, '陶泥儿.app.tar.gz');
writeFileSync(artifact, 'mac package');
writeFileSync(`${artifact}.sig`, 'mac signature');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
const downloadArtifact = createDmgFixture(root, target);
const context = {
...resolveReleaseContext([`--target=${target}`], {}),
bundleRoot: root,
};
const result = await withStubbedFetch(
(url) => {
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({}, 404);
},
() => generateUpdateManifest(context),
);
assert.equal(result.artifact, artifact);
assert.equal(result.downloadArtifact, downloadArtifact);
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
assert.equal(result.legacyManifestPath, null);
const key = target.startsWith('aarch64')
? 'darwin-aarch64'
: 'darwin-x86_64';
assert.deepEqual(Object.keys(result.manifest.platforms), [key]);
assert.deepEqual(Object.keys(result.manifest.downloads), [key]);
assert.match(
result.manifest.platforms[key].url,
/\/dev-mac\/.*\.app\.tar\.gz$/,
);
assert.equal(
decodeURIComponent(
new URL(result.manifest.downloads[key].url).pathname,
),
`/agc/dev-mac/${packageVersion}/${path.basename(downloadArtifact)}`,
);
assert.deepEqual(
JSON.parse(readFileSync(result.manifestPath, 'utf8')),
result.manifest,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}
test('DMG selection ignores other versions and architectures but rejects missing, empty and ambiguous current packages', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-dmg-selection-'));
try {
const target = 'aarch64-apple-darwin';
const options = {
target,
version: '2.3.4',
artifact: path.join(root, '陶泥儿.app.tar.gz'),
};
const oldVersion = createDmgFixture(root, target, '2.3.3');
const wrongArchitecture = createDmgFixture(
root,
'x86_64-apple-darwin',
'2.3.4',
);
assert.throws(() => selectFirstInstallArtifact([], options), /找到 0 个/u);
assert.throws(
() =>
selectFirstInstallArtifact([oldVersion, wrongArchitecture], options),
/找到 0 个/u,
);
const current = createDmgFixture(root, target, '2.3.4');
assert.equal(
selectFirstInstallArtifact(
[oldVersion, wrongArchitecture, current],
options,
),
current,
);
writeFileSync(current, '');
assert.throws(
() => selectFirstInstallArtifact([current], options),
/不存在或为空/u,
);
writeFileSync(current, 'valid dmg');
const second = path.join(root, '另一包_2.3.4_aarch64.dmg');
writeFileSync(second, 'ambiguous dmg');
assert.throws(
() => selectFirstInstallArtifact([current, second], options),
/找到 2 个/u,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('manifest writer refuses to create latest when the current Mac DMG is missing', async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-missing-dmg-'));
try {
const artifact = path.join(root, '陶泥儿.app.tar.gz');
writeFileSync(artifact, 'updater archive');
writeFileSync(`${artifact}.sig`, 'signature');
const context = {
...resolveReleaseContext(['--target=aarch64-apple-darwin'], {}),
bundleRoot: root,
};
await assert.rejects(
() => generateUpdateManifest(context),
/首装 DMG 必须唯一匹配/u,
);
assert.throws(() => readFileSync(path.join(root, 'latest.json')), {
code: 'ENOENT',
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('invalid target or platform used as channel fails before any release side effect', async () => {
let touched = false;
const sideEffects = {
prepareVersion: () => {
touched = true;
},
build: () => {
touched = true;
},
generateManifest: () => {
touched = true;
},
};
await assert.rejects(
() => buildRelease(['--target', 'unknown'], sideEffects),
/不支持的发布目标/,
);
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
assert.rejects(
() => buildRelease(['--target=aarch64-apple-darwin'], sideEffects),
/发布渠道无效/,
),
);
assert.equal(touched, false);
});
test('universal uses the Mac channel and the same signed artifact for both architectures', () => {
const context = resolveReleaseContext(['--target', universalTarget], {
AGC_BUILD_TARGET: windowsTarget,
});
// 渠道本身不含系统:分区由渠道 + 目标推导,二者不能混为一谈。
assert.equal(context.channel, 'dev');
assert.equal(
resolveReleasePartition(context.channel, context.target),
'dev-mac',
);
assert.ok(context.bundleRoot.includes(universalTarget));
withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => {
const manifest = createUpdateManifest(artifact, {
...context,
downloadArtifact: createDmgFixture(
path.dirname(artifact),
universalTarget,
),
});
assert.deepEqual(Object.keys(manifest.platforms), [
'darwin-aarch64',
'darwin-x86_64',
]);
assert.deepEqual(
manifest.platforms['darwin-aarch64'],
manifest.platforms['darwin-x86_64'],
);
assert.match(manifest.platforms['darwin-aarch64'].url, /\/dev-mac\//);
// 两个平台键共用同一个 universal 首装包,不能要求出两份架构 DMG。
assert.deepEqual(
manifest.downloads['darwin-aarch64'].url,
manifest.downloads['darwin-x86_64'].url,
);
assert.match(manifest.downloads['darwin-aarch64'].url, /_universal\.dmg$/u);
});
});
test('Windows remains the default and explicit Windows overrides macOS environment', () => {
const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
for (const context of [
resolveReleaseContext([], {}),
resolveReleaseContext(['--target', windowsTarget], {
AGC_BUILD_TARGET: 'aarch64-apple-darwin',
}),
]) {
assert.equal(context.channel, 'dev');
assert.equal(
selectReleaseArtifact(files, context.target),
'/tmp/windows.exe',
);
runTauriBuild(
['--target', windowsTarget, '--config', 'user-config.json'],
context,
{
stageRuntime: () => {},
spawn: (_binary, command) => {
assert.ok(
command.includes(
'--features=cocos-editor-execute,unity-editor-execute,godot-editor-execute',
),
);
assert.ok(command.includes('user-config.json'));
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
readFileSync(command[configIndex + 1], 'utf8'),
);
assert.match(
config.plugins.updater.endpoints[0],
/\/dev-win\/latest\.json$/,
);
return { status: 0 };
},
},
);
}
});
test('no-bundle smoke skips version writes and manifest generation', async () => {
const steps = [];
await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
prepareVersion: () => {
steps.push('version');
},
build: (_args, context) => {
steps.push(context.channel);
},
generateManifest: () => {
steps.push('manifest');
},
});
assert.deepEqual(steps, ['dev']);
});
test('release stages Node before Tauri and injects its resource mapping only for bundles', () => {
const context = resolveReleaseContext(['--target', windowsTarget]);
const events = [];
runTauriBuild(['--target', windowsTarget], context, {
stageRuntime(target) {
assert.equal(target, windowsTarget);
events.push('stage');
},
spawn(_binary, args) {
events.push('build');
const config = JSON.parse(
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
);
assert.deepEqual(config.bundle.resources, {
'resources/node-runtime': 'game-runtime/node',
});
return { status: 0 };
},
});
assert.deepEqual(events, ['stage', 'build']);
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
stageRuntime() {
assert.fail('no-bundle must not stage resources');
},
spawn(_binary, args) {
const config = JSON.parse(
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
);
assert.equal(config.bundle, undefined);
return { status: 0 };
},
});
assert.throws(
() =>
runTauriBuild(['--target', windowsTarget], context, {
stageRuntime() {
throw new Error('missing runtime');
},
spawn() {
assert.fail('invalid runtime must prevent build');
},
}),
/missing runtime/,
);
});
test('channel manifest carries version, platform keys and signature', () => {
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
const manifest = createUpdateManifest(artifact, {
channel: 'dev',
target: windowsTarget,
publishedAt: '2026-09-17T00:00:00.000Z',
});
assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
assert.equal(manifest.notes, '修复与改进');
assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
assert.deepEqual(manifest.downloads, {
'windows-x86_64': { url: manifest.platforms['windows-x86_64'].url },
});
assert.equal(
manifest.platforms['windows-x86_64'].signature,
'signature-content',
);
assert.match(
manifest.platforms['windows-x86_64'].url,
new RegExp(`/agc/dev-win/${manifest.version}/`, 'u'),
);
});
});
});
test('missing signature fails the channel manifest closed', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
try {
const artifact = path.join(directory, '陶泥儿_0.1.48_x64-setup.exe');
writeFileSync(artifact, 'installation package');
assert.throws(
() =>
createUpdateManifest(artifact, {
channel: 'dev',
target: windowsTarget,
}),
/缺少更新包签名/u,
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('legacy manifest keeps the sha256 contract of published clients', () => {
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
const legacy = createLegacyUpdateManifest(artifact, {
channel: 'dev',
});
assert.match(legacy.version, /^\d+\.\d+\.\d+$/u);
assert.equal(legacy.sha256.length, 64);
assert.equal(legacy.size, 'installation package'.length);
assert.match(legacy.downloadUrl, /\/agc\/dev-win\/[\d.]+\//u);
});
});
test('next release version follows the higher local or channel version', () => {
assert.equal(compareVersions('0.1.15', '0.1.12'), 1);
assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16');
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
});
for (const channel of ['release', 'beta-2']) {
for (const target of [windowsTarget, 'aarch64-apple-darwin']) {
test(`${channel} ${target} freezes its endpoint, version source and published objects`, async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-manifest-'));
try {
const windows = target === windowsTarget;
const partition = `${channel}-${windows ? 'win' : 'mac'}`;
const artifact = path.join(
root,
windows ? '陶泥儿_x64-setup.exe' : '陶泥儿.app.tar.gz',
);
writeFileSync(artifact, 'updater package');
writeFileSync(`${artifact}.sig`, 'updater signature');
if (!windows) createDmgFixture(root, target);
const context = {
...resolveReleaseContext([`--target=${target}`], {
AGC_UPDATE_CHANNEL: channel,
}),
bundleRoot: root,
};
const requests = [];
const result = await withStubbedFetch(
(url) => {
requests.push(url);
assert.ok(url.endsWith(`/agc/${partition}/latest.json`));
return jsonResponse({
version: '2.3.4',
commit: 'abcdef1234567890',
});
},
async () => {
assert.equal(
await resolveRemoteHighWaterVersion(
context.channel,
context.target,
),
'2.3.4',
);
runTauriBuild([`--target=${target}`], context, {
stageRuntime: () => {},
spawn: (_binary, command) => {
const config = JSON.parse(
readFileSync(
command[command.lastIndexOf('--config') + 1],
'utf8',
),
);
assert.ok(
config.plugins.updater.endpoints[0].endsWith(
`/agc/${partition}/latest.json`,
),
);
return { status: 0 };
},
});
return generateUpdateManifest(context);
},
);
assert.equal(result.channel, channel);
assert.equal(result.target, target);
assert.equal(result.manifest.version, packageVersion);
assert.equal(result.legacyManifestPath, null);
assert.equal(result.legacyManifest, null);
assert.equal(requests.length, 2);
for (const entry of [
...Object.values(result.manifest.platforms),
...Object.values(result.manifest.downloads),
]) {
assert.ok(entry.url.includes(`/agc/${partition}/${packageVersion}/`));
}
assert.throws(
() => createLegacyUpdateManifest(artifact, { channel, target }),
/只属于 dev 渠道/u,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}
}
test('version high water keeps the legacy pointer during the migration window', async () => {
await withStubbedFetch(
(url) =>
url.endsWith('/agc/dev-win/latest.json')
? jsonResponse({}, 404)
: jsonResponse({ version: '0.1.57' }),
async () => {
assert.equal(
await resolveRemoteHighWaterVersion('dev', windowsTarget),
'0.1.57',
);
// 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。
assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58');
},
);
});
test('version high water takes the higher of channel and legacy pointer', async () => {
await withStubbedFetch(
(url) =>
url.endsWith('/agc/dev-win/latest.json')
? jsonResponse({ version: '0.1.60' })
: jsonResponse({ version: '0.1.57' }),
async () => {
assert.equal(
await resolveRemoteHighWaterVersion('dev', windowsTarget),
'0.1.60',
);
},
);
});
test('version high water ignores the windows migration pointer for other channels', async () => {
await withStubbedFetch(
(url) => {
assert.ok(
!url.endsWith('/agc/latest.json'),
'non-windows channel must not read the windows migration pointer',
);
return jsonResponse({ version: '0.1.12' });
},
async () => {
for (const [channel, target] of [
['dev', 'aarch64-apple-darwin'],
['release', windowsTarget],
['beta-2', windowsTarget],
]) {
assert.equal(
await resolveRemoteHighWaterVersion(channel, target),
'0.1.12',
);
}
},
);
});
test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => {
await withStubbedFetch(
() => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }),
async () => {
assert.equal(
await resolvePreviousReleaseCommit('dev', {
override: '6017d46088c04199e99cf89f347b12d67591475e',
}),
'6017d46088c04199e99cf89f347b12d67591475e',
);
// 覆盖值非法时忽略,继续用清单里的 commit。
assert.equal(
await resolvePreviousReleaseCommit('dev', {
override: 'not-a-sha',
}),
'abcdef1234567890',
);
assert.equal(
await resolvePreviousReleaseCommit('dev', { override: ' ' }),
'abcdef1234567890',
);
},
);
await withStubbedFetch(
() => jsonResponse({ version: '0.1.61' }),
async () => {
assert.equal(
await resolvePreviousReleaseCommit('dev', { override: undefined }),
null,
);
},
);
});
test('release notes anchor degrades to null when the manifest cannot be read', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error('fetch failed');
};
try {
assert.equal(
await resolvePreviousReleaseCommit('dev', { override: undefined }),
null,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('recent commit fallback marks that entries may repeat the previous release', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-'));
const git = (...args) =>
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
try {
git('init', '--quiet');
git('config', 'user.email', 'release@example.test');
git('config', 'user.name', 'release test');
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
recursive: true,
});
for (const name of ['one', 'two']) {
writeFileSync(
path.join(directory, `apps/ai-game-creator-shell/${name}.rs`),
`fn ${name}() {}\n`,
);
git('add', '.');
git('commit', '--quiet', '-m', `客户端:${name}`);
}
writeFileSync(path.join(directory, 'README.md'), '# 文档\n');
git('add', '.');
git('commit', '--quiet', '-m', '文档:说明');
const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 });
assert.deepEqual(
recent.map((entry) => entry.subject),
['客户端:two', '客户端:one'],
);
const notes = formatRecentReleaseNotes(recent);
assert.match(
notes,
/^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u,
);
assert.equal(formatRecentReleaseNotes([]), '');
assert.equal(formatRecentReleaseNotes(null), '');
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('release entry forwards the built artifacts and dry-run mode to the uploader', () => {
const source = readFileSync(
new URL('./release-upload.mjs', import.meta.url),
'utf8',
);
assert.match(
source,
/const release = await buildRelease\(process\.argv\.slice\(2\)\)/u,
);
assert.match(source, /uploadReleaseArtifacts\(release, \{/u);
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
assert.ok(source.includes('\n dryRun,\n'));
});
test('release notes list client commits with short sha and bound their size', () => {
const notes = formatReleaseNotes([
{ sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' },
{ sha: '55af6014', subject: '修'.repeat(120) },
]);
const lines = notes.split('\n');
assert.equal(lines.length, 2);
assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u);
const truncatedSubject = lines[1]
.replace(/^- /u, '')
.replace(/55af6014$/u, '');
assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
assert.match(truncatedSubject, /…$/u);
assert.match(lines[1], /55af6014$/u);
const many = formatReleaseNotes(
Array.from({ length: 20 }, (_, index) => ({
sha: `sha${index}`,
subject: `改动 ${index}`,
})),
);
assert.match(many, /- 其余 8 项客户端改动省略$/u);
assert.equal(formatReleaseNotes([]), '');
assert.equal(formatReleaseNotes(null), '');
});
test('release commits cover only client paths and skip merge commits', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-'));
const git = (...args) =>
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
try {
git('init', '--quiet');
git('config', 'user.email', 'release@example.test');
git('config', 'user.name', 'release test');
git('commit', '--allow-empty', '--quiet', '-m', '基点');
const base = git('rev-parse', 'HEAD').trim();
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
recursive: true,
});
mkdirSync(path.join(directory, 'docs'), { recursive: true });
writeFileSync(
path.join(directory, 'apps/ai-game-creator-shell/main.rs'),
'fn main() {}\n',
);
git('add', '.');
git('commit', '--quiet', '-m', '客户端:新增更新插件接入');
writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n');
git('add', '.');
git('commit', '--quiet', '-m', '文档:补充说明');
writeFileSync(
path.join(directory, 'apps/ai-game-creator-shell/other.rs'),
'fn other() {}\n',
);
git('add', '.');
git('commit', '--quiet', '-m', '客户端:修复版本回退');
git('checkout', '--quiet', '-b', 'side');
writeFileSync(
path.join(directory, 'apps/ai-game-creator-shell/side.rs'),
'fn side() {}\n',
);
git('add', '.');
git('commit', '--quiet', '-m', '客户端:侧分支改动');
git('checkout', '--quiet', 'master');
git('merge', '--quiet', '--no-ff', '--no-edit', 'side');
const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory });
assert.ok(commits, '应能在临时仓库里收集提交');
const subjects = commits.map((entry) => entry.subject);
// 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。
assert.deepEqual(subjects, [
'客户端:侧分支改动',
'客户端:修复版本回退',
'客户端:新增更新插件接入',
]);
assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha)));
assert.equal(
collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }),
null,
);
assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('scheduler path filter stays in sync with client release paths', () => {
const jenkinsfile = readFileSync(
new URL(
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
import.meta.url,
),
'utf8',
);
const filterLine = jenkinsfile
.split('\n')
.find((line) => line.includes('apps/ai-game-creator-shell/*|'));
assert.ok(filterLine, '调度管线里应存在发布范围过滤模式');
for (const pattern of agcReleasePathPatterns) {
const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern;
assert.ok(
filterLine.includes(bashPattern),
`调度管线过滤缺少 ${bashPattern}`,
);
}
});
test('scheduler skips the full build only for non-deploy paths', () => {
const jenkinsfile = readFileSync(
new URL(
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
import.meta.url,
),
'utf8',
);
const skipLine = jenkinsfile
.split('\n')
.find((line) => line.includes('docs/*|.codex/*|jenkins/*'));
assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式');
for (const pattern of [
'docs/*',
'.codex/*',
'jenkins/*',
'apps/ai-game-creator-shell/*',
'apps/mobile-shell/*',
'apps/desktop-shell/*',
'apps/preview-deployer-web/*',
'tools/*',
'*.md',
]) {
assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`);
}
});