按门禁组拆分客户端 CI,AGC 的 web / rust 两段并行
Project CI / Native shell tests (push) Failing after 3m36s
Project CI / Frontend tests (push) Successful in 4m13s
Project CI / Repository checks (push) Successful in 3m3s
Project CI / AI game creator shell web tests (push) Successful in 2m31s
Project CI / Backend tests (push) Successful in 7m49s
Project CI / AI game creator shell Rust tests (push) Successful in 14m10s

原生壳门禁原本挤在同一个 job 里串行执行,跑一遍 18 分 37 秒,其中 AI 游戏创作
壳独占约 15 分钟(壳内 Rust 套件 2451 条用例串行 441 秒),而微信 / 移动 / 桌面 /
H5 的全部门禁加起来不到 50 秒。长尾拖住短门禁,runner 也无法并行。

- scripts/check-native-shells.mjs 支持 `--groups=`(contract / shells / agc-web /
  agc-rust / release):每个步骤与静态断言归属且只归属一个分组,不带参数时仍按
  原顺序串行跑全部分组,本地 `npm run check:native-shells` 语义不变。
- 顺带修掉 H5 HostBridge 调用链扫描在 Windows 上恒红的缺陷:collectFiles 返回
  反斜杠路径而期望清单是 POSIX 写法,scannedFiles.has() 永远为假。新增
  normalizeScannedFilePath 只统一分隔符(不能复用会去后缀的 normalizeModulePath),
  在 Linux 上是恒等变换。
- package.json 增加 5 个分组脚本,并把 ai-game-creator-shell:check 拆成
  :check:web 与 :check:rust;聚合脚本保持 web && rust && agent-run:smoke 同序。
  agent-run smoke 会 spawn cargo,因此归入 agc-rust。
- .gitea/workflows/project-ci.yml 拆成 6 个 job:新增 native-shell-tests(contract +
  shells + release)、ai-game-creator-shell-web-tests、ai-game-creator-shell-rust-tests
  三个门禁 job,与 backend-tests / frontend-tests / repository-checks 并列。
- scripts/project-ci-workflow.test.ts 增加 3 条结构测试:分组恰好被一个 job 调用且
  与脚本内声明一致、CI 不再调用全量入口、AGC web/rust 拆分与聚合脚本等价、独立
  crate 预热必须发生在 AGC Rust 门禁之前。
- 同步运维文档、development-workflow、decision-log、pitfalls。

