mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-13 16:08:39 +08:00
490 lines
16 KiB
JavaScript
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);
|
|
}
|