@plone/scripts
Version:
Volto Core scripts package - Contains scripts and dependencies for these scripts for tooling when developing Plone 6 / Volto
262 lines (212 loc) • 6.48 kB
JavaScript
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { globSync as glob } from 'glob';
const NEWS_GLOB = '**/news/*.{breaking,feature,bugfix,documentation,internal}';
const IGNORE_GLOBS = ['**/node_modules/**', '_build/**', 'docs/**'];
const RELEASE_GROUPS = [
{
label: '@plone/types',
packages: ['@plone/types'],
},
{
label: 'Core packages (level 1)',
packages: ['@plone/components', '@plone/registry'],
},
{
label: 'Utils (level 2)',
packages: [
'@plone/babel-preset-razzle',
'@plone/scripts',
'@plone/razzle-dev-utils',
'@plone/razzle',
],
},
{
label: 'Add-on packages (level 3)',
packages: ['@plone/volto-slate'],
},
{
label: 'Application',
packages: ['@plone/volto'],
},
];
function readPackageJson(filename) {
return JSON.parse(readFileSync(filename, 'utf8'));
}
function getPackageMetadata() {
const packageFiles = [...glob('packages/*/package.json')];
return packageFiles.reduce(
(metadata, filename) => {
const packageJson = readPackageJson(filename);
const packageDir = path.dirname(filename);
metadata.byDir.set(packageDir, {
dir: packageDir,
name: packageJson.name,
hasReleaseScript: Boolean(packageJson.scripts?.release),
});
metadata.byName.set(packageJson.name, packageDir);
return metadata;
},
{
byDir: new Map(),
byName: new Map(),
},
);
}
function collectNewsFragments(packageMetadata) {
const fragmentsByPackage = new Map();
glob(NEWS_GLOB, { ignore: IGNORE_GLOBS }).forEach((filename) => {
const packageDir = filename.split('/').slice(0, 2).join('/');
const metadata = packageMetadata.byDir.get(packageDir);
if (!metadata) {
return;
}
const packageInfo = fragmentsByPackage.get(metadata.name) ?? {
...metadata,
fragments: [],
};
packageInfo.fragments.push(filename);
fragmentsByPackage.set(metadata.name, packageInfo);
});
return fragmentsByPackage;
}
function getPlannedReleases(fragmentsByPackage) {
const planned = [];
RELEASE_GROUPS.forEach((group) => {
const packages = group.packages
.map((packageName) => fragmentsByPackage.get(packageName))
.filter(Boolean);
if (packages.length > 0) {
planned.push({
label: group.label,
packages,
});
}
});
return planned;
}
function getUnplannedReleases(fragmentsByPackage) {
const plannedPackageNames = new Set(
RELEASE_GROUPS.flatMap((group) => group.packages),
);
return [...fragmentsByPackage.values()]
.filter((pkg) => !plannedPackageNames.has(pkg.name))
.sort((a, b) => a.name.localeCompare(b.name));
}
function printPackageLine(pkg, index) {
const prefix = index == null ? '-' : `${String(index).padStart(2, ' ')}.`;
const fragmentLabel =
pkg.fragments.length === 1
? '1 news fragment'
: `${pkg.fragments.length} news fragments`;
const releaseLabel = pkg.hasReleaseScript ? '' : ' - no release script';
console.log(
`${prefix} ${pkg.name} (${pkg.dir}) - ${fragmentLabel}${releaseLabel}`,
);
}
function printSummary(plannedReleases, unplannedReleases) {
if (plannedReleases.length === 0 && unplannedReleases.length === 0) {
console.log('No releasable news fragments found.');
return;
}
console.log('Release plan');
console.log('============');
let releaseIndex = 1;
plannedReleases.forEach((group) => {
console.log(`\n${group.label}`);
group.packages.forEach((pkg) => {
printPackageLine(pkg, releaseIndex);
releaseIndex += 1;
});
});
if (unplannedReleases.length > 0) {
console.log('\nChanged packages outside the planned release chain');
unplannedReleases.forEach((pkg) => {
printPackageLine(pkg);
});
}
}
function runRelease(packageName) {
const result = spawnSync('pnpm', ['--filter', packageName, 'release'], {
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(
`Release failed for ${packageName} with exit code ${result.status}`,
);
}
}
async function promptAndRelease(plannedReleases) {
const packages = plannedReleases.flatMap((group) => group.packages);
if (packages.length === 0) {
console.log('\nNothing to release.');
return;
}
const rl = readline.createInterface({ input, output });
try {
for (const pkg of packages) {
const answer = await rl.question(`\nRelease ${pkg.name}? [Y/n/q] `);
const normalized = answer.trim().toLowerCase();
if (normalized === 'q') {
console.log('Release sequence aborted.');
return;
}
if (normalized === 'n') {
console.log(`Skipped ${pkg.name}.`);
continue;
}
runRelease(pkg.name);
}
} finally {
rl.close();
}
}
function validateReleasePlan(packageMetadata) {
const missing = [];
const withoutReleaseScript = [];
RELEASE_GROUPS.forEach((group) => {
group.packages.forEach((packageName) => {
const packageDir = packageMetadata.byName.get(packageName);
if (!packageDir) {
missing.push(packageName);
return;
}
const metadata = packageMetadata.byDir.get(packageDir);
if (!metadata?.hasReleaseScript) {
withoutReleaseScript.push(packageName);
}
});
});
if (missing.length > 0) {
throw new Error(
`Release plan references unknown packages: ${missing.join(', ')}`,
);
}
if (withoutReleaseScript.length > 0) {
throw new Error(
`Release plan references packages without a release script: ${withoutReleaseScript.join(', ')}`,
);
}
}
async function main() {
const shouldRelease = process.argv.includes('--release');
const packageMetadata = getPackageMetadata();
validateReleasePlan(packageMetadata);
const fragmentsByPackage = collectNewsFragments(packageMetadata);
const plannedReleases = getPlannedReleases(fragmentsByPackage);
const unplannedReleases = getUnplannedReleases(fragmentsByPackage);
printSummary(plannedReleases, unplannedReleases);
if (shouldRelease) {
await promptAndRelease(plannedReleases);
}
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});