验证:`--groups=contract` 本地通过;vitest 11 passed;eslint 与 prettier 通过;
check:encoding 13329 文件通过;check:doc-index 103 份通过。shells / agc-web /
agc-rust / release 分组只能由 Linux CI 执行(Windows 上 spawnSync npm.cmd 报
EINVAL,属既有平台限制,非本次引入)。分支保护需补两个新 required context:
`Project CI / AI game creator shell web tests (pull_request)` 与
`Project CI / AI game creator shell Rust tests (pull_request)`。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
This commit is contained in:
2026-09-14 16:13:11 +08:00
parent 4e553bb15d
commit 20f109027a
8 changed files with 554 additions and 168 deletions
+238 -51
View File
@@ -79,6 +79,72 @@ const aiGameCreatorViteConfigSource = fs.readFileSync(
'utf8',
);
// 按门禁组运行:默认跑全部分组(本地语义不变),CI 用 `--groups=` 把互不依赖的
// 分组拆成独立 job。每个步骤和静态断言必须属于且只属于一个分组,分组名同时是
// 根 `check:native-shells:<group>` 脚本和 workflow job 的拆分口径。
// - contract:纯源码 / 契约 / 清单断言,不需要任何构建产物。
// - shells:微信壳、Expo 移动壳、Tauri 桌面壳与 H5 HostBridge 的现役运行时门禁。
// - agc-webAI 游戏创作壳的前端门禁(typecheck 与壳内测试,不触碰 Cargo)。
// - agc-rustAI 游戏创作壳的 Rust 门禁(共享 / 平台 crate 测试、串行壳测试和
// 会用 `src-tauri/Cargo.toml` spawn `cargo` 的 agent-run smoke)。
// - release:发布构建 smoke 和依赖发布产物的落盘断言。
const nativeShellGateGroups = [
'contract',
'shells',
'agc-web',
'agc-rust',
'release',
];
const requestedNativeShellGroups = readRequestedNativeShellGroups(
process.argv.slice(2),
);
function readRequestedNativeShellGroups(argv) {
const groupsFlag = argv.find((argument) => argument.startsWith('--groups='));
if (groupsFlag === undefined) {
return nativeShellGateGroups;
}
const requested = groupsFlag
.slice('--groups='.length)
.split(',')
.map((group) => group.trim())
.filter(Boolean);
if (requested.length === 0) {
throw new Error(
`--groups requires at least one of: ${nativeShellGateGroups.join(', ')}`,
);
}
const unknownGroups = requested.filter(
(group) => !nativeShellGateGroups.includes(group),
);
if (unknownGroups.length > 0) {
throw new Error(
`unknown native shell gate group(s): ${unknownGroups.join(', ')}; expected ${nativeShellGateGroups.join(', ')}`,
);
}
return nativeShellGateGroups.filter((group) => requested.includes(group));
}
function runsNativeShellGateGroup(group) {
if (!nativeShellGateGroups.includes(group)) {
throw new Error(`unknown native shell gate group: ${group}`);
}
return requestedNativeShellGroups.includes(group);
}
function runNativeShellGate(group, label, gate) {
if (!runsNativeShellGateGroup(group)) {
return;
}
console.log(`[check:native-shells] ${label}`);
gate();
}
const productionShellScanRoots = [
'apps/mobile-shell',
'apps/desktop-shell',
@@ -161,7 +227,11 @@ function assertRootNativeShellCheckScripts() {
}
}
assertRootNativeShellCheckScripts();
runNativeShellGate(
'contract',
'root-native-shell-check-scripts',
assertRootNativeShellCheckScripts,
);
function assertNativeShellDependencyVersionGuardrails() {
for (const snippet of [
"const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url)",
@@ -209,7 +279,11 @@ function assertNativeShellDependencyVersionGuardrails() {
}
}
assertNativeShellDependencyVersionGuardrails();
runNativeShellGate(
'contract',
'native-shell-dependency-version-guardrails',
assertNativeShellDependencyVersionGuardrails,
);
const h5HostBridgeCallChainWrapperFiles = [
'src/hooks/useHostNavigationCanGoBack.ts',
'src/components/platform-entry/platformProfileHostClipboard.ts',
@@ -2156,6 +2230,7 @@ const h5HostBridgeTests = [
const h5NativeAppRouteFlowTestSteps = h5NativeAppRouteFlowContracts.flatMap(
(contract) =>
(contract.targetedTests ?? []).map((test) => ({
group: 'shells',
label: `h5-native-app-route-${contract.route}`,
command: npmCommand,
args: ['run', 'test', '--', test.filePath, '-t', test.name],
@@ -2176,67 +2251,98 @@ const wechatShellTests = [
const steps = [
{
group: 'shells',
label: 'h5-host-bridge-tests',
command: npmCommand,
args: ['run', 'test', '--', ...h5HostBridgeTests],
},
...h5NativeAppRouteFlowTestSteps,
{
group: 'shells',
label: 'wechat-shell-tests',
command: npmCommand,
args: ['run', 'test', '--', ...wechatShellTests],
},
{
group: 'shells',
label: 'mobile-shell-typecheck',
command: npmCommand,
args: ['run', 'mobile-shell:typecheck'],
},
{
group: 'shells',
label: 'mobile-shell-test',
command: npmCommand,
args: ['run', 'mobile-shell:test'],
},
{
group: 'shells',
label: 'mobile-shell-eas-build-config-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:build-config'],
},
{
group: 'shells',
label: 'mobile-shell-expo-config-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:config'],
},
{
group: 'shells',
label: 'mobile-shell-expo-export-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:export'],
},
{
group: 'shells',
label: 'desktop-shell-test',
command: npmCommand,
args: ['run', 'desktop-shell:test'],
},
{
group: 'shells',
label: 'desktop-shell-typecheck',
command: npmCommand,
args: ['run', 'desktop-shell:typecheck'],
},
// AI 游戏创作壳原先一步串完 typecheck、壳内测试、共享 / 平台 crate 测试和
// 串行壳测试,CI 因此只有一条 10 分钟以上的长尾。这里按同一组命令切成
// web 与 rust 两段,顺序与 `npm run ai-game-creator-shell:check` 完全一致,
// 但允许 CI 并行执行;本地全量运行仍然是 web -> rust -> smoke 原顺序。
{
label: 'ai-game-creator-shell-check',
group: 'agc-web',
label: 'ai-game-creator-shell-check-web',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check'],
args: ['run', 'ai-game-creator-shell:check:web'],
},
{
group: 'agc-rust',
label: 'ai-game-creator-shell-check-rust',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check:rust'],
},
// agent-run smoke 会用 `src-tauri/Cargo.toml` spawn `cargo`,因此与 Rust 段同组,
// 保证它落在已经预热 AGC Cargo 依赖的 job 里。
{
group: 'agc-rust',
label: 'ai-game-creator-shell-agent-run-smoke',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:agent-run:smoke'],
},
{
group: 'release',
label: 'ai-game-creator-shell-release-build-smoke',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:build', '--', '--no-bundle'],
},
{
group: 'release',
label: 'desktop-shell-release-build-smoke',
command: npmCommand,
args: ['run', 'desktop-shell:build', '--', '--no-bundle'],
},
{
group: 'release',
label: 'desktop-shell-stage-release-binary',
command: npmCommand,
args: ['run', 'desktop-shell:stage-release-binary'],
@@ -2664,6 +2770,12 @@ function normalizeModulePath(modulePath) {
.replace(/\.(jsx?|tsx?)$/, '');
}
// 调用链扫描用 POSIX 相对路径做集合成员比较,但 `collectFiles` 在 Windows 上返回
// 反斜杠路径。这里只统一分隔符、保留扩展名,Linux 上与原行为完全一致。
function normalizeScannedFilePath(filePath) {
return filePath.split(path.sep).join('/');
}
function importedModulePath(fromFile, specifier) {
if (specifier === '@') {
return '.';
@@ -2758,12 +2870,12 @@ function collectH5HostBridgeCallChainFiles() {
importsScannedFacadeCapability ||
imports.some((specifier) => wrapperModules.has(specifier))
) {
scannedFiles.add(file);
scannedFiles.add(normalizeScannedFilePath(file));
}
}
for (const wrapperFile of h5HostBridgeCallChainWrapperFiles) {
scannedFiles.add(wrapperFile);
scannedFiles.add(normalizeScannedFilePath(wrapperFile));
}
const missingRequiredFiles = h5HostBridgeRequiredCallChainFiles.filter(
@@ -5021,6 +5133,10 @@ function assertDesktopReleaseBinaryArtifact() {
}
for (const step of steps) {
if (!runsNativeShellGateGroup(step.group)) {
continue;
}
console.log(`[check:native-shells] ${step.label}`);
const result = spawnSync(step.command, step.args, {
cwd: process.cwd(),
@@ -5046,71 +5162,142 @@ for (const step of steps) {
}
}
console.log('[check:native-shells] desktop-release-binary-artifact');
assertDesktopReleaseBinaryArtifact();
runNativeShellGate(
'release',
'desktop-release-binary-artifact',
assertDesktopReleaseBinaryArtifact,
);
console.log('[check:native-shells] host-bridge-layer-layout');
assertHostBridgeLayerLayout();
runNativeShellGate(
'contract',
'host-bridge-layer-layout',
assertHostBridgeLayerLayout,
);
console.log('[check:native-shells] native-shell-capability-plan');
assertNativeShellCapabilityPlan();
runNativeShellGate(
'contract',
'native-shell-capability-plan',
assertNativeShellCapabilityPlan,
);
console.log('[check:native-shells] external-url-protocol-parity');
assertExternalUrlProtocolParity();
runNativeShellGate(
'contract',
'external-url-protocol-parity',
assertExternalUrlProtocolParity,
);
console.log('[check:native-shells] wechat-mini-program-route-parity');
assertWechatMiniProgramRouteParity();
runNativeShellGate(
'contract',
'wechat-mini-program-route-parity',
assertWechatMiniProgramRouteParity,
);
console.log('[check:native-shells] wechat-mini-program-capability-flows');
assertWechatMiniProgramCapabilityFlows();
runNativeShellGate(
'contract',
'wechat-mini-program-capability-flows',
assertWechatMiniProgramCapabilityFlows,
);
console.log('[check:native-shells] expo-mobile-capability-flows');
assertExpoMobileCapabilityFlows();
runNativeShellGate(
'contract',
'expo-mobile-capability-flows',
assertExpoMobileCapabilityFlows,
);
console.log('[check:native-shells] tauri-desktop-capability-flows');
assertTauriDesktopCapabilityFlows();
runNativeShellGate(
'contract',
'tauri-desktop-capability-flows',
assertTauriDesktopCapabilityFlows,
);
console.log('[check:native-shells] h5-native-app-route-flows');
assertH5NativeAppRouteFlows();
runNativeShellGate(
'contract',
'h5-native-app-route-flows',
assertH5NativeAppRouteFlows,
);
console.log('[check:native-shells] wechat-payment-result-boundaries');
assertWechatPaymentResultBoundaries();
runNativeShellGate(
'contract',
'wechat-payment-result-boundaries',
assertWechatPaymentResultBoundaries,
);
console.log('[check:native-shells] wechat-auth-failure-boundaries');
assertWechatAuthFailureBoundaries();
runNativeShellGate(
'contract',
'wechat-auth-failure-boundaries',
assertWechatAuthFailureBoundaries,
);
console.log('[check:native-shells] wechat-web-view-page-event-boundaries');
assertWechatWebViewPageEventBoundaries();
runNativeShellGate(
'contract',
'wechat-web-view-page-event-boundaries',
assertWechatWebViewPageEventBoundaries,
);
console.log('[check:native-shells] wechat-share-grid-failure-boundaries');
assertWechatShareGridFailureBoundaries();
runNativeShellGate(
'contract',
'wechat-share-grid-failure-boundaries',
assertWechatShareGridFailureBoundaries,
);
console.log('[check:native-shells] desktop-navigation-event-boundaries');
assertDesktopNavigationEventBoundaries();
runNativeShellGate(
'contract',
'desktop-navigation-event-boundaries',
assertDesktopNavigationEventBoundaries,
);
console.log('[check:native-shells] h5-host-bridge-event-subscription-gates');
assertH5HostBridgeEventSubscriptionGates();
runNativeShellGate(
'contract',
'h5-host-bridge-event-subscription-gates',
assertH5HostBridgeEventSubscriptionGates,
);
console.log('[check:native-shells] h5-host-bridge-payload-boundaries');
assertH5HostBridgePayloadBoundaries();
runNativeShellGate(
'contract',
'h5-host-bridge-payload-boundaries',
assertH5HostBridgePayloadBoundaries,
);
console.log('[check:native-shells] h5-native-app-transport-timeout-boundaries');
assertH5NativeAppTransportTimeoutBoundaries();
runNativeShellGate(
'contract',
'h5-native-app-transport-timeout-boundaries',
assertH5NativeAppTransportTimeoutBoundaries,
);
console.log('[check:native-shells] h5-native-app-message-source-boundaries');
assertH5NativeAppMessageSourceBoundaries();
runNativeShellGate(
'contract',
'h5-native-app-message-source-boundaries',
assertH5NativeAppMessageSourceBoundaries,
);
console.log('[check:native-shells] h5-native-app-transport-facade-boundary');
assertH5NativeAppTransportFacadeBoundary();
runNativeShellGate(
'contract',
'h5-native-app-transport-facade-boundary',
assertH5NativeAppTransportFacadeBoundary,
);
console.log('[check:native-shells] generated-native-shell-artifact-boundary');
assertNoTrackedGeneratedNativeShellArtifacts();
assertGeneratedNativeShellArtifactsAreIgnored();
runNativeShellGate(
'contract',
'generated-native-shell-artifact-boundary',
() => {
assertNoTrackedGeneratedNativeShellArtifacts();
assertGeneratedNativeShellArtifactsAreIgnored();
},
);
console.log('[check:native-shells] ai-game-creator-shell-user-dev-boundary');
assertAiGameCreatorShellUserDevBoundary();
runNativeShellGate(
'contract',
'ai-game-creator-shell-user-dev-boundary',
assertAiGameCreatorShellUserDevBoundary,
);
console.log('[check:native-shells] production-shell-dev-scaffold-scan');
assertNoProductionShellDevScaffoldTerms();
runNativeShellGate(
'contract',
'production-shell-dev-scaffold-scan',
assertNoProductionShellDevScaffoldTerms,
);
console.log(
`[check:native-shells] groups=${requestedNativeShellGroups.join(',')}`,
);
console.log('[check:native-shells] OK');
+96 -8
View File
@@ -40,8 +40,28 @@ const jobNames = [
'frontend-tests',
'backend-tests',
'native-shell-tests',
'ai-game-creator-shell-web-tests',
'ai-game-creator-shell-rust-tests',
] as const;
const rootPackageJson = JSON.parse(
readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'),
) as { scripts?: Record<string, string> };
const nativeShellGateScript = readFileSync(
resolve(process.cwd(), 'scripts/check-native-shells.mjs'),
'utf8',
);
// 客户端门禁拆分口径:门禁脚本里的每个分组都由一个根 npm 脚本暴露,并在 workflow
// 的某个 job 里被恰好调用一次。新增分组时必须同步这三处,否则拆分就会静默漏跑门禁。
const nativeShellGateGroupScripts = {
contract: 'npm run check:native-shells:contract',
shells: 'npm run check:native-shells:shells',
'agc-web': 'npm run check:native-shells:agc-web',
'agc-rust': 'npm run check:native-shells:agc-rust',
release: 'npm run check:native-shells:release',
} as const;
function jobSection(jobName: (typeof jobNames)[number]) {
const jobStart = workflow.indexOf(` ${jobName}:`);
expect(jobStart).toBeGreaterThanOrEqual(0);
@@ -67,6 +87,10 @@ function stepSection(jobName: (typeof jobNames)[number], stepName: string) {
);
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
}
function backendStepIndex(stepName: string) {
const backendJobStart = workflow.indexOf(' backend-tests:');
const nativeShellJobStart = workflow.indexOf(' native-shell-tests:');
@@ -87,7 +111,9 @@ describe('project CI workflow', () => {
});
it('keeps every job on the isolated preinstalled CI image boundary', () => {
expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength(4);
expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength(
jobNames.length,
);
expect(workflow).not.toContain('actions/checkout');
expect(workflow).not.toContain('actions/setup-node');
expect(workflow).not.toMatch(/^\s+run: .*\b(?:apt|rustup)\b/m);
@@ -314,17 +340,77 @@ describe('project CI workflow', () => {
expect(frontendJob).toContain('run: npm run check:production-api-deploy');
const nativeJob = jobSection('native-shell-tests');
expect(nativeJob).toContain('run: npm run check:native-shells');
expect(nativeJob).toContain('run: npm run check:native-shells:contract');
expect(nativeJob).toContain('run: npm run check:native-shells:shells');
expect(nativeJob).toContain('run: npm run check:native-shells:release');
expect(nativeJob).toContain(
'git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock',
);
expect(nativeJob).toContain('server-rs/Cargo.toml');
expect(nativeJob).toContain('apps/desktop-shell/src-tauri/Cargo.toml');
expect(nativeJob).toContain(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(nativeJob).toContain('cargo fetch --locked');
});
it('prefetches the excluded standalone Rust crates before the native shell gates', () => {
it('runs every native shell gate group exactly once across the split jobs', () => {
for (const [group, script] of Object.entries(nativeShellGateGroupScripts)) {
expect(rootPackageJson.scripts?.[`check:native-shells:${group}`]).toBe(
`node scripts/check-native-shells.mjs --groups=${group}`,
);
expect(
workflow.match(new RegExp(`^ {8}run: ${escapeRegExp(script)}$`, 'mu')),
).toHaveLength(1);
}
const declaredGroups = [
...nativeShellGateScript
.slice(
nativeShellGateScript.indexOf('const nativeShellGateGroups = ['),
nativeShellGateScript.indexOf(
'];',
nativeShellGateScript.indexOf('const nativeShellGateGroups = ['),
),
)
.matchAll(/'([a-z-]+)'/gu),
].map((match) => match[1]);
expect(declaredGroups).toEqual(Object.keys(nativeShellGateGroupScripts));
// 全量分组脚本只允许本地使用:CI 必须走拆分后的分组脚本,避免整套门禁再被
// 串行跑一遍。
expect(workflow).not.toMatch(/^ {8}run: npm run check:native-shells$/mu);
});
it('splits the AI game creator shell gates into web and Rust jobs', () => {
const webJob = jobSection('ai-game-creator-shell-web-tests');
expect(webJob).toContain('run: npm run check:native-shells:agc-web');
expect(webJob).not.toContain('cargo fetch');
const rustJob = jobSection('ai-game-creator-shell-rust-tests');
expect(rustJob).toContain('run: npm run check:native-shells:agc-rust');
expect(rustJob).toContain('server-rs/Cargo.toml');
expect(rustJob).toContain(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(rustJob).toContain('cargo fetch --locked');
// 拆开的 web / rust 两段必须还是原 `ai-game-creator-shell:check` 的同一条命令序列。
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check']).toBe(
'npm run ai-game-creator-shell:check:web && npm run ai-game-creator-shell:check:rust && npm run ai-game-creator-shell:agent-run:smoke',
);
expect(rootPackageJson.scripts?.['ai-game-creator-shell:check:web']).toBe(
'npm run ai-game-creator-shell:typecheck && npm run test -- apps/ai-game-creator-shell/tests',
);
expect(
rootPackageJson.scripts?.['ai-game-creator-shell:check:rust'],
).toContain(
'cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1',
);
});
it('prefetches the excluded standalone Rust crates before the AI game creator shell Rust gates', () => {
const standaloneStep = stepSection(
'native-shell-tests',
'ai-game-creator-shell-rust-tests',
'Prepare standalone Rust crate dependencies',
);
for (const manifest of [
@@ -338,9 +424,11 @@ describe('project CI workflow', () => {
expect(standaloneStep).toContain('cargo fetch \\');
expect(standaloneStep).not.toContain('cargo fetch --locked');
const nativeJob = jobSection('native-shell-tests');
const rustJob = jobSection('ai-game-creator-shell-rust-tests');
expect(
nativeJob.indexOf('Prepare standalone Rust crate dependencies'),
).toBeLessThan(nativeJob.indexOf('run: npm run check:native-shells'));
rustJob.indexOf('Prepare standalone Rust crate dependencies'),
).toBeLessThan(
rustJob.indexOf('run: npm run check:native-shells:agc-rust'),
);
});
});