Files
nocobase/scripts/prepare-release-pack.mjs
T

490 lines
16 KiB
JavaScript

import { createHash, randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { pathToFileURL } from 'node:url';
import {
archiveNameForPackage,
discoverPackages,
readPackedManifest,
validatePackedManifest,
} from './pack-check.mjs';
const PLAN_FILE = 'publish-plan.json';
const PACKAGES_DIRECTORY = 'packages';
const DIST_TAG_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/u;
const VERSION_LIKE_TAG_PATTERN = /^v?\d+(?:[.-]|$)/iu;
const SAFE_ARCHIVE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u;
export function validateDistTag(tag, label = 'dist-tag') {
if (
typeof tag !== 'string' ||
!DIST_TAG_PATTERN.test(tag) ||
VERSION_LIKE_TAG_PATTERN.test(tag)
) {
throw new Error(
`${label} must be a non-version npm dist-tag containing only letters, digits, dot, underscore, or hyphen.`,
);
}
return tag;
}
function validateReleaseIdentity(entry, location) {
if (typeof entry.name !== 'string' || entry.name.length === 0) {
throw new Error(`${location}.name must be a non-empty string.`);
}
if (typeof entry.version !== 'string' || entry.version.length === 0) {
throw new Error(`${location}.version must be a non-empty string.`);
}
}
export function validatePublishPlan(document, tagOverride) {
if (!document || typeof document !== 'object' || Array.isArray(document)) {
throw new Error('Publish plan must be a JSON object.');
}
if (document.version !== 1) {
throw new Error(
`Invalid publish plan version: expected 1, received ${String(document.version)}.`,
);
}
if (!Array.isArray(document.plan)) {
throw new Error('Publish plan must contain a plan array.');
}
if (tagOverride !== undefined) validateDistTag(tagOverride, '--tag');
const packageLocations = new Map();
for (const [groupIndex, group] of document.plan.entries()) {
if (!Array.isArray(group)) {
throw new Error(`Publish plan group ${groupIndex} must be an array.`);
}
for (const [entryIndex, entry] of group.entries()) {
const location = `plan[${groupIndex}][${entryIndex}]`;
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`${location} must be an object.`);
}
if (!['publish', 'tag-only'].includes(entry.kind)) {
throw new Error(`${location}.kind must be publish or tag-only.`);
}
validateReleaseIdentity(entry, location);
if (packageLocations.has(entry.name)) {
throw new Error(
`Duplicate release entry for ${entry.name}: ${packageLocations.get(entry.name)} and ${location}.`,
);
}
packageLocations.set(entry.name, location);
if (entry.kind === 'publish') {
if (!['public', 'restricted'].includes(entry.access)) {
throw new Error(`${location}.access must be public or restricted.`);
}
validateDistTag(tagOverride ?? entry.tag, `${location}.tag`);
}
}
}
return document;
}
async function sha256Integrity(filePath) {
const hash = createHash('sha256');
await pipeline(createReadStream(filePath), hash);
return `sha256-${hash.digest('base64')}`;
}
async function requireRegularFile(filePath, description) {
let stats;
try {
stats = await lstat(filePath);
} catch (error) {
if (error?.code === 'ENOENT') {
throw new Error(`Missing ${description}: ${filePath}.`, { cause: error });
}
throw error;
}
if (!stats.isFile()) {
throw new Error(`${description} must be a regular file: ${filePath}.`);
}
}
async function requireDirectory(directoryPath, description) {
let stats;
try {
stats = await lstat(directoryPath);
} catch (error) {
if (error?.code === 'ENOENT') {
throw new Error(`Missing ${description}: ${directoryPath}.`, {
cause: error,
});
}
throw error;
}
if (!stats.isDirectory()) {
throw new Error(`${description} must be a directory: ${directoryPath}.`);
}
}
function requireSourcePackage(entry, packagesByName) {
const packageInfo = packagesByName.get(entry.name);
if (!packageInfo) {
throw new Error(
`Publish plan package is not in the source repository: ${entry.name}.`,
);
}
if (packageInfo.manifest.version !== entry.version) {
throw new Error(
`Publish plan version mismatch for ${entry.name}: source is ${packageInfo.manifest.version}, plan requests ${entry.version}.`,
);
}
return packageInfo;
}
async function validateTarball({
entry,
packageInfo,
tarballPath,
expectedIntegrity,
}) {
await requireRegularFile(tarballPath, `tarball for ${entry.name}`);
// Reject modified bytes before invoking tar; GNU tar and BSD tar handle trailing garbage differently.
const integrity = await sha256Integrity(tarballPath);
if (expectedIntegrity !== undefined && integrity !== expectedIntegrity) {
throw new Error(
`Tarball integrity mismatch for ${entry.name}: expected ${expectedIntegrity}, received ${integrity}.`,
);
}
const packedManifest = await readPackedManifest(tarballPath);
validatePackedManifest(packageInfo.manifest, packedManifest);
return integrity;
}
function releaseArchiveName(packageName) {
const filename = archiveNameForPackage(packageName);
if (!SAFE_ARCHIVE_PATTERN.test(filename)) {
throw new Error(
`Cannot derive a safe release archive filename for ${packageName}.`,
);
}
return filename;
}
function resolveContainedTarball(outputDirectory, tarballPath, packageName) {
if (typeof tarballPath !== 'string' || tarballPath.length === 0) {
throw new Error(`${packageName} tarball.path must be a non-empty string.`);
}
if (
path.posix.isAbsolute(tarballPath) ||
path.win32.isAbsolute(tarballPath) ||
tarballPath.includes('\\') ||
tarballPath.split('/').includes('..')
) {
throw new Error(`${packageName} tarball.path must stay inside --output.`);
}
const resolved = path.resolve(outputDirectory, ...tarballPath.split('/'));
if (
!pathsOverlap(outputDirectory, resolved) ||
resolved === outputDirectory
) {
throw new Error(`${packageName} tarball.path must stay inside --output.`);
}
return resolved;
}
function pathsOverlap(left, right) {
const relative = path.relative(left, right);
return (
relative === '' ||
(!relative.startsWith('..') && !path.isAbsolute(relative))
);
}
async function inspectExistingOutput(outputDirectory, planPath) {
let stats;
try {
stats = await lstat(outputDirectory);
} catch (error) {
if (error?.code === 'ENOENT') return false;
throw error;
}
if (!stats.isDirectory()) {
throw new Error(`--output must be a directory: ${outputDirectory}.`);
}
const allowed = new Set([PLAN_FILE, PACKAGES_DIRECTORY]);
if (path.dirname(planPath) === outputDirectory) {
allowed.add(path.basename(planPath));
}
for (const entry of await readdir(outputDirectory, { withFileTypes: true })) {
if (!allowed.has(entry.name)) {
throw new Error(
`Unexpected entry in release-pack output: ${entry.name}. Use a dedicated output directory.`,
);
}
if (entry.name === PACKAGES_DIRECTORY && !entry.isDirectory()) {
throw new Error(`${PACKAGES_DIRECTORY} must be a directory.`);
}
if (entry.name !== PACKAGES_DIRECTORY && !entry.isFile()) {
throw new Error(`${entry.name} must be a regular file.`);
}
}
return true;
}
async function replaceOutputDirectory({
outputDirectory,
outputExists,
stagingDirectory,
}) {
const backupDirectory = `${outputDirectory}.backup-${randomUUID()}`;
let backedUp = false;
try {
if (outputExists) {
await rename(outputDirectory, backupDirectory);
backedUp = true;
}
await rename(stagingDirectory, outputDirectory);
if (backedUp) await rm(backupDirectory, { force: true, recursive: true });
} catch (error) {
await rm(stagingDirectory, { force: true, recursive: true });
if (backedUp) {
await rm(outputDirectory, { force: true, recursive: true });
await rename(backupDirectory, outputDirectory);
}
throw error;
}
}
export async function prepareReleasePack({
artifacts,
output,
plan,
repoRoot,
tag,
}) {
const planPath = path.resolve(plan);
const artifactsDirectory = path.resolve(artifacts);
const outputDirectory = path.resolve(output);
const resolvedRepoRoot = path.resolve(repoRoot);
if (outputDirectory === path.parse(outputDirectory).root) {
throw new Error('--output cannot be a filesystem root.');
}
if (
pathsOverlap(outputDirectory, artifactsDirectory) ||
pathsOverlap(artifactsDirectory, outputDirectory)
) {
throw new Error('--output and --artifacts must not overlap.');
}
if (
pathsOverlap(outputDirectory, resolvedRepoRoot) ||
pathsOverlap(resolvedRepoRoot, outputDirectory)
) {
throw new Error('--output and --repo-root must not overlap.');
}
await requireRegularFile(planPath, 'publish plan');
const originalPlan = await readFile(planPath, 'utf8');
const document = validatePublishPlan(JSON.parse(originalPlan), tag);
const packages = await discoverPackages(resolvedRepoRoot);
const packagesByName = new Map(
packages.map((packageInfo) => [packageInfo.manifest.name, packageInfo]),
);
const preparedByName = new Map();
// Read and hash every publish artifact before creating the output staging directory. A missing package late in a
// dependency chunk must not leave a publishable subset behind.
for (const entry of document.plan.flat()) {
if (entry.kind !== 'publish') continue;
const packageInfo = requireSourcePackage(entry, packagesByName);
const filename = releaseArchiveName(entry.name);
const sourcePath = path.join(artifactsDirectory, filename);
preparedByName.set(entry.name, {
filename,
integrity: await validateTarball({
entry,
packageInfo,
tarballPath: sourcePath,
}),
sourcePath,
});
}
const packedPlan = document.plan.map((group) =>
group.map((entry) => {
if (entry.kind !== 'publish') return entry;
const prepared = preparedByName.get(entry.name);
return {
...entry,
...(tag === undefined ? {} : { tag }),
tarball: {
path: path.posix.join(PACKAGES_DIRECTORY, prepared.filename),
integrity: prepared.integrity,
},
};
}),
);
await mkdir(path.dirname(outputDirectory), { recursive: true });
const outputExists = await inspectExistingOutput(outputDirectory, planPath);
const stagingDirectory = await mkdtemp(
path.join(path.dirname(outputDirectory), '.release-pack-'),
);
try {
const stagingPackages = path.join(stagingDirectory, PACKAGES_DIRECTORY);
await mkdir(stagingPackages);
for (const prepared of preparedByName.values()) {
const destination = path.join(stagingPackages, prepared.filename);
await copyFile(prepared.sourcePath, destination);
const copiedIntegrity = await sha256Integrity(destination);
if (copiedIntegrity !== prepared.integrity) {
throw new Error(
`Artifact changed while copying ${prepared.filename}: expected ${prepared.integrity}, received ${copiedIntegrity}.`,
);
}
}
await writeFile(
path.join(stagingDirectory, PLAN_FILE),
`${JSON.stringify({ ...document, plan: packedPlan }, null, 2)}\n`,
);
await replaceOutputDirectory({
outputDirectory,
outputExists,
stagingDirectory,
});
} catch (error) {
await rm(stagingDirectory, { force: true, recursive: true });
throw error;
}
console.log(
`Prepared ${preparedByName.size} publish tarball${preparedByName.size === 1 ? '' : 's'} in ${outputDirectory}.`,
);
return { plan: packedPlan, publishCount: preparedByName.size };
}
export async function validatePreparedReleasePack({ output, plan, repoRoot }) {
const planPath = path.resolve(plan);
const outputDirectory = path.resolve(output);
const resolvedRepoRoot = path.resolve(repoRoot);
await requireRegularFile(planPath, 'publish plan');
await requireDirectory(outputDirectory, 'release-pack output');
const canonicalOutputDirectory = await realpath(outputDirectory);
const document = validatePublishPlan(
JSON.parse(await readFile(planPath, 'utf8')),
);
const packages = await discoverPackages(resolvedRepoRoot);
const packagesByName = new Map(
packages.map((packageInfo) => [packageInfo.manifest.name, packageInfo]),
);
let publishCount = 0;
for (const entry of document.plan.flat()) {
if (entry.kind !== 'publish') continue;
publishCount += 1;
const packageInfo = requireSourcePackage(entry, packagesByName);
if (!entry.tarball || typeof entry.tarball !== 'object') {
throw new Error(`${entry.name} must include tarball metadata.`);
}
if (typeof entry.tarball.integrity !== 'string') {
throw new Error(`${entry.name} tarball.integrity must be a string.`);
}
const tarballPath = resolveContainedTarball(
outputDirectory,
entry.tarball.path,
entry.name,
);
const expectedTarballPath = path.posix.join(
PACKAGES_DIRECTORY,
releaseArchiveName(entry.name),
);
if (entry.tarball.path !== expectedTarballPath) {
throw new Error(
`${entry.name} tarball.path must be ${expectedTarballPath}.`,
);
}
await requireRegularFile(tarballPath, `prepared tarball for ${entry.name}`);
const canonicalTarballPath = await realpath(tarballPath);
if (!pathsOverlap(canonicalOutputDirectory, canonicalTarballPath)) {
throw new Error(`${entry.name} tarball.path must stay inside --output.`);
}
await validateTarball({
entry,
packageInfo,
tarballPath: canonicalTarballPath,
expectedIntegrity: entry.tarball.integrity,
});
}
console.log(
`Validated ${publishCount} prepared publish tarball${publishCount === 1 ? '' : 's'} in ${outputDirectory}.`,
);
return { plan: document.plan, publishCount };
}
function parseArguments(args) {
const options = {};
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === '--validate-only') {
if (options.validateOnly) {
throw new Error('Duplicate option: --validate-only');
}
options.validateOnly = true;
continue;
}
if (!argument.startsWith('--')) {
throw new Error(`Unexpected argument: ${argument}`);
}
const name = argument.slice(2);
if (!['artifacts', 'output', 'plan', 'repo-root', 'tag'].includes(name)) {
throw new Error(`Unknown option: ${argument}`);
}
if (options[name] !== undefined) {
throw new Error(`Duplicate option: ${argument}`);
}
const value = args[index + 1];
if (!value) throw new Error(`${argument} requires a value.`);
options[name] = value;
index += 1;
}
const required = options.validateOnly
? ['output', 'plan', 'repo-root']
: ['artifacts', 'output', 'plan', 'repo-root'];
for (const name of required) {
if (!options[name]) throw new Error(`--${name} is required.`);
}
if (options.validateOnly && (options.artifacts || options.tag)) {
throw new Error(
'--artifacts and --tag are not valid with --validate-only.',
);
}
return {
artifacts: options.artifacts,
output: options.output,
plan: options.plan,
repoRoot: options['repo-root'],
tag: options.tag,
validateOnly: options.validateOnly === true,
};
}
const isMain =
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
if (isMain) {
const options = parseArguments(process.argv.slice(2));
if (options.validateOnly) await validatePreparedReleasePack(options);
else await prepareReleasePack(options);
}