迁移 npm workspaces 统一依赖边界
根目录统一管理九个 workspace 与唯一 lockfile 迁移 CI、Jenkins 和容器的根目录单次 npm ci 补齐 hoist 兼容、版本校验与 workspace 门禁 同步依赖边界方案、运维文档和长期记忆
This commit is contained in:
@@ -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:-}" \
|
||||
|
||||
@@ -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(',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 时必须跳过该安装。',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}" \
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user