迁移 npm workspaces 统一依赖边界

根目录统一管理九个 workspace 与唯一 lockfile

迁移 CI、Jenkins 和容器的根目录单次 npm ci

补齐 hoist 兼容、版本校验与 workspace 门禁

同步依赖边界方案、运维文档和长期记忆
This commit is contained in:
2026-08-22 12:21:07 +08:00
parent 00b78c9701
commit 27de28a487
42 changed files with 3309 additions and 7137 deletions
+7 -8
View File
@@ -9,6 +9,13 @@ expected_toolchain="$(
test -n "${expected_toolchain}"
[[ "$(node --version)" == v22.* ]]
if [[ -n "${GENARRATIVE_GITEA_CI_NPM_VERSION:-}" ]]; then
test "$(npm --version)" = "${GENARRATIVE_GITEA_CI_NPM_VERSION}"
printf 'npm_version=hit\n'
else
printf 'npm_version=partial\n'
printf '%s\n' '::warning title=CI npm version metadata is partial::The prebuilt image does not declare GENARRATIVE_GITEA_CI_NPM_VERSION; continue with the root npm ci, then refresh the trusted CI image after this migration lands.'
fi
rustup toolchain list | rg -q "^${expected_toolchain}(-[^ ]+)?( |$)"
[[ "$(rustup run "${expected_toolchain}" rustc --version)" == "rustc ${expected_toolchain} "* ]]
test "$(readlink -f "$(command -v node)")" = "/usr/local/lib/genarrative-node/bin/node"
@@ -44,16 +51,12 @@ verify_cache_lock() {
}
npm_lock_path="${repo_root}/package-lock.json"
agc_npm_lock_path="${repo_root}/apps/ai-game-creator-shell/package-lock.json"
server_rust_lock_path="${repo_root}/server-rs/Cargo.lock"
desktop_rust_lock_path="${repo_root}/apps/desktop-shell/src-tauri/Cargo.lock"
agc_rust_lock_path="${repo_root}/apps/ai-game-creator-shell/src-tauri/Cargo.lock"
if [[ ! -f "${npm_lock_path}" ]]; then
npm_lock_path='/usr/local/share/genarrative-ci/npm/package-lock.json'
fi
if [[ ! -f "${agc_npm_lock_path}" ]]; then
agc_npm_lock_path='/usr/local/share/genarrative-ci/agc-npm/package-lock.json'
fi
if [[ ! -f "${server_rust_lock_path}" ]]; then
server_rust_lock_path='/usr/local/share/genarrative-ci/locks/server-rs.Cargo.lock'
fi
@@ -68,10 +71,6 @@ verify_cache_lock \
npm \
"${GENARRATIVE_GITEA_CI_NPM_LOCK_SHA256:-}" \
"${npm_lock_path}"
verify_cache_lock \
agc_npm \
"${GENARRATIVE_GITEA_CI_AGC_NPM_LOCK_SHA256:-}" \
"${agc_npm_lock_path}"
verify_cache_lock \
server_rust \
"${GENARRATIVE_GITEA_CI_SERVER_RUST_LOCK_SHA256:-}" \
+7 -1
View File
@@ -177,12 +177,15 @@ function assertNativeShellDependencyVersionGuardrails() {
"const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url)",
'function assertPackageDependencyVersion(',
'function assertPackageLockVersion(',
'function readWorkspaceLockManifest(',
'function assertLockManifestMatchesMobileChannelBoundary(',
"'apps/mobile-shell'",
"'@expo/metro-runtime': '^56.0.15'",
"expo: '^56.0.12'",
"'react-native': '^0.86.0'",
"'react-native-webview': '^13.16.1'",
"'eas-cli': '^20.3.0'",
"assertPackageLockVersion('eas-cli', '20.3.0')",
"assertPackageLockVersion('apps/mobile-shell', 'eas-cli', '20.3.0')",
]) {
if (!mobileShellConfigCheckSource.includes(snippet)) {
throw new Error(
@@ -196,6 +199,9 @@ function assertNativeShellDependencyVersionGuardrails() {
"const cargoLockPath = new URL('../src-tauri/Cargo.lock', import.meta.url)",
'function assertPackageDependencyVersion(',
'function assertPackageLockVersion(',
'function readWorkspaceLockManifest(',
'function assertLockManifestMatchesDependencyBoundary(',
"'apps/desktop-shell'",
'function assertCargoDependencyLine(',
'function assertCargoLockPackageVersion(',
'function assertCargoLockDirectDependency(',
+293
View File
@@ -0,0 +1,293 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
export const REQUIRED_PACKAGE_MANAGER = 'npm@10.9.7';
export const REQUIRED_WORKSPACES = Object.freeze([
'apps/admin-web',
'apps/ai-game-creator-shell',
'apps/desktop-shell',
'apps/mobile-shell',
'apps/preview-deployer-web',
'packages/image-canvas-core',
'packages/image-canvas-react',
'packages/shared',
'tools/spine-json-export-validator',
]);
const WORKSPACE_NAMES = Object.freeze({
'apps/admin-web': '@genarrative/admin-web',
'apps/ai-game-creator-shell': '@genarrative/ai-game-creator-shell',
'apps/desktop-shell': '@genarrative/desktop-shell',
'apps/mobile-shell': '@genarrative/mobile-shell',
'apps/preview-deployer-web': '@genarrative/preview-deployer-web',
'packages/image-canvas-core': '@genarrative/image-canvas-core',
'packages/image-canvas-react': '@genarrative/image-canvas-react',
'packages/shared': '@genarrative/shared',
'tools/spine-json-export-validator':
'@genarrative/spine-json-export-validator',
});
const REQUIRED_LOCAL_DEPENDENCIES = Object.freeze({
'package.json': [
'@genarrative/image-canvas-core',
'@genarrative/image-canvas-react',
'@genarrative/shared',
],
'apps/admin-web/package.json': ['@genarrative/shared'],
'apps/ai-game-creator-shell/package.json': [
'@genarrative/image-canvas-core',
'@genarrative/image-canvas-react',
'@genarrative/shared',
],
'apps/mobile-shell/package.json': ['@genarrative/shared'],
'packages/image-canvas-react/package.json': [
'@genarrative/image-canvas-core',
],
});
const DEPENDENCY_FIELDS = Object.freeze([
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
]);
const IGNORED_NESTED_DIRECTORIES = new Set([
'.git',
'.vite',
'build',
'dist',
'node_modules',
'target',
]);
const HOIST_SENSITIVE_CONFIGS = Object.freeze([
'vite.config.ts',
'vitest.config.ts',
'apps/ai-game-creator-shell/vite.config.ts',
]);
const FORBIDDEN_WORKSPACE_NODE_MODULES_PATH =
'apps/ai-game-creator-shell/node_modules/';
function readJson(rootDir, relativePath, errors) {
const absolutePath = path.join(rootDir, relativePath);
try {
return JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
} catch (error) {
errors.push(`${relativePath}: cannot read valid JSON (${error.message})`);
return null;
}
}
function normalizeWorkspaceLink(value) {
return typeof value === 'string'
? value.replace(/^\.\//u, '').replaceAll('\\', '/')
: value;
}
function findNestedLockfiles(rootDir, workspacePath) {
const matches = [];
const pending = [path.join(rootDir, workspacePath)];
while (pending.length > 0) {
const directory = pending.pop();
let entries;
try {
entries = fs.readdirSync(directory, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isSymbolicLink()) {
continue;
}
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (!IGNORED_NESTED_DIRECTORIES.has(entry.name)) {
pending.push(entryPath);
}
continue;
}
if (
entry.name === 'package-lock.json' ||
entry.name === 'npm-shrinkwrap.json'
) {
matches.push(path.relative(rootDir, entryPath).replaceAll('\\', '/'));
}
}
}
return matches.sort();
}
export function collectNpmWorkspaceErrors(rootDir) {
const errors = [];
const rootPackage = readJson(rootDir, 'package.json', errors);
const lockfile = readJson(rootDir, 'package-lock.json', errors);
if (!rootPackage || !lockfile) {
return errors;
}
for (const configPath of HOIST_SENSITIVE_CONFIGS) {
const absolutePath = path.join(rootDir, configPath);
if (
fs.existsSync(absolutePath) &&
fs
.readFileSync(absolutePath, 'utf8')
.includes(FORBIDDEN_WORKSPACE_NODE_MODULES_PATH)
) {
errors.push(
`${configPath}: resolve dependencies from the workspace manifest instead of hard-coding ${FORBIDDEN_WORKSPACE_NODE_MODULES_PATH}`,
);
}
}
if (rootPackage.packageManager !== REQUIRED_PACKAGE_MANAGER) {
errors.push(
`package.json: packageManager must be ${REQUIRED_PACKAGE_MANAGER}, received ${String(rootPackage.packageManager)}`,
);
}
if (
JSON.stringify(rootPackage.workspaces) !==
JSON.stringify(REQUIRED_WORKSPACES)
) {
errors.push(
`package.json: workspaces must be the explicit ordered list ${JSON.stringify(REQUIRED_WORKSPACES)}`,
);
}
const manifests = new Map([['package.json', rootPackage]]);
for (const workspacePath of REQUIRED_WORKSPACES) {
const manifestPath = `${workspacePath}/package.json`;
const manifest = readJson(rootDir, manifestPath, errors);
if (!manifest) {
continue;
}
manifests.set(manifestPath, manifest);
if (manifest.name !== WORKSPACE_NAMES[workspacePath]) {
errors.push(
`${manifestPath}: name must be ${WORKSPACE_NAMES[workspacePath]}, received ${String(manifest.name)}`,
);
}
if (manifest.version !== '0.1.0') {
errors.push(`${manifestPath}: workspace version must be 0.1.0`);
}
for (const nestedLockfile of findNestedLockfiles(rootDir, workspacePath)) {
errors.push(
`${nestedLockfile}: nested npm lockfiles are forbidden inside workspaces`,
);
}
}
const localPackageNames = new Set(Object.values(WORKSPACE_NAMES));
for (const [manifestPath, manifest] of manifests) {
for (const dependencyField of DEPENDENCY_FIELDS) {
for (const [dependencyName, dependencySpec] of Object.entries(
manifest[dependencyField] ?? {},
)) {
if (
typeof dependencySpec === 'string' &&
dependencySpec.startsWith('workspace:')
) {
errors.push(
`${manifestPath}: ${dependencyField}.${dependencyName} must not use the unsupported workspace: protocol`,
);
}
if (
localPackageNames.has(dependencyName) &&
dependencySpec !== '0.1.0'
) {
errors.push(
`${manifestPath}: ${dependencyField}.${dependencyName} must use exact version 0.1.0`,
);
}
}
}
}
for (const [manifestPath, requiredDependencies] of Object.entries(
REQUIRED_LOCAL_DEPENDENCIES,
)) {
const manifest = manifests.get(manifestPath);
if (!manifest) {
continue;
}
for (const dependencyName of requiredDependencies) {
if (manifest.dependencies?.[dependencyName] !== '0.1.0') {
errors.push(
`${manifestPath}: dependencies.${dependencyName} must be declared as exact version 0.1.0`,
);
}
}
}
const lockPackages = lockfile.packages;
if (!lockPackages || typeof lockPackages !== 'object') {
errors.push('package-lock.json: packages map is required');
return errors;
}
if (
JSON.stringify(lockPackages['']?.workspaces) !==
JSON.stringify(REQUIRED_WORKSPACES)
) {
errors.push(
'package-lock.json: root package entry must contain the explicit workspace list',
);
}
for (const workspacePath of REQUIRED_WORKSPACES) {
const expectedName = WORKSPACE_NAMES[workspacePath];
const workspaceEntry = lockPackages[workspacePath];
if (!workspaceEntry || workspaceEntry.name !== expectedName) {
errors.push(
`package-lock.json: packages[${JSON.stringify(workspacePath)}] must describe ${expectedName}`,
);
}
const linkKey = `node_modules/${expectedName}`;
const linkEntry = lockPackages[linkKey];
if (
!linkEntry ||
linkEntry.link !== true ||
normalizeWorkspaceLink(linkEntry.resolved) !== workspacePath
) {
errors.push(
`package-lock.json: packages[${JSON.stringify(linkKey)}] must link to ${workspacePath}`,
);
}
}
return errors;
}
export function checkNpmWorkspaces(rootDir) {
const errors = collectNpmWorkspaceErrors(rootDir);
if (errors.length > 0) {
throw new Error(
`npm workspace validation failed:\n- ${errors.join('\n- ')}`,
);
}
}
const isMainModule =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isMainModule) {
const rootDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
try {
checkNpmWorkspaces(rootDir);
console.log('[check:npm-workspaces] OK');
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, test } from 'node:test';
import {
collectNpmWorkspaceErrors,
REQUIRED_PACKAGE_MANAGER,
REQUIRED_WORKSPACES,
} from './check-npm-workspaces.mjs';
const fixtureDirectories = [];
const workspaceNames = {
'apps/admin-web': '@genarrative/admin-web',
'apps/ai-game-creator-shell': '@genarrative/ai-game-creator-shell',
'apps/desktop-shell': '@genarrative/desktop-shell',
'apps/mobile-shell': '@genarrative/mobile-shell',
'apps/preview-deployer-web': '@genarrative/preview-deployer-web',
'packages/image-canvas-core': '@genarrative/image-canvas-core',
'packages/image-canvas-react': '@genarrative/image-canvas-react',
'packages/shared': '@genarrative/shared',
'tools/spine-json-export-validator':
'@genarrative/spine-json-export-validator',
};
const localDependencies = {
'apps/admin-web': { '@genarrative/shared': '0.1.0' },
'apps/ai-game-creator-shell': {
'@genarrative/image-canvas-core': '0.1.0',
'@genarrative/image-canvas-react': '0.1.0',
'@genarrative/shared': '0.1.0',
},
'apps/mobile-shell': { '@genarrative/shared': '0.1.0' },
'packages/image-canvas-react': { '@genarrative/image-canvas-core': '0.1.0' },
};
afterEach(() => {
for (const fixtureDirectory of fixtureDirectories.splice(0)) {
fs.rmSync(fixtureDirectory, { recursive: true, force: true });
}
});
function writeJson(rootDir, relativePath, value) {
const absolutePath = path.join(rootDir, relativePath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`);
}
function createValidFixture() {
const rootDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-npm-workspaces-'),
);
fixtureDirectories.push(rootDir);
writeJson(rootDir, 'package.json', {
name: 'fixture-root',
private: true,
version: '0.0.0',
packageManager: REQUIRED_PACKAGE_MANAGER,
workspaces: REQUIRED_WORKSPACES,
dependencies: {
'@genarrative/image-canvas-core': '0.1.0',
'@genarrative/image-canvas-react': '0.1.0',
'@genarrative/shared': '0.1.0',
},
});
const packages = {
'': {
name: 'fixture-root',
version: '0.0.0',
workspaces: REQUIRED_WORKSPACES,
},
};
for (const workspacePath of REQUIRED_WORKSPACES) {
const manifest = {
name: workspaceNames[workspacePath],
private: true,
version: '0.1.0',
dependencies: localDependencies[workspacePath],
};
writeJson(rootDir, `${workspacePath}/package.json`, manifest);
packages[workspacePath] = manifest;
packages[`node_modules/${manifest.name}`] = {
resolved: workspacePath,
link: true,
};
}
writeJson(rootDir, 'package-lock.json', {
name: 'fixture-root',
version: '0.0.0',
lockfileVersion: 3,
requires: true,
packages,
});
return rootDir;
}
function readJson(rootDir, relativePath) {
return JSON.parse(fs.readFileSync(path.join(rootDir, relativePath), 'utf8'));
}
test('accepts the fixed npm workspace topology and root lock links', () => {
const rootDir = createValidFixture();
assert.deepEqual(collectNpmWorkspaceErrors(rootDir), []);
});
test('rejects workspace protocol and non-exact local package versions', () => {
const rootDir = createValidFixture();
const manifestPath = 'apps/admin-web/package.json';
const manifest = readJson(rootDir, manifestPath);
manifest.dependencies['@genarrative/shared'] = 'workspace:*';
writeJson(rootDir, manifestPath, manifest);
const errors = collectNpmWorkspaceErrors(rootDir).join('\n');
assert.match(errors, /must not use the unsupported workspace: protocol/u);
assert.match(errors, /must use exact version 0\.1\.0/u);
});
test('rejects missing workspace entries and links in the root lock', () => {
const rootDir = createValidFixture();
const lockfile = readJson(rootDir, 'package-lock.json');
delete lockfile.packages['apps/mobile-shell'];
delete lockfile.packages['node_modules/@genarrative/mobile-shell'];
writeJson(rootDir, 'package-lock.json', lockfile);
const errors = collectNpmWorkspaceErrors(rootDir).join('\n');
assert.match(errors, /packages\["apps\/mobile-shell"\]/u);
assert.match(errors, /node_modules\/@genarrative\/mobile-shell/u);
});
test('rejects nested package locks and shrinkwrap files', () => {
const rootDir = createValidFixture();
writeJson(rootDir, 'apps/admin-web/package-lock.json', {});
writeJson(rootDir, 'packages/shared/fixtures/npm-shrinkwrap.json', {});
const errors = collectNpmWorkspaceErrors(rootDir).join('\n');
assert.match(errors, /apps\/admin-web\/package-lock\.json/u);
assert.match(errors, /packages\/shared\/fixtures\/npm-shrinkwrap\.json/u);
});
test('rejects package manager and explicit workspace list drift', () => {
const rootDir = createValidFixture();
const rootPackage = readJson(rootDir, 'package.json');
rootPackage.packageManager = 'npm@11.0.0';
rootPackage.workspaces = ['apps/*', 'packages/*'];
writeJson(rootDir, 'package.json', rootPackage);
const errors = collectNpmWorkspaceErrors(rootDir).join('\n');
assert.match(errors, /packageManager must be npm@10\.9\.7/u);
assert.match(errors, /workspaces must be the explicit ordered list/u);
});
test('rejects test and build aliases tied to a nested workspace install', () => {
const rootDir = createValidFixture();
fs.writeFileSync(
path.join(rootDir, 'vitest.config.ts'),
"const cubone = 'apps/ai-game-creator-shell/node_modules/@cubone/react-file-manager';\n",
);
const errors = collectNpmWorkspaceErrors(rootDir).join('\n');
assert.match(
errors,
/vitest\.config\.ts: resolve dependencies from the workspace manifest/u,
);
});
+8 -3
View File
@@ -7593,20 +7593,25 @@ const webBuildStageContent =
: '';
const webNpmCiBlock = `if (params.RUN_NPM_CI) {
sh 'bash -lc "npm ci"'
sh 'bash -lc "npm ci --prefix apps/ai-game-creator-shell"'
}`;
const webNpmCiBlockOffset = webBuildStageContent.indexOf(webNpmCiBlock);
const webTestOffset = webBuildStageContent.indexOf('npm run test');
const webNpmCiCalls = webBuildStageContent.match(/\bnpm ci(?:\s|["'])/gu);
const webNpmVersionCheckOffset = webBuildStageContent.indexOf(
'actual_npm_version="$(npm --version)"',
);
if (
!webBuildContent.includes("GENARRATIVE_NPM_VERSION = '10.9.7'") ||
webNpmVersionCheckOffset < 0 ||
webNpmCiBlockOffset < 0 ||
webTestOffset < 0 ||
webNpmVersionCheckOffset >= webNpmCiBlockOffset ||
webNpmCiBlockOffset >= webTestOffset ||
(webNpmCiCalls?.length ?? 0) !== 2
(webNpmCiCalls?.length ?? 0) !== 1
) {
failed = true;
console.error(
'[check:production-ops] Web Build 必须在 RUN_NPM_CI 条件块内依次安装根 lockfile 与 apps/ai-game-creator-shell 独立 lockfile,并在 npm run test 前完成;RUN_NPM_CI=false 时两次安装必须一起跳过。',
'[check:production-ops] Web Build 必须先精确校验 npm 10.9.7,再在 RUN_NPM_CI 条件块内按根 workspace lockfile 执行唯一一次 npm ci,并在 npm run test 前完成;RUN_NPM_CI=false 时必须跳过该安装。',
);
}
+16 -5
View File
@@ -14,8 +14,15 @@ write_build_context_file_list() {
deploy/container/gitea-ci-checkout.sh \
package.json \
package-lock.json \
apps/admin-web/package.json \
apps/ai-game-creator-shell/package.json \
apps/ai-game-creator-shell/package-lock.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 \
apps/ai-game-creator-shell/src-tauri/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.lock \
server-rs/Cargo.toml \
@@ -65,8 +72,15 @@ case "${command_name}" in
deploy/container/gitea-ci-checkout.sh \
package.json \
package-lock.json \
apps/admin-web/package.json \
apps/ai-game-creator-shell/package.json \
apps/ai-game-creator-shell/package-lock.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 \
apps/ai-game-creator-shell/src-tauri/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.lock \
server-rs/Cargo.toml \
@@ -82,8 +96,6 @@ case "${command_name}" in
)"
npm_lock_sha256="$(sha256sum "${repo_root}/package-lock.json")"
npm_lock_sha256="${npm_lock_sha256%% *}"
agc_npm_lock_sha256="$(sha256sum "${repo_root}/apps/ai-game-creator-shell/package-lock.json")"
agc_npm_lock_sha256="${agc_npm_lock_sha256%% *}"
server_rust_lock_sha256="$(sha256sum "${repo_root}/server-rs/Cargo.lock")"
server_rust_lock_sha256="${server_rust_lock_sha256%% *}"
desktop_rust_lock_sha256="$(sha256sum "${repo_root}/apps/desktop-shell/src-tauri/Cargo.lock")"
@@ -98,7 +110,6 @@ case "${command_name}" in
--pull=false \
--build-arg "IMAGE_REVISION=${image_revision}" \
--build-arg "NPM_LOCK_SHA256=${npm_lock_sha256}" \
--build-arg "AGC_NPM_LOCK_SHA256=${agc_npm_lock_sha256}" \
--build-arg "SERVER_RUST_LOCK_SHA256=${server_rust_lock_sha256}" \
--build-arg "DESKTOP_RUST_LOCK_SHA256=${desktop_rust_lock_sha256}" \
--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}" \
+99 -32
View File
@@ -30,6 +30,10 @@ const imageDockerignore = readFileSync(
),
'utf8',
);
const apiServerDockerfile = readFileSync(
resolve(process.cwd(), 'deploy/container/api-server.Dockerfile'),
'utf8',
);
const jobNames = [
'repository-checks',
@@ -109,28 +113,37 @@ describe('project CI workflow', () => {
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 every root and AI game creator npm clean install as a bounded whole command', () => {
it('retries the single root workspace clean install in every job as a bounded whole command', () => {
for (const jobName of jobNames) {
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);
}
for (const jobName of [
'repository-checks',
'frontend-tests',
'native-shell-tests',
] as const) {
const install = stepSection(
jobName,
'Install AI game creator dependencies',
);
expect(install).toContain(
'bash scripts/ci-npm-ci-with-retry.sh --prefix apps/ai-game-creator-shell',
);
}
expect(workflow).not.toContain('Install AI game creator dependencies');
expect(npmCiRetryScript).toContain(
'max_attempts="${GENARRATIVE_CI_NPM_CI_ATTEMPTS:-3}"',
@@ -147,15 +160,30 @@ describe('project CI workflow', () => {
);
});
it('builds and verifies image caches against both AI game creator locks', () => {
const npmManifest = 'apps/ai-game-creator-shell/package.json';
const npmLock = 'apps/ai-game-creator-shell/package-lock.json';
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 [
[npmManifest, 2],
[npmLock, 3],
[rustManifest, 2],
[rustLock, 3],
] as const) {
@@ -163,16 +191,18 @@ describe('project CI workflow', () => {
expect(imageDockerignore).toContain(`!${path}`);
}
expect(imageBuildScript).toContain(
'--build-arg "AGC_NPM_LOCK_SHA256=${agc_npm_lock_sha256}"',
);
expect(imageBuildScript).toContain(
'--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}"',
);
expect(imageDockerfile).toContain('ARG AGC_NPM_LOCK_SHA256');
expect(imageDockerfile).toContain('ARG AGC_RUST_LOCK_SHA256');
expect(imageDockerfile).toContain(
'--prefix /usr/local/share/genarrative-ci/agc-npm',
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(
@@ -182,18 +212,15 @@ describe('project CI workflow', () => {
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_NPM_LOCK_SHA256=${AGC_NPM_LOCK_SHA256}',
);
expect(imageDockerfile).toContain(
'GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256=${AGC_RUST_LOCK_SHA256}',
);
expect(imageCheckScript).toContain(npmLock);
expect(imageCheckScript).toContain(rustLock);
expect(imageCheckScript).toContain(
'${GENARRATIVE_GITEA_CI_AGC_NPM_LOCK_SHA256:-}',
'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:-}',
);
@@ -202,6 +229,46 @@ describe('project CI workflow', () => {
);
});
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',