mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-13 07:47:55 +08:00
Split the release prepare job so build-and-pack and a two-shard test job run in parallel after versioning, skip the templates in the release build, run pack:check in parallel without rebuilding, resolve only @nocobase packages from npm.nocobase.ai, and start the Hub image alongside the GitHub Release.
472 lines
14 KiB
JavaScript
472 lines
14 KiB
JavaScript
import { execFile, spawn } from 'node:child_process';
|
|
import {
|
|
access,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
} from 'node:fs/promises';
|
|
import { availableParallelism, tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export function archiveNameForPackage(packageName) {
|
|
return `${packageName.replace(/^@/u, '').replaceAll('/', '-')}.tgz`;
|
|
}
|
|
|
|
export function findUnresolvedProtocols(value, fieldPath = []) {
|
|
if (typeof value === 'string') {
|
|
return /^(?:workspace|catalog):/u.test(value)
|
|
? [{ field: fieldPath.join('.'), value }]
|
|
: [];
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((item, index) =>
|
|
findUnresolvedProtocols(item, [...fieldPath, String(index)]),
|
|
);
|
|
}
|
|
|
|
if (value && typeof value === 'object') {
|
|
return Object.entries(value).flatMap(([key, item]) =>
|
|
findUnresolvedProtocols(item, [...fieldPath, key]),
|
|
);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
export function hasTypeEntrypoints(manifest) {
|
|
if (typeof manifest.types === 'string') return true;
|
|
|
|
const visit = (value) => {
|
|
if (!value || typeof value !== 'object') return false;
|
|
if (!Array.isArray(value) && typeof value.types === 'string') return true;
|
|
return Object.values(value).some(visit);
|
|
};
|
|
|
|
return visit(manifest.exports);
|
|
}
|
|
|
|
// Compiled packages publish runtime resources in dist and resolve them from the plugin's baseDir.
|
|
// Keep source directories out of these packages, preserving the publish contract that also protects older runtimes
|
|
// which search package-root resources first. Templates publish source instead of dist and are exempt.
|
|
const RUNTIME_RESOLVED_SOURCE_DIRECTORIES = ['database', 'server'];
|
|
|
|
export function findShadowedSourceDirectories(manifest) {
|
|
const files = Array.isArray(manifest.files) ? manifest.files : [];
|
|
if (!files.includes('dist')) return [];
|
|
return RUNTIME_RESOLVED_SOURCE_DIRECTORIES.filter((directoryName) =>
|
|
files.includes(directoryName),
|
|
);
|
|
}
|
|
|
|
export function validatePackageManifest(manifest, directory) {
|
|
const errors = [];
|
|
|
|
if (typeof manifest.name !== 'string' || manifest.name.length === 0) {
|
|
errors.push('name must be a non-empty string');
|
|
}
|
|
if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
|
|
errors.push('version must be a non-empty string');
|
|
}
|
|
if (manifest.private === true) {
|
|
errors.push('packages/ packages must not be private');
|
|
}
|
|
if (manifest.publishConfig?.access !== 'public') {
|
|
errors.push('publishConfig.access must be "public"');
|
|
}
|
|
if (
|
|
!Array.isArray(manifest.files) ||
|
|
manifest.files.length === 0 ||
|
|
manifest.files.some((file) => typeof file !== 'string' || file.length === 0)
|
|
) {
|
|
errors.push('files must be a non-empty array of strings');
|
|
}
|
|
|
|
for (const directoryName of findShadowedSourceDirectories(manifest)) {
|
|
errors.push(
|
|
`files must not publish the "${directoryName}" source directory beside dist; compiled runtime resources belong in dist`,
|
|
);
|
|
}
|
|
|
|
// A subpath present in `exports` but absent from `publishConfig.exports` resolves in this repository, where source
|
|
// exports are used, and fails only once the package is installed from a registry: Node reports
|
|
// ERR_PACKAGE_PATH_NOT_EXPORTED for a subpath the source tree clearly has. `@nocobase/app-server` shipped without
|
|
// `./i18n` exactly this way, and a generated application could not start.
|
|
const sourceExports = manifest.exports;
|
|
const publishExports = manifest.publishConfig?.exports;
|
|
|
|
if (
|
|
sourceExports &&
|
|
publishExports &&
|
|
typeof sourceExports === 'object' &&
|
|
typeof publishExports === 'object'
|
|
) {
|
|
const missing = Object.keys(sourceExports).filter(
|
|
(subpath) => !(subpath in publishExports),
|
|
);
|
|
const extra = Object.keys(publishExports).filter(
|
|
(subpath) => !(subpath in sourceExports),
|
|
);
|
|
|
|
if (missing.length > 0) {
|
|
errors.push(
|
|
`publishConfig.exports is missing ${missing.join(', ')}, which exports declares`,
|
|
);
|
|
}
|
|
if (extra.length > 0) {
|
|
errors.push(
|
|
`publishConfig.exports declares ${extra.join(', ')}, which exports does not`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(
|
|
`Invalid publish metadata in ${path.join(directory, 'package.json')}:\n${errors
|
|
.map((error) => ` - ${error}`)
|
|
.join('\n')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// packages/ groups its packages one level deep, as packages/<category>/<package>. The category directories themselves
|
|
// carry no package.json, so publishable packages are found by descending into each of them. Reading only the top level
|
|
// would discover nothing and let `pnpm pack:check` pass without checking anything.
|
|
async function listPackageDirectories(packagesDirectory) {
|
|
const categories = await readdir(packagesDirectory, { withFileTypes: true });
|
|
const directories = [];
|
|
|
|
for (const category of categories) {
|
|
if (!category.isDirectory()) continue;
|
|
const categoryDirectory = path.join(packagesDirectory, category.name);
|
|
for (const entry of await readdir(categoryDirectory, {
|
|
withFileTypes: true,
|
|
})) {
|
|
if (!entry.isDirectory()) continue;
|
|
directories.push(path.join(categoryDirectory, entry.name));
|
|
}
|
|
}
|
|
|
|
return directories;
|
|
}
|
|
|
|
export async function discoverPackages(repoRoot) {
|
|
const packagesDirectory = path.join(repoRoot, 'packages');
|
|
const directories = await listPackageDirectories(packagesDirectory);
|
|
const packages = [];
|
|
|
|
for (const directory of directories) {
|
|
const manifestPath = path.join(directory, 'package.json');
|
|
let manifest;
|
|
|
|
try {
|
|
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') continue;
|
|
throw error;
|
|
}
|
|
|
|
validatePackageManifest(manifest, directory);
|
|
|
|
try {
|
|
const changelog = await readFile(
|
|
path.join(directory, 'CHANGELOG.md'),
|
|
'utf8',
|
|
);
|
|
if (!changelog.startsWith(`# ${manifest.name}\n`)) {
|
|
throw new Error(
|
|
`${manifest.name} CHANGELOG.md must start with "# ${manifest.name}".`,
|
|
);
|
|
}
|
|
if (
|
|
manifest.version !== '0.0.0' &&
|
|
!changelog.includes(`## ${manifest.version}\n`)
|
|
) {
|
|
throw new Error(
|
|
`${manifest.name} CHANGELOG.md must include version ${manifest.version}.`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') {
|
|
throw new Error(`${manifest.name} must include CHANGELOG.md.`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
packages.push({ directory, manifest });
|
|
}
|
|
|
|
return packages.sort((left, right) =>
|
|
left.manifest.name.localeCompare(right.manifest.name),
|
|
);
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
...options,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
child.stdout.on('data', (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
child.once('error', reject);
|
|
child.once('exit', (code, signal) => {
|
|
if (code === 0) {
|
|
resolve({ stderr, stdout });
|
|
return;
|
|
}
|
|
|
|
const output = [stdout.trim(), stderr.trim()].filter(Boolean).join('\n');
|
|
reject(
|
|
new Error(
|
|
`${command} ${args.join(' ')} failed with ${
|
|
signal ? `signal ${signal}` : `exit code ${code}`
|
|
}.${output ? `\n${output}` : ''}`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function readPackedManifest(archivePath) {
|
|
const { stdout } = await execFileAsync('tar', [
|
|
'-xOf',
|
|
archivePath,
|
|
'package/package.json',
|
|
]);
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
export function validatePackedManifest(sourceManifest, packedManifest) {
|
|
if (
|
|
packedManifest.name !== sourceManifest.name ||
|
|
packedManifest.version !== sourceManifest.version
|
|
) {
|
|
throw new Error(
|
|
`Packed identity mismatch for ${sourceManifest.name}: expected ${sourceManifest.name}@${sourceManifest.version}, received ${packedManifest.name}@${packedManifest.version}.`,
|
|
);
|
|
}
|
|
|
|
const unresolved = findUnresolvedProtocols(packedManifest);
|
|
if (unresolved.length === 0) return;
|
|
|
|
throw new Error(
|
|
`Unresolved workspace/catalog protocols in ${sourceManifest.name}:\n${unresolved
|
|
.map(({ field, value }) => ` ${field}: ${value}`)
|
|
.join('\n')}`,
|
|
);
|
|
}
|
|
|
|
async function smokeTestDevConfig(archivePath, packageDirectory) {
|
|
const extractDirectory = await mkdtemp(
|
|
path.join(packageDirectory, '.pack-check-'),
|
|
);
|
|
|
|
try {
|
|
await execFileAsync('tar', ['-xzf', archivePath, '-C', extractDirectory]);
|
|
|
|
for (const entry of [
|
|
'eslint/index.js',
|
|
'prettier/index.js',
|
|
'vitest/node.js',
|
|
'vitest/react.js',
|
|
'vite/app.js',
|
|
'database/database-manifests.js',
|
|
]) {
|
|
await import(
|
|
pathToFileURL(path.join(extractDirectory, 'package', 'dist', entry))
|
|
.href
|
|
);
|
|
}
|
|
} finally {
|
|
await rm(extractDirectory, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
// A `prepack` script chains the steps a package needs before it can be packed, typically `pnpm registry:build &&
|
|
// pnpm build`. When the workspace has just been built, the build half repeats work already on disk: in a release it
|
|
// recompiled fourteen packages one at a time. Prebuilt mode keeps every other step and drops only the build.
|
|
export function prepackStepsWithoutBuild(prepack) {
|
|
if (typeof prepack !== 'string' || prepack.trim().length === 0) return [];
|
|
return prepack
|
|
.split('&&')
|
|
.map((step) => step.trim())
|
|
.filter((step) => step.length > 0 && !/^pnpm (?:run )?build$/u.test(step));
|
|
}
|
|
|
|
async function runPrepackWithoutBuild({ directory, manifest }, env) {
|
|
for (const step of prepackStepsWithoutBuild(manifest.scripts?.prepack)) {
|
|
await run('sh', ['-c', step], { cwd: directory, env });
|
|
}
|
|
|
|
// Skipping the build is only safe when the build already ran. Without this, a package whose dist is missing would
|
|
// pack an archive holding nothing but its manifest, and publint would not notice every such case.
|
|
if (Array.isArray(manifest.files) && manifest.files.includes('dist')) {
|
|
try {
|
|
await access(path.join(directory, 'dist'));
|
|
} catch {
|
|
throw new Error(
|
|
`${manifest.name} has no dist directory. Run pnpm build before pack:check with PACK_CHECK_PREBUILT=1.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function checkPackage({
|
|
archivePath,
|
|
packageInfo,
|
|
prebuilt,
|
|
repoRoot,
|
|
env,
|
|
}) {
|
|
const { directory, manifest } = packageInfo;
|
|
|
|
await rm(archivePath, { force: true });
|
|
if (prebuilt) {
|
|
await runPrepackWithoutBuild(packageInfo, env);
|
|
await run(
|
|
'pnpm',
|
|
['pack', '--out', archivePath, '--config.ignore-scripts=true'],
|
|
{ cwd: directory, env },
|
|
);
|
|
} else {
|
|
await run('pnpm', ['pack', '--out', archivePath], {
|
|
cwd: directory,
|
|
env,
|
|
});
|
|
}
|
|
|
|
const packedManifest = await readPackedManifest(archivePath);
|
|
validatePackedManifest(manifest, packedManifest);
|
|
await run('pnpm', ['exec', 'publint', 'run', archivePath], {
|
|
cwd: repoRoot,
|
|
env,
|
|
});
|
|
|
|
if (hasTypeEntrypoints(packedManifest)) {
|
|
await run('pnpm', ['exec', 'attw', archivePath, '--profile', 'esm-only'], {
|
|
cwd: repoRoot,
|
|
env,
|
|
});
|
|
}
|
|
|
|
if (manifest.name === '@nocobase/dev-config') {
|
|
await smokeTestDevConfig(archivePath, directory);
|
|
}
|
|
}
|
|
|
|
// Only prebuilt mode runs in parallel by default. Otherwise every prepack rebuilds its package, and builds share
|
|
// state: @nocobase/dev-config clears its dist before rebuilding it while other packages' builds run its
|
|
// nocobase-db-manifests binary from there.
|
|
export function resolveConcurrency(value, { prebuilt = false } = {}) {
|
|
if (value === undefined || value.trim() === '') {
|
|
return prebuilt ? availableParallelism() : 1;
|
|
}
|
|
const concurrency = Number(value);
|
|
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
throw new Error(
|
|
`PACK_CHECK_CONCURRENCY must be a positive integer, received ${JSON.stringify(value)}.`,
|
|
);
|
|
}
|
|
return concurrency;
|
|
}
|
|
|
|
// Runs `task` over `items` with at most `concurrency` in flight. The first failure stops new tasks from starting and
|
|
// is rethrown once the tasks already running have settled, so no child process outlives the check.
|
|
export async function mapWithConcurrency(items, concurrency, task) {
|
|
let next = 0;
|
|
let failure;
|
|
|
|
async function worker() {
|
|
while (failure === undefined && next < items.length) {
|
|
const index = next;
|
|
next += 1;
|
|
try {
|
|
await task(items[index], index);
|
|
} catch (error) {
|
|
failure ??= { error };
|
|
}
|
|
}
|
|
}
|
|
|
|
await Promise.all(
|
|
Array.from({ length: Math.min(concurrency, items.length) }, worker),
|
|
);
|
|
if (failure !== undefined) throw failure.error;
|
|
}
|
|
|
|
export async function packCheck({
|
|
env = process.env,
|
|
repoRoot = path.resolve(import.meta.dirname, '..'),
|
|
} = {}) {
|
|
const packages = await discoverPackages(repoRoot);
|
|
const configuredDirectory = env.PACK_DIR?.trim();
|
|
const packDirectory = configuredDirectory
|
|
? path.resolve(configuredDirectory)
|
|
: await mkdtemp(path.join(tmpdir(), 'nocobase-pack-check-'));
|
|
const checkEnv = { ...env, PACK_DIR: packDirectory };
|
|
const prebuilt = env.PACK_CHECK_PREBUILT === '1';
|
|
const concurrency = resolveConcurrency(env.PACK_CHECK_CONCURRENCY, {
|
|
prebuilt,
|
|
});
|
|
|
|
await mkdir(packDirectory, { recursive: true });
|
|
console.log(
|
|
`Checking ${packages.length} publishable packages with concurrency ${concurrency}${
|
|
prebuilt ? ', reusing the existing build' : ''
|
|
}...`,
|
|
);
|
|
|
|
try {
|
|
let completed = 0;
|
|
await mapWithConcurrency(packages, concurrency, async (packageInfo) => {
|
|
const { name } = packageInfo.manifest;
|
|
const archivePath = path.join(packDirectory, archiveNameForPackage(name));
|
|
|
|
try {
|
|
await checkPackage({
|
|
archivePath,
|
|
env: checkEnv,
|
|
packageInfo,
|
|
prebuilt,
|
|
repoRoot,
|
|
});
|
|
} catch (error) {
|
|
console.log(`${name} ... failed`);
|
|
throw new Error(`Pack check failed for ${name}.`, { cause: error });
|
|
}
|
|
completed += 1;
|
|
console.log(`[${completed}/${packages.length}] ${name} ... ok`);
|
|
});
|
|
|
|
console.log(`Pack checks passed for ${packages.length} packages.`);
|
|
} finally {
|
|
if (!configuredDirectory) {
|
|
await rm(packDirectory, { force: true, recursive: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
const isMain =
|
|
process.argv[1] &&
|
|
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
|
|
if (isMain) {
|
|
await packCheck();
|
|
}
|