Files
Genarrative/scripts/project-ci-workflow.test.ts
T
kdletters 288e1d4f4a
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 13s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 12s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 12s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 13s
Project CI / AI game creator shell Rust smoke (push) Failing after 12s
Project CI / AI game creator shell Rust crates (push) Failing after 20s
Project CI / Backend tests (push) Failing after 23s
Project CI / Native shell tests (push) Failing after 12s
Project CI / Frontend tests (push) Failing after 5s
Project CI / Repository checks (push) Failing after 11s
Project CI / AI game creator shell web tests (push) Failing after 13s
同步最新主干并修复 Godot 插件启动竞态
合入主干 Rust 1.98.1 工具链及对应 CI 文档更新
Godot 插件先接收受控项目快照再注册命令和连接能力
补充启动竞态回归、失败回执诊断及插件启动合同
2026-09-20 17:46:53 +08:00

634 lines
26 KiB
TypeScript
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 { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const workflow = readFileSync(
resolve(process.cwd(), '.gitea/workflows/project-ci.yml'),
'utf8',
);
const imageBuildScript = readFileSync(
resolve(process.cwd(), 'scripts/gitea-ci-job-image.sh'),
'utf8',
);
const imageCheckScript = readFileSync(
resolve(process.cwd(), 'scripts/check-gitea-ci-job-image.sh'),
'utf8',
);
const npmCiRetryScript = readFileSync(
resolve(process.cwd(), 'scripts/ci-npm-ci-with-retry.sh'),
'utf8',
);
const imageDockerfile = readFileSync(
resolve(process.cwd(), 'deploy/container/gitea-ci-job.Dockerfile'),
'utf8',
);
const imageDockerignore = readFileSync(
resolve(
process.cwd(),
'deploy/container/gitea-ci-job.Dockerfile.dockerignore',
),
'utf8',
);
const apiServerDockerfile = readFileSync(
resolve(process.cwd(), 'deploy/container/api-server.Dockerfile'),
'utf8',
);
const deployContainerReadme = readFileSync(
resolve(process.cwd(), 'deploy/container/README.md'),
'utf8',
);
const operationsDoc = readFileSync(
resolve(
process.cwd(),
'docs/【开发运维】本地开发验证与生产运维-2026-05-15.md',
),
'utf8',
);
const rustToolchainToml = readFileSync(
resolve(process.cwd(), 'rust-toolchain.toml'),
'utf8',
);
const jobNames = [
'repository-checks',
'frontend-tests',
'backend-tests',
'native-shell-tests',
'ai-game-creator-shell-web-tests',
'ai-game-creator-shell-rust-shard-1',
'ai-game-creator-shell-rust-shard-2',
'ai-game-creator-shell-rust-shard-3',
'ai-game-creator-shell-rust-shard-4',
'ai-game-creator-shell-rust-smoke',
'ai-game-creator-shell-rust-crates',
] as const;
// 这几个 job 是纯 cargo 门禁:AGC 壳的 Rust 分片与 agent-run smoke 只用 cargo 与
// node 内建模块(smoke 脚本只 import `node:*`),crate 级测试只跑 cargo 命令,
// 都不需要 node_modules。省掉这些 `npm ci`(各 1~3 分钟)是把客户端 Rust 关键路径
// 压到 7 分钟以内的前提,因此这里显式允许它们不装 npm 依赖。
const jobsWithoutNpmInstall: readonly string[] = [
'ai-game-creator-shell-rust-shard-1',
'ai-game-creator-shell-rust-shard-2',
'ai-game-creator-shell-rust-shard-3',
'ai-game-creator-shell-rust-shard-4',
'ai-game-creator-shell-rust-smoke',
'ai-game-creator-shell-rust-crates',
];
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-crates': 'npm run check:native-shells:agc-rust-crates',
'agc-rust-shard-1': 'npm run check:native-shells:agc-rust-shard-1',
'agc-rust-shard-2': 'npm run check:native-shells:agc-rust-shard-2',
'agc-rust-shard-3': 'npm run check:native-shells:agc-rust-shard-3',
'agc-rust-shard-4': 'npm run check:native-shells:agc-rust-shard-4',
'agc-rust-smoke': 'npm run check:native-shells:agc-rust-smoke',
release: 'npm run check:native-shells:release',
} as const;
function jobSection(jobName: (typeof jobNames)[number]) {
const jobStart = workflow.indexOf(` ${jobName}:`);
expect(jobStart).toBeGreaterThanOrEqual(0);
const nextJobOffset = workflow
.slice(jobStart + 1)
.search(/^ {2}[a-z][a-z0-9-]+:$/m);
return workflow.slice(
jobStart,
nextJobOffset < 0 ? undefined : jobStart + 1 + nextJobOffset,
);
}
function stepSection(jobName: (typeof jobNames)[number], stepName: string) {
const job = jobSection(jobName);
const stepStart = job.indexOf(` - name: ${stepName}`);
expect(stepStart).toBeGreaterThanOrEqual(0);
const nextStepOffset = job.slice(stepStart + 1).search(/^ {6}- name: /m);
return job.slice(
stepStart,
nextStepOffset < 0 ? undefined : stepStart + 1 + nextStepOffset,
);
}
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:');
expect(backendJobStart).toBeGreaterThanOrEqual(0);
expect(nativeShellJobStart).toBeGreaterThan(backendJobStart);
return workflow
.slice(backendJobStart, nativeShellJobStart)
.indexOf(` - name: ${stepName}`);
}
describe('project CI workflow', () => {
it('runs for master pushes, pull requests, and manual dispatch only', () => {
expect(workflow).toMatch(
/on:\n {2}push:\n {4}branches:\n {6}- master\n {2}pull_request:\n {2}workflow_dispatch:/u,
);
expect(workflow).not.toContain('codex/ai-game-creator-app');
});
it('keeps every job on the isolated preinstalled CI image boundary', () => {
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);
for (const jobName of jobNames) {
const job = jobSection(jobName);
const checkout = job.indexOf('genarrative-gitea-checkout');
const validateImage = job.indexOf(
'GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh',
);
const installDependencies = job.indexOf(
'- name: Install npm dependencies',
);
expect(checkout).toBeGreaterThanOrEqual(0);
expect(validateImage).toBeGreaterThan(checkout);
if (jobsWithoutNpmInstall.includes(jobName)) {
expect(installDependencies).toBe(-1);
continue;
}
expect(installDependencies).toBeGreaterThan(validateImage);
}
expect(imageDockerfile).toMatch(
/^ARG RUST_IMAGE=[^\s]+@sha256:[a-f0-9]{64}$/m,
);
expect(imageDockerfile).toMatch(
/^ARG RUNNER_IMAGE=[^\s]+@sha256:[a-f0-9]{64}$/m,
);
expect(imageDockerfile).toContain('ARG NPM_VERSION=10.9.7');
expect(imageDockerfile).toContain(
'npm install --global "npm@${NPM_VERSION}" --no-audit --no-fund',
);
expect(imageDockerfile).toContain(
'GENARRATIVE_GITEA_CI_NPM_VERSION=${NPM_VERSION}',
);
expect(imageCheckScript).toContain(
'test "$(npm --version)" = "${GENARRATIVE_GITEA_CI_NPM_VERSION}"',
);
expect(imageCheckScript).toContain("printf 'npm_version=hit\\n'");
expect(imageCheckScript).toContain("printf 'npm_version=partial\\n'");
expect(imageCheckScript).toContain(
'::warning title=CI npm version metadata is partial::',
);
expect(imageCheckScript).toContain('continue with the root npm ci');
expect(imageCheckScript).not.toContain(
'test -n "${GENARRATIVE_GITEA_CI_NPM_VERSION:-}"',
);
});
it('retries the single root workspace clean install in every job that needs node_modules', () => {
for (const jobName of jobNames) {
if (jobsWithoutNpmInstall.includes(jobName)) {
expect(jobSection(jobName)).not.toContain('ci-npm-ci-with-retry.sh');
continue;
}
const install = stepSection(jobName, 'Install npm dependencies');
expect(install).toContain('bash scripts/ci-npm-ci-with-retry.sh');
expect(install).not.toContain('--prefix');
expect(
jobSection(jobName).match(/ci-npm-ci-with-retry\.sh/gu),
).toHaveLength(1);
}
expect(workflow).not.toContain('Install AI game creator dependencies');
expect(npmCiRetryScript).toContain(
'max_attempts="${GENARRATIVE_CI_NPM_CI_ATTEMPTS:-3}"',
);
expect(npmCiRetryScript).toContain(
'base_delay_seconds="${GENARRATIVE_CI_NPM_CI_RETRY_DELAY_SECONDS:-5}"',
);
expect(npmCiRetryScript).toContain(
'for attempt in $(seq 1 "${max_attempts}"); do',
);
expect(npmCiRetryScript).toContain('if npm ci "$@"; then');
expect(npmCiRetryScript).toContain(
'if [[ "${attempt}" -eq "${max_attempts}" ]]; then',
);
});
it('builds one npm workspace cache from every manifest and keeps three Cargo lock caches', () => {
const workspaceManifests = [
'apps/admin-web/package.json',
'apps/ai-game-creator-shell/package.json',
'apps/desktop-shell/package.json',
'apps/mobile-shell/package.json',
'apps/preview-deployer-web/package.json',
'packages/image-canvas-core/package.json',
'packages/image-canvas-react/package.json',
'packages/shared/package.json',
'tools/spine-json-export-validator/package.json',
];
const rustManifest = 'apps/ai-game-creator-shell/src-tauri/Cargo.toml';
const rustLock = 'apps/ai-game-creator-shell/src-tauri/Cargo.lock';
for (const workspaceManifest of workspaceManifests) {
expect(imageBuildScript.split(workspaceManifest)).toHaveLength(3);
expect(imageDockerignore).toContain(`!${workspaceManifest}`);
expect(imageDockerfile).toContain(
`COPY ${workspaceManifest} /usr/local/share/genarrative-ci/npm/${workspaceManifest}`,
);
}
for (const [path, expectedCount] of [
[rustManifest, 2],
[rustLock, 3],
] as const) {
expect(imageBuildScript.split(path)).toHaveLength(expectedCount + 1);
expect(imageDockerignore).toContain(`!${path}`);
}
expect(imageBuildScript).toContain(
'--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}"',
);
expect(imageDockerfile).toContain('ARG AGC_RUST_LOCK_SHA256');
expect(imageDockerfile.match(/\bnpm ci\b/gu)).toHaveLength(1);
expect(imageDockerfile).not.toContain('AGC_NPM_LOCK_SHA256');
expect(imageDockerfile).not.toContain('/agc-npm');
expect(imageBuildScript).not.toContain(
'apps/ai-game-creator-shell/package-lock.json',
);
expect(imageDockerignore).not.toContain(
'!apps/ai-game-creator-shell/package-lock.json',
);
expect(
imageDockerfile.match(
/--manifest-path \/tmp\/genarrative-cargo-cache\/apps\/ai-game-creator-shell\/src-tauri\/Cargo\.toml/g,
),
).toHaveLength(1);
expect(imageDockerfile).toContain(
'cargo_fetch_with_retry /tmp/genarrative-cargo-cache/apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(imageDockerfile).toContain(
'GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256=${AGC_RUST_LOCK_SHA256}',
);
expect(imageCheckScript).toContain(
'npm_lock_path="${repo_root}/package-lock.json"',
);
expect(imageCheckScript).not.toContain('agc_npm_lock_path');
expect(imageCheckScript).toContain(rustLock);
expect(imageCheckScript).toContain(
'${GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256:-}',
);
expect(imageCheckScript).toContain(
'::warning title=CI dependency cache is partial::',
);
});
it('copies every workspace manifest before the API image web-builder clean install', () => {
const npmCiOffset = apiServerDockerfile.indexOf('RUN npm ci');
expect(npmCiOffset).toBeGreaterThanOrEqual(0);
expect(apiServerDockerfile.match(/\bRUN npm ci\b/gu)).toHaveLength(1);
expect(apiServerDockerfile).toContain('ARG NPM_VERSION=10.9.7');
expect(apiServerDockerfile).toContain(
'npm install --global "npm@${NPM_VERSION}" --no-audit --no-fund',
);
const npmVersionCheckOffset = apiServerDockerfile.indexOf(
'test "$(npm --version)" = "${NPM_VERSION}"',
);
expect(npmVersionCheckOffset).toBeGreaterThanOrEqual(0);
expect(npmVersionCheckOffset).toBeLessThan(npmCiOffset);
for (const manifest of [
'package.json',
'package-lock.json',
'apps/admin-web/package.json',
'apps/ai-game-creator-shell/package.json',
'apps/desktop-shell/package.json',
'apps/mobile-shell/package.json',
'apps/preview-deployer-web/package.json',
'packages/image-canvas-core/package.json',
'packages/image-canvas-react/package.json',
'packages/shared/package.json',
'tools/spine-json-export-validator/package.json',
]) {
const copyLine = apiServerDockerfile
.split('\n')
.find(
(line) =>
line.startsWith('COPY ') && line.split(/\s+/u).includes(manifest),
);
expect(copyLine).toBeDefined();
const copyOffset = apiServerDockerfile.indexOf(copyLine ?? '');
expect(copyOffset).toBeGreaterThanOrEqual(0);
expect(copyOffset).toBeLessThan(npmCiOffset);
}
});
it('prepares locked server-rs dependencies before the first Cargo build gate', () => {
const prepareDependencies = backendStepIndex(
'Prepare server-rs Rust dependencies',
);
const checkBoundaries = backendStepIndex('Check server-rs boundaries');
const runWorkspaceTests = backendStepIndex('Run server-rs workspace tests');
expect(prepareDependencies).toBeGreaterThanOrEqual(0);
expect(checkBoundaries).toBeGreaterThan(prepareDependencies);
expect(runWorkspaceTests).toBeGreaterThan(checkBoundaries);
expect(workflow).toContain('cargo fetch --locked');
expect(workflow).toContain('for attempt in $(seq 1 5); do');
expect(workflow).toContain(
'cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml',
);
expect(workflow).toContain(
'cargo test --locked -p spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml',
);
});
it('never passes HEAD itself to the schema comparison gate', () => {
expect(
workflow.match(
/if \[\[ "\$\{resolved_base_ref\}" == "\$\{head_ref\}" \]\]; then/g,
),
).toHaveLength(2);
expect(workflow.match(/git rev-parse --verify HEAD\^/g)).toHaveLength(2);
expect(
workflow.match(
/comparison base must resolve to a commit distinct from HEAD\./g,
),
).toHaveLength(2);
});
it('keeps frontend, operations fixture, and native shell gates in dedicated jobs', () => {
const frontendJob = jobSection('frontend-tests');
expect(frontendJob).toContain('run: npm run test');
expect(frontendJob).toContain('run: npm run bgfilter-worker:smoke-test');
expect(frontendJob).toContain(
'run: npm run check:production-health-patrol',
);
expect(frontendJob).toContain('run: npm run check:production-api-release');
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: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('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('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-z0-9-]+)'/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, shell shard, and shared crate 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');
expect(nativeShellGateScript).toMatch(
/group: 'agc-web',[\s\S]*?args: \['run', 'agc:plugins:test'\]/u,
);
expect(rootPackageJson.scripts?.['agc:plugins:test']).toContain(
'plugins/agc-unity-editor/src/entry.test.mjs',
);
expect(rootPackageJson.scripts?.['agc:plugins:test']).toContain(
'plugins/agc-godot-editor/src/entry.test.mjs',
);
// 壳 bin 单测按名单分 4 片,一片一个 job:每个片 job 只跑自己那片,且只预热 AGC 壳
// 自己那份锁定依赖(server-rs 那份归 crate 级 job)。
for (const [index, jobName] of [
[1, 'ai-game-creator-shell-rust-shard-1'],
[2, 'ai-game-creator-shell-rust-shard-2'],
[3, 'ai-game-creator-shell-rust-shard-3'],
[4, 'ai-game-creator-shell-rust-shard-4'],
] as const) {
const shardJob = jobSection(jobName);
expect(shardJob).toContain(
`run: npm run check:native-shells:agc-rust-shard-${index}`,
);
expect(shardJob).toContain(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(shardJob).toContain('cargo fetch --locked');
expect(shardJob).not.toContain('server-rs/Cargo.toml');
}
// 整套用例不能再作为一条命令串行跑完:每个片 job 都必须落到分片运行器的
// `--shard-index` 上,4 个 index 各一次。
for (const index of [1, 2, 3, 4]) {
expect(nativeShellGateScript).toContain(`'--shard-index=${index}'`);
}
expect(
nativeShellGateScript.match(/ai-game-creator-shell:check:rust:shell/gu),
).toHaveLength(4);
const smokeJob = jobSection('ai-game-creator-shell-rust-smoke');
expect(smokeJob).toContain(
'run: npm run check:native-shells:agc-rust-smoke',
);
expect(smokeJob).toContain('cargo fetch --locked');
expect(smokeJob).not.toContain('server-rs/Cargo.toml');
const cratesJob = jobSection('ai-game-creator-shell-rust-crates');
expect(cratesJob).toContain(
'run: npm run check:native-shells:agc-rust-crates',
);
expect(cratesJob).toContain('server-rs/Cargo.toml');
expect(cratesJob).toContain('cargo fetch --locked');
expect(nativeShellGateScript).toMatch(
/group: 'agc-rust-crates',[\s\S]*?args: \['run', 'agc:plugins:native-test'\]/u,
);
expect(rootPackageJson.scripts?.['agc:plugins:native-test']).toContain(
'cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
);
expect(rootPackageJson.scripts?.['agc:plugins:native-test']).toContain(
'cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml',
);
// 拆开的 web / rust 两段必须还是原 `ai-game-creator-shell:check` 的同一条命令序列,
// rust 段再拆成 crate 级与壳分片两段后在聚合脚本里保持同序。
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']).toBe(
'npm run ai-game-creator-shell:check:rust:crates && npm run ai-game-creator-shell:check:rust:shell',
);
expect(
rootPackageJson.scripts?.['ai-game-creator-shell:check:rust:crates'],
).toContain(
'cargo test --locked -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app',
);
expect(
rootPackageJson.scripts?.['ai-game-creator-shell:check:rust:shell'],
).toBe(
'node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4',
);
// 整套用例不再作为一条 `cargo test -- --test-threads=1` 命令串行跑:CI 与本地都走
// 分片运行器,片内串行,片与片之间靠 job 级并发摊开。
expect(workflow).not.toContain('-- --test-threads=1');
expect(workflow).not.toMatch(/^ {8}run: .*--test-threads/mu);
});
it('keeps the shell shard runner exhaustive over discovered tests', () => {
const shardRunner = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs',
),
'utf8',
);
// 分片必须对 `--list` 的全集做覆盖与互斥校验,否则改分片规则会静默漏跑门禁。
expect(shardRunner).toContain('function assertShardsCoverEveryTest(');
expect(shardRunner).toContain('must be exhaustive');
expect(shardRunner).toContain('must be disjoint');
expect(shardRunner).toContain("'--test-threads=1'");
expect(shardRunner).toContain('TMPDIR: shardTmpDirectory');
expect(shardRunner).toContain("'--exact'");
// `--shard-index` 是 CI 的「一个 job 一片」入口:只跑指定片,但覆盖自校验照旧
// 针对 `--list` 全集执行,所以改分片规则仍然会在每个 job 上暴露。
expect(shardRunner).toContain("case 'shard-index':");
expect(shardRunner).toContain('must be within --shards');
});
it('prefetches the excluded standalone Rust crates before the shared crate gates', () => {
const standaloneStep = stepSection(
'ai-game-creator-shell-rust-crates',
'Prepare standalone Rust crate dependencies',
);
for (const manifest of [
'server-rs/crates/agent-runtime-core/Cargo.toml',
'server-rs/crates/agent-runtime-orchestration/Cargo.toml',
'plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml',
]) {
expect(standaloneStep).toContain(manifest);
}
// 这些 crate 没有提交 Cargo.lock,只能用不带 --locked 的 fetch
// 带 --locked 会因为缺少锁文件直接失败。
expect(standaloneStep).toContain('cargo fetch \\');
expect(standaloneStep).not.toContain('cargo fetch --locked');
const unityStep = stepSection(
'ai-game-creator-shell-rust-crates',
'Prepare Unity plugin Rust dependencies',
);
expect(unityStep).toContain('cargo fetch --locked');
expect(unityStep).toContain(
'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
);
const godotStep = stepSection(
'ai-game-creator-shell-rust-crates',
'Prepare Godot plugin Rust dependencies',
);
expect(godotStep).toContain('cargo fetch --locked');
expect(godotStep).toContain(
'plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml',
);
const cratesJob = jobSection('ai-game-creator-shell-rust-crates');
expect(
cratesJob.indexOf('Prepare standalone Rust crate dependencies'),
).toBeLessThan(
cratesJob.indexOf('run: npm run check:native-shells:agc-rust-crates'),
);
});
// 预构建镜像的 Rust 版本分散在四处:rust-toolchain.toml 的 channel、Dockerfile 的
// RUST_IMAGE digest、构建脚本的默认 tag,以及两份运维文档。漏改任何一处都会让 job
// 在 runtime 校验里失败(`RUSTUP_AUTO_INSTALL=0` 不允许现场补装),或者让文档指向
// 已经不存在的镜像,所以这里把它们钉成同一个事实。
it('keeps the prebuilt CI job image Rust version, digest and tag in sync with the ops docs', () => {
const channel = rustToolchainToml.match(/^channel = "([^"]+)"$/mu)?.[1];
expect(channel).toBeTruthy();
const channelValue = channel ?? '';
const rustImage = imageDockerfile.match(
/^ARG RUST_IMAGE=([^\s@]+)@(sha256:[a-f0-9]{64})$/mu,
);
expect(rustImage).not.toBeNull();
const rustImageRef = rustImage?.[1] ?? '';
const rustImageDigest = rustImage?.[2] ?? '';
const [major, minor] = channelValue.split('.');
expect(rustImageRef).toBe(`rust:${major}.${minor}-bookworm`);
// 同一 Dockerfile 里可能出现在多个 LABEL 块中(后写的覆盖前者),必须同值,
// 否则镜像真实版本会和脚本、文档的说法分叉。
const labelVersions = [
...imageDockerfile.matchAll(
/org\.opencontainers\.image\.version="([0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+)"/gu,
),
].map((match) => match[1] ?? '');
expect(labelVersions.length).toBeGreaterThan(0);
expect(new Set(labelVersions).size).toBe(1);
const stampMatch = (labelVersions.at(-1) ?? '').match(
/^(\d{4})\.(\d{2})\.(\d{2})\.(\d+)$/u,
);
expect(stampMatch).not.toBeNull();
const imageTagStamp = `${stampMatch?.[1] ?? ''}${stampMatch?.[2] ?? ''}${
stampMatch?.[3] ?? ''
}.${stampMatch?.[4] ?? ''}`;
expect(imageBuildScript).toContain(
`genarrative/gitea-project-ci:${imageTagStamp}`,
);
for (const doc of [deployContainerReadme, operationsDoc]) {
expect(doc).toContain(rustImageDigest);
expect(doc).toContain(`Rust \`${channelValue}\``);
expect(doc).toContain(imageTagStamp);
}
// runtime 校验按 rust-toolchain.toml 的 channel 逐字比对镜像内工具链名,
// 所以基础镜像 digest 里的 RUST_VERSION 必须与 channel 完全相同。
expect(imageCheckScript).toContain(
'rustup toolchain list | rg -q "^${expected_toolchain}(-[^ ]+)?( |$)"',
);
});
});