mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-16 15:07:08 +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.
304 lines
8.9 KiB
JavaScript
304 lines
8.9 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
archiveNameForPackage,
|
|
discoverPackages,
|
|
findShadowedSourceDirectories,
|
|
findUnresolvedProtocols,
|
|
hasTypeEntrypoints,
|
|
mapWithConcurrency,
|
|
prepackStepsWithoutBuild,
|
|
resolveConcurrency,
|
|
validatePackedManifest,
|
|
validatePackageManifest,
|
|
} from '../../scripts/pack-check.mjs';
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, '../..');
|
|
|
|
test('publishes the default template as source instead of a runtime library', async () => {
|
|
const manifest = JSON.parse(
|
|
await readFile(
|
|
path.join(
|
|
repoRoot,
|
|
'packages/templates/app-template-default/package.json',
|
|
),
|
|
'utf8',
|
|
),
|
|
);
|
|
|
|
assert.equal(manifest.exports, undefined);
|
|
assert.ok(manifest.files.includes('server'));
|
|
// The database directory ships as source too. It is named by part rather than whole so that the
|
|
// Collection artifacts `collections generate` writes under database/<connection>/collections/ stay
|
|
// out of the tarball; template-framework-alignment pins which parts.
|
|
assert.ok(manifest.files.some((entry) => entry.startsWith('database/')));
|
|
assert.ok(!manifest.files.includes('dist'));
|
|
});
|
|
|
|
test('derives stable archive names from scoped package names', () => {
|
|
assert.equal(
|
|
archiveNameForPackage('@nocobase/app-client'),
|
|
'nocobase-app-client.tgz',
|
|
);
|
|
});
|
|
|
|
test('discovers packages from packages directories in package-name order', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
await createPackage(repoRoot, 'tools/zeta', '@nocobase/zeta');
|
|
await createPackage(repoRoot, 'libs/alpha', '@nocobase/alpha');
|
|
|
|
const packages = await discoverPackages(repoRoot);
|
|
|
|
assert.deepEqual(
|
|
packages.map(({ manifest }) => manifest.name),
|
|
['@nocobase/alpha', '@nocobase/zeta'],
|
|
);
|
|
});
|
|
|
|
test('rejects incomplete publication metadata', () => {
|
|
assert.throws(
|
|
() =>
|
|
validatePackageManifest(
|
|
{
|
|
name: '@nocobase/incomplete',
|
|
private: true,
|
|
version: '0.0.1',
|
|
},
|
|
'/repo/packages/libs/incomplete',
|
|
),
|
|
/must not be private[\s\S]*publishConfig\.access[\s\S]*files/u,
|
|
);
|
|
});
|
|
|
|
test('rejects a compiled package that also publishes its runtime-resolved sources', () => {
|
|
assert.deepEqual(
|
|
findShadowedSourceDirectories({ files: ['dist', 'database', 'server'] }),
|
|
['database', 'server'],
|
|
);
|
|
// A template publishes its sources for the user to read and edit, and has no `dist` for them to shadow.
|
|
assert.deepEqual(
|
|
findShadowedSourceDirectories({ files: ['server', 'database'] }),
|
|
[],
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
validatePackageManifest(
|
|
{
|
|
files: ['dist', 'database'],
|
|
name: '@nocobase/app-plugin-shadowed',
|
|
publishConfig: { access: 'public' },
|
|
version: '0.0.1',
|
|
},
|
|
'/repo/packages/plugins/app-plugin-shadowed',
|
|
),
|
|
/must not publish the "database" source directory beside dist/u,
|
|
);
|
|
});
|
|
|
|
test('requires every discovered package to have a changelog', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
await createPackage(repoRoot, 'libs/missing-changelog', '@nocobase/missing', {
|
|
changelog: false,
|
|
});
|
|
|
|
await assert.rejects(
|
|
discoverPackages(repoRoot),
|
|
/must include CHANGELOG\.md/u,
|
|
);
|
|
});
|
|
|
|
test('validates changelog package names', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
await createPackage(repoRoot, 'libs/invalid-changelog', '@nocobase/invalid', {
|
|
changelogContents: '# @nocobase/wrong\n',
|
|
});
|
|
|
|
await assert.rejects(
|
|
discoverPackages(repoRoot),
|
|
/CHANGELOG\.md must start with/u,
|
|
);
|
|
});
|
|
|
|
test('requires the current released version in the changelog', async (t) => {
|
|
const repoRoot = await createTestRepo(t);
|
|
await createPackage(repoRoot, 'libs/stale-changelog', '@nocobase/stale', {
|
|
changelogContents: '# @nocobase/stale\n\n## 0.0.0\n',
|
|
});
|
|
|
|
await assert.rejects(
|
|
discoverPackages(repoRoot),
|
|
/CHANGELOG\.md must include version 0\.0\.1/u,
|
|
);
|
|
});
|
|
|
|
test('finds unresolved workspace and catalog protocols with field paths', () => {
|
|
assert.deepEqual(
|
|
findUnresolvedProtocols({
|
|
dependencies: {
|
|
'@nocobase/app-client': 'workspace:^',
|
|
react: '^19.0.0',
|
|
},
|
|
devDependencies: {
|
|
typescript: 'catalog:',
|
|
},
|
|
}),
|
|
[
|
|
{
|
|
field: 'dependencies.@nocobase/app-client',
|
|
value: 'workspace:^',
|
|
},
|
|
{
|
|
field: 'devDependencies.typescript',
|
|
value: 'catalog:',
|
|
},
|
|
],
|
|
);
|
|
});
|
|
|
|
test('detects type entrypoints in top-level and exported declarations', () => {
|
|
assert.equal(hasTypeEntrypoints({ types: './dist/index.d.ts' }), true);
|
|
assert.equal(
|
|
hasTypeEntrypoints({
|
|
exports: {
|
|
'./client': { types: './dist/client.d.ts' },
|
|
},
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
hasTypeEntrypoints({ exports: { '.': './dist/index.js' } }),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('validates packed identity and resolved protocols', () => {
|
|
const source = { name: '@nocobase/example', version: '1.0.0' };
|
|
|
|
assert.doesNotThrow(() =>
|
|
validatePackedManifest(source, {
|
|
dependencies: { react: '^19.0.0' },
|
|
...source,
|
|
}),
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
validatePackedManifest(source, {
|
|
dependencies: { '@nocobase/sdk': 'workspace:^' },
|
|
...source,
|
|
}),
|
|
/dependencies\.@nocobase\/sdk: workspace:\^/u,
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
validatePackedManifest(source, {
|
|
name: source.name,
|
|
version: '2.0.0',
|
|
}),
|
|
/Packed identity mismatch/u,
|
|
);
|
|
});
|
|
|
|
test('prebuilt mode keeps every prepack step except the package build', () => {
|
|
assert.deepEqual(prepackStepsWithoutBuild('pnpm build'), []);
|
|
assert.deepEqual(prepackStepsWithoutBuild('pnpm run build'), []);
|
|
assert.deepEqual(
|
|
prepackStepsWithoutBuild('pnpm registry:build && pnpm build'),
|
|
['pnpm registry:build'],
|
|
);
|
|
assert.deepEqual(prepackStepsWithoutBuild('pnpm registry:build'), [
|
|
'pnpm registry:build',
|
|
]);
|
|
assert.deepEqual(prepackStepsWithoutBuild(undefined), []);
|
|
});
|
|
|
|
test('every workspace prepack script is understood by prebuilt mode', async () => {
|
|
// A prepack step prebuilt mode does not recognize would run in full, which is safe but silently slow; one it
|
|
// recognizes wrongly would be skipped. Pin the shapes in use so a new one is looked at rather than guessed.
|
|
const packages = await discoverPackages(repoRoot);
|
|
for (const { manifest } of packages) {
|
|
const prepack = manifest.scripts?.prepack;
|
|
if (prepack === undefined) continue;
|
|
for (const step of prepackStepsWithoutBuild(prepack)) {
|
|
assert.equal(step, 'pnpm registry:build', manifest.name);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('resolves pack-check concurrency from the environment', () => {
|
|
assert.equal(resolveConcurrency('3'), 3);
|
|
assert.equal(resolveConcurrency(undefined), 1);
|
|
assert.equal(resolveConcurrency(''), 1);
|
|
assert.ok(resolveConcurrency(undefined, { prebuilt: true }) >= 1);
|
|
assert.equal(resolveConcurrency('2', { prebuilt: true }), 2);
|
|
assert.throws(() => resolveConcurrency('0'), /positive integer/u);
|
|
assert.throws(() => resolveConcurrency('two'), /positive integer/u);
|
|
});
|
|
|
|
test('runs tasks with bounded concurrency and stops after the first failure', async () => {
|
|
let running = 0;
|
|
let peak = 0;
|
|
const started = [];
|
|
await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (item) => {
|
|
started.push(item);
|
|
running += 1;
|
|
peak = Math.max(peak, running);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
running -= 1;
|
|
});
|
|
assert.equal(peak, 2);
|
|
assert.deepEqual(started, [1, 2, 3, 4, 5]);
|
|
|
|
const attempted = [];
|
|
await assert.rejects(
|
|
mapWithConcurrency([1, 2, 3, 4, 5], 1, async (item) => {
|
|
attempted.push(item);
|
|
if (item === 2) throw new Error('boom');
|
|
}),
|
|
/boom/u,
|
|
);
|
|
assert.deepEqual(attempted, [1, 2]);
|
|
});
|
|
|
|
async function createTestRepo(t) {
|
|
const repoRoot = await mkdtemp(
|
|
path.join(tmpdir(), 'nocobase-pack-check-test-'),
|
|
);
|
|
await mkdir(path.join(repoRoot, 'packages'));
|
|
t.after(() => rm(repoRoot, { force: true, recursive: true }));
|
|
return repoRoot;
|
|
}
|
|
|
|
// `relativeDirectory` is a `<category>/<package>` path, matching the two-level layout of `packages/`.
|
|
async function createPackage(
|
|
repoRoot,
|
|
relativeDirectory,
|
|
packageName,
|
|
{ changelog = true, changelogContents } = {},
|
|
) {
|
|
const directory = path.join(repoRoot, 'packages', relativeDirectory);
|
|
await mkdir(directory, { recursive: true });
|
|
await writeFile(
|
|
path.join(directory, 'package.json'),
|
|
`${JSON.stringify(
|
|
{
|
|
files: ['dist'],
|
|
name: packageName,
|
|
publishConfig: { access: 'public' },
|
|
version: '0.0.1',
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
if (changelog) {
|
|
await writeFile(
|
|
path.join(directory, 'CHANGELOG.md'),
|
|
changelogContents ?? `# ${packageName}\n\n## 0.0.1\n`,
|
|
);
|
|
}
|
|
}
|