修复AGC随包资源staging边界
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m34s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Repository checks (pull_request) Failing after 25s
Project CI / Native shell tests (pull_request) Failing after 1m44s
Project CI / Frontend tests (pull_request) Successful in 2m12s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m30s

修复 --no-bundle 构建前的资源准备接线。

拒绝插件随包目录中的未声明文件并同步Rust校验。

增加staging替换失败后的旧资源恢复与回归测试。
This commit is contained in:
2026-09-30 19:12:30 +08:00
parent 6d789b9dc0
commit d2d78fa3d4
7 changed files with 733 additions and 40 deletions
@@ -505,10 +505,11 @@ export function runTauriBuild(
const tauriArguments = buildTauriBuildArguments(args, context.target);
const { channel, target } = context;
const features = resolveEditorFeatures({ argv: args, target });
// --no-bundle 只跳过发行运行时资源;应用自身构建仍需先准备随包资源。
if (!args.includes('--no-bundle')) {
stageRuntime(target);
stageBundled(target, { features });
}
stageBundled(target, { features });
const configPath = writeChannelConfigFile(
channel,
target,
@@ -1001,6 +1001,7 @@ test('release stages Node before Tauri and injects its resource mapping only for
events.push('bundled');
},
spawn(_binary, args) {
events.push('build');
const config = JSON.parse(
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
);
@@ -1013,6 +1014,7 @@ test('release stages Node before Tauri and injects its resource mapping only for
return { status: 0 };
},
});
assert.deepEqual(events, ['bundled', 'build']);
assert.throws(
() =>
runTauriBuild(['--target', windowsTarget], context, {
@@ -485,6 +485,7 @@ function renderLibraryStaging(entry) {
['targets', rustStrings(entry.targets)],
['features', rustStrings(entry.features)],
['layout', rustString(entry.layout)],
['files', rustStrings(entry.files)],
],
1,
);
@@ -488,8 +488,11 @@ function buildUnitInto(
}
function stageAtomically(unitPath, builder) {
const stagingPath = `${unitPath}-staging-${process.pid}-${randomBytes(4).toString('hex')}`;
const suffix = `${process.pid}-${randomBytes(4).toString('hex')}`;
const stagingPath = `${unitPath}-staging-${suffix}`;
const backupPath = `${unitPath}-backup-${suffix}`;
rmSync(stagingPath, { recursive: true, force: true });
rmSync(backupPath, { recursive: true, force: true });
mkdirSync(stagingPath, { recursive: true });
try {
builder(stagingPath);
@@ -497,15 +500,31 @@ function stageAtomically(unitPath, builder) {
rmSync(stagingPath, { recursive: true, force: true });
throw error;
}
rmSync(unitPath, { recursive: true, force: true });
const hadPrevious = existsSync(unitPath);
try {
if (hadPrevious) {
renameSync(unitPath, backupPath);
}
renameSync(stagingPath, unitPath);
} catch (error) {
// 并发调用未做加锁(见技术方案 §4.3):这里只保证失败可读、且不丢已经生成好的 staging。
let restored = !hadPrevious;
if (hadPrevious && existsSync(backupPath)) {
try {
if (existsSync(unitPath)) {
rmSync(unitPath, { recursive: true, force: true });
}
renameSync(backupPath, unitPath);
restored = true;
} catch {
restored = false;
}
}
fail(
`替换 ${unitPath} 失败(${error.code ?? error.message}):同一资源目录可能正被另一个准备步骤进程写入;staging 已保留在 ${stagingPath},确认没有并发进程后重试`,
`替换 ${unitPath} 失败(${error.code ?? error.message}):staging 保留在 ${stagingPath};旧资源${restored ? '已恢复' : '恢复失败,请检查 ' + backupPath},确认没有并发进程后重试`,
);
}
rmSync(backupPath, { recursive: true, force: true });
}
function prepareCodex({
@@ -860,6 +879,30 @@ function subdirectoryEnabled(subdirectory, target, features) {
return matchesContains && matchesTarget && matchesFeatures;
}
function collectTreeFiles(root, label) {
const files = [];
const stack = [['', root]];
while (stack.length > 0) {
const [prefix, directory] = stack.pop();
if (!existsSync(directory)) {
continue;
}
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
const entryPath = path.join(directory, entry.name);
if (entry.isSymbolicLink()) {
fail(`${label}不允许符号链接:${entryPath}`);
}
if (entry.isDirectory()) {
stack.push([relative, entryPath]);
} else if (entry.isFile()) {
files.push(relative);
}
}
}
return files.sort();
}
/// 复制规则由各 section 自带(plugins / claudeAgent),同名语义、同序判定。
function skipDirectory(rules, name) {
return (
@@ -958,7 +1001,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) {
for (const staging of declaration.plugins.libraryStaging ?? []) {
if (
plugin.name !== staging.plugin ||
libraryStagingEnabled(staging, target, features)
!libraryStagingEnabled(staging, target, features)
) {
continue;
}
@@ -978,7 +1021,7 @@ function pluginSourceFingerprint(declaration, plugins, target, features) {
for (const payload of declaration.plugins.nativePayloads ?? []) {
if (
plugin.name !== payload.plugin ||
nativePayloadEnabled(payload, target, features)
!nativePayloadEnabled(payload, target, features)
) {
continue;
}
@@ -1007,8 +1050,11 @@ function pluginTreeMatches(
if (!existsSync(destination)) {
return false;
}
const allowedFiles = new Set();
for (const plugin of plugins) {
const pluginDestination = path.join(destination, plugin.name);
const pluginPrefix = `${plugin.name}/`;
allowedFiles.add(`${pluginPrefix}${declaration.plugins.manifestFileName}`);
if (
!existsSync(
path.join(pluginDestination, declaration.plugins.manifestFileName),
@@ -1022,17 +1068,24 @@ function pluginTreeMatches(
}
const stagedDirectory = path.join(pluginDestination, subdirectory.path);
if (!subdirectoryEnabled(subdirectory, target, features)) {
// 当前目标/feature 下不该出现的子目录:存在即说明是别的构建留下的,必须重建清理。
if (existsSync(stagedDirectory)) {
return false;
}
continue;
}
const relativePrefix = `${plugin.name}/${subdirectory.path}/`;
const sourceRoot = path.join(plugin.root, subdirectory.path);
if (subdirectory.origin !== 'source') {
if (existsSync(sourceRoot) && !existsSync(stagedDirectory)) {
return false;
}
for (const relative of walkFiles(
declaration.plugins,
sourceRoot,
'插件资源',
)) {
allowedFiles.add(`${relativePrefix}${relative}`);
}
continue;
}
for (const relative of walkFiles(
@@ -1046,6 +1099,7 @@ function pluginTreeMatches(
subdirectory.path,
relative,
);
allowedFiles.add(`${relativePrefix}${relative}`);
if (!existsSync(staged)) {
return false;
}
@@ -1057,8 +1111,32 @@ function pluginTreeMatches(
}
}
}
for (const staging of declaration.plugins.libraryStaging ?? []) {
if (
plugin.name === staging.plugin &&
libraryStagingEnabled(staging, target, features)
) {
for (const relative of staging.files) {
allowedFiles.add(
`${plugin.name}/${staging.sourceSubdirectory}/${relative}`,
);
}
}
}
for (const payload of declaration.plugins.nativePayloads ?? []) {
if (
plugin.name === payload.plugin &&
nativePayloadEnabled(payload, target, features)
) {
allowedFiles.add(
`${plugin.name}/${payload.destinationSubdirectory}/${payload.destinationFileName ?? payload.sourceFileName}`,
);
}
}
}
return true;
return collectTreeFiles(destination, '插件随包目录').every((relative) =>
allowedFiles.has(relative),
);
}
function assertOwnedPluginRoot(destination, plugins) {
@@ -1504,10 +1582,16 @@ function preparePlugins({
});
return {
summary: `plugins 重新生成(${plugins.length} 个插件,写入 ${declaration.plugins.destinationDirectory})`,
record: { fingerprint },
record: {
fingerprint: pluginSourceFingerprint(
declaration,
plugins,
target,
features,
),
},
};
}
/// 准备全部随包资源;返回逐条汇总,供入口日志与测试断言。
export function prepareBundledResources({
target = resolveHostTarget(),
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
@@ -286,6 +287,41 @@ function prepare(fixture, overrides = {}) {
});
}
/// 替换失败注入:让「staging → 目标目录」的那一次 rename 抛错。
/// 走 node:fs 的 ESM 活绑定(syncBuiltinESMExports 同步),实现里不得为测试开后门;
/// body 是同步的,注入窗口内不会有别的调用跑进来。
function withRenameFailure(predicate, body) {
const original = fs.renameSync;
let injected = false;
fs.renameSync = (from, to) => {
if (
!injected &&
predicate(path.resolve(String(from)), path.resolve(String(to)))
) {
injected = true;
throw Object.assign(new Error('注入的替换失败'), { code: 'EPERM' });
}
return original.call(fs, from, to);
};
syncBuiltinESMExports();
try {
return body();
} finally {
fs.renameSync = original;
syncBuiltinESMExports();
}
}
/// 把「必须失败」的调用收敛成断言:返回错误对象,没抛错即用例失败。
function expectThrow(body) {
try {
body();
} catch (error) {
return error;
}
throw new Error('预期抛错但调用成功了');
}
test('stages declared codex components with manifest and preserved notice', () => {
const fixture = buildFixture();
try {
@@ -824,3 +860,300 @@ test('delivers editor branch artifacts declared as prepared or library staging',
fixture.cleanup();
}
});
/// 全量 Windows 编辑器 feature:prepared / libraryStaging / nativePayload 三条交付路径全开。
const ALL_WINDOWS_FEATURES = new Set([
'unity-editor-execute',
'godot-editor-execute',
'cocos-editor-injection',
]);
function pluginStaged(fixture, relative) {
return path.join(
fixture.destinationRoot,
fixture.declaration.plugins.destinationDirectory,
relative,
);
}
test('keeps the previous codex resources when the replacement fails', () => {
const fixture = buildFixture();
try {
prepare(fixture);
const declaration = fixture.declaration;
const layout = declaration.codex.targets.find(
(entry) => entry.target === WINDOWS_TARGET,
);
const unit = path.join(
fixture.destinationRoot,
declaration.codex.resourceDirectory,
layout.directory,
);
const before = snapshot(unit);
const component = layout.files.find((relative) =>
relative.includes('code-mode-host'),
);
const vendor = path.join(
fixture.appRoot,
`node_modules/@openai/codex-${layout.platform}/vendor/${WINDOWS_TARGET}`,
);
// 上游组件变了:这一次必然走「staging → 目标」的替换,注入的失败正好落在替换上。
fs.writeFileSync(path.join(vendor, component), 'changed component\n');
// 锁定包未变化时 Codex 走缓存;移除记录强制重新读取上游内容。
fs.rmSync(fixture.recordPath);
const error = expectThrow(() =>
withRenameFailure(
(from, to) => to === path.resolve(unit),
() => prepare(fixture),
),
);
assert.match(error.message, /替换 .*win-x64.* 失败/u);
assert.match(error.message, /旧资源已恢复/u);
assert.deepEqual(
snapshot(unit),
before,
'替换失败必须把旧资源原样恢复:内容、尺寸、时间戳与可执行位都不许变',
);
assert.equal(
fs.readFileSync(
path.join(unit, declaration.codex.noticeFileName),
'utf8',
),
'windows codex notice\n',
'受版本控制的第三方声明必须还在原位',
);
const stagingPath = /staging 保留在 ([^;]+);/u.exec(error.message)?.[1];
assert.ok(stagingPath, `报错必须给出 staging 位置:${error.message}`);
// 目标目录旁边只允许剩「旧资源」和「留给人工检查的新 staging」;backup 必须已经归位。
const siblings = fs.readdirSync(path.dirname(unit));
assert.ok(siblings.includes(path.basename(unit)));
assert.ok(siblings.includes(path.basename(stagingPath)));
assert.ok(
siblings.every((entry) => !entry.includes('-backup-')),
'恢复成功后不得留下 backup 目录',
);
assert.equal(
fs.readFileSync(path.join(stagingPath, component), 'utf8'),
'changed component\n',
'没有落盘的新产物必须留在 staging 里供人工检查',
);
// 注入消失后重试必须成功:旧资源完整 → 替换重新做一遍 → 落盘的是上游新内容。
const summaries = prepare(fixture);
assert.match(summaries[0], /重新生成/u);
assert.equal(
fs.readFileSync(path.join(unit, component), 'utf8'),
'changed component\n',
);
} finally {
fixture.cleanup();
}
});
test('keeps the previous plugin resources when the replacement fails', () => {
const fixture = buildFixture();
try {
prepare(fixture);
const destination = path.join(
fixture.destinationRoot,
fixture.declaration.plugins.destinationDirectory,
);
// 未声明的额外文件让缓存判定必然漂移:这次一定会走完整替换。
fs.writeFileSync(
path.join(destination, 'agc-demo-editor/src/rogue.mjs'),
'rogue\n',
);
const before = snapshot(destination);
const error = expectThrow(() =>
withRenameFailure(
(from, to) => to === path.resolve(destination),
() => prepare(fixture),
),
);
assert.match(error.message, /替换 .*plugins.* 失败/u);
assert.match(error.message, /旧资源已恢复/u);
assert.deepEqual(
snapshot(destination),
before,
'替换失败时旧插件资源(含尚未清理的额外文件)必须逐字节保留',
);
prepare(fixture);
assert.ok(
!fs.existsSync(path.join(destination, 'agc-demo-editor/src/rogue.mjs')),
'注入消失后重试必须成功并清掉额外文件',
);
assert.ok(
fs.existsSync(
path.join(destination, 'agc-demo-editor/panels/panel.html'),
),
'重试后声明的随包内容必须齐全',
);
} finally {
fixture.cleanup();
}
});
test('clears undeclared and hidden entries left in the staged plugin tree', () => {
const fixture = buildFixture();
try {
prepare(fixture);
// 三种残留:额外目录、隐藏目录、声明的源码目录里的隐藏文件。
for (const relative of [
'agc-demo-editor/extra/rogue.txt',
'agc-demo-editor/.cache/tmp.bin',
'agc-demo-editor/src/.env',
]) {
const file = pluginStaged(fixture, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'residue\n');
}
const summaries = prepare(fixture);
assert.match(summaries[1], /plugins 重新生成/u);
for (const relative of [
'agc-demo-editor/extra',
'agc-demo-editor/.cache',
'agc-demo-editor/src/.env',
]) {
assert.ok(
!fs.existsSync(pluginStaged(fixture, relative)),
`未声明的残留 ${relative} 必须被清掉`,
);
}
for (const relative of [
'agc-demo-editor/plugin.json',
'agc-demo-editor/src/entry.mjs',
'agc-demo-editor/panels/panel.html',
]) {
assert.ok(
fs.existsSync(pluginStaged(fixture, relative)),
`声明的随包内容 ${relative} 必须还在`,
);
}
assert.ok(
!fs.existsSync(
pluginStaged(fixture, 'agc-demo-editor/panels/panel.test.mjs'),
),
'跳过规则在重建后依然生效',
);
} finally {
fixture.cleanup();
}
});
test('removes staged plugin files whose source has been deleted', () => {
const fixture = buildFixture();
try {
prepare(fixture);
assert.ok(
fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/panels/panel.html')),
'前置条件:源码里的文件已经随包',
);
fs.rmSync(
path.join(fixture.repoRoot, 'plugins/agc-demo-editor/panels/panel.html'),
);
const summaries = prepare(fixture);
assert.match(summaries[1], /plugins 重新生成/u);
assert.ok(
!fs.existsSync(
pluginStaged(fixture, 'agc-demo-editor/panels/panel.html'),
),
'源码里已删除的文件不得留在随包目录',
);
assert.ok(
fs.existsSync(pluginStaged(fixture, 'agc-demo-editor/src/entry.mjs')),
'同一插件的其他声明内容必须还在',
);
// 重建后的目录必须自洽:紧接着一次准备应命中缓存而不是反复重建。
assert.match(prepare(fixture)[1], /命中缓存/u);
} finally {
fixture.cleanup();
}
});
test('clears staged prepared payloads when their feature is disabled', () => {
const fixture = buildFixture();
try {
prepare(fixture, { features: ALL_WINDOWS_FEATURES });
for (const relative of [
'agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe',
'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
'agc-godot-editor/native/gdextension/vendor/provenance.json',
]) {
assert.ok(
fs.existsSync(pluginStaged(fixture, relative)),
`前置条件:feature 全开时 ${relative} 必须随包`,
);
}
prepare(fixture, { features: new Set() });
for (const relative of [
'agc-cocos-editor/native/payload',
'agc-unity-editor/dotnet',
'agc-godot-editor/native',
]) {
assert.ok(
!fs.existsSync(pluginStaged(fixture, relative)),
`feature 关闭后 ${relative} 不得作为残留继续随包`,
);
}
for (const plugin of [
'agc-cocos-editor',
'agc-unity-editor',
'agc-godot-editor',
]) {
assert.ok(
fs.existsSync(pluginStaged(fixture, `${plugin}/plugin.json`)),
`feature 关闭不得动到 ${plugin} 的声明内容`,
);
assert.ok(
fs.existsSync(pluginStaged(fixture, `${plugin}/src/entry.mjs`)),
`feature 关闭不得动到 ${plugin} 的源码内容`,
);
}
} finally {
fixture.cleanup();
}
});
test('treats declared prepared and library-staging artifacts as owned on a second run', () => {
const fixture = buildFixture();
try {
prepare(fixture, { features: ALL_WINDOWS_FEATURES });
const destination = path.join(
fixture.destinationRoot,
fixture.declaration.plugins.destinationDirectory,
);
const before = snapshot(destination);
const summaries = prepare(fixture, { features: ALL_WINDOWS_FEATURES });
assert.match(
summaries[1],
/plugins 命中缓存(未写入/u,
'prepared 子目录与 libraryStaging 产物属于本工具,不得被误判成外来内容而反复重建',
);
assert.deepEqual(
snapshot(destination),
before,
'命中缓存时一个字节、一个时间戳都不许动',
);
for (const relative of [
'agc-cocos-editor/native/payload/cocos-editor-bridge.dll',
'agc-unity-editor/dotnet/publish/win-x64/Agc.Unity.Attach.exe',
'agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll',
'agc-godot-editor/native/gdextension/vendor/provenance.json',
]) {
assert.ok(
fs.existsSync(pluginStaged(fixture, relative)),
`已声明产物 ${relative} 不得因为缓存判定被删掉`,
);
}
} finally {
fixture.cleanup();
}
});