Files
crossdesk/apps/cli/scripts/release-npm.cjs
T

243 lines
12 KiB
JavaScript

'use strict';
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const { setTimeout: sleep } = require('node:timers/promises');
const project = require('../package.json');
const platforms = require('../npm/platforms.json');
const registry = 'https://registry.npmjs.org/';
const repository = 'kunkundi/crossdesk';
function versionParts(version) {
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?$/.exec(version);
assert(match && !(match[4] || '').split('.').some((part) => /^0\d+$/.test(part)),
`Invalid release version: ${version}`);
return [match.slice(1, 4).map(BigInt), match[4]?.split('.')];
}
function compareVersions(left, right) {
const [a, preA] = versionParts(left);
const [b, preB] = versionParts(right);
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i] ? 1 : -1;
if (!preA || !preB) return preA ? -1 : preB ? 1 : 0;
for (let i = 0; i < Math.max(preA.length, preB.length); i++) {
if (preA[i] === preB[i]) continue;
if (preA[i] === undefined || preB[i] === undefined) return preA[i] === undefined ? -1 : 1;
const numericA = /^\d+$/.test(preA[i]);
const numericB = /^\d+$/.test(preB[i]);
if (numericA !== numericB) return numericA ? -1 : 1;
return (numericA ? BigInt(preA[i]) > BigInt(preB[i]) : preA[i] > preB[i]) ? 1 : -1;
}
return 0;
}
function releasePlan() {
assert(['true', 'false'].includes(process.env.RELEASE_DRY_RUN), 'Set RELEASE_DRY_RUN=true or false explicitly.');
const dryRun = process.env.RELEASE_DRY_RUN === 'true';
const version = project.version;
versionParts(version);
assert.equal(project.name, '@chunkdash/crossdesk-cli', 'Unexpected entry package scope.');
const nativeVersion = fs.readFileSync(path.join(__dirname, '../xmake.lua'), 'utf8')
.match(/set_version\("([^"]+)"\)/)?.[1];
assert.equal(version, nativeVersion, 'package.json and xmake.lua versions must match.');
const ref = process.env.GITHUB_REF || '';
if (!dryRun) {
assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Real publishing must run in GitHub Actions.');
assert.equal(process.env.GITHUB_REPOSITORY, repository, 'Unexpected publishing repository.');
assert(['push', 'workflow_dispatch'].includes(process.env.GITHUB_EVENT_NAME), 'Unsupported publishing event.');
assert.equal(ref, `refs/tags/cli-v${version}`, 'Publish only from the matching cli-vVERSION tag.');
} else if (ref.startsWith('refs/tags/')) {
assert.equal(ref, `refs/tags/cli-v${version}`, 'Git tag and package version must match, including in a dry run.');
}
return { version, tag: version.includes('-') ? 'next' : 'latest', dryRun };
}
function run(command, args, options = {}) {
return execFileSync(command, args, { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024, ...options });
}
function npm(args, options) {
return process.env.npm_execpath
? run(process.execPath, [process.env.npm_execpath, ...args], options)
: run('npm', args, options);
}
function archiveRead(file, member, encoding = 'utf8') {
return run('tar', ['-xOf', file, `package/${member}`], { encoding });
}
function filename(name, version) {
return `${name.replace(/^@/, '').replace('/', '-')}-${version}.tgz`;
}
function inspect(file, name, version) {
const manifest = JSON.parse(archiveRead(file, 'package.json'));
assert.equal(manifest.name, name, `Unexpected package in ${file}`);
assert.equal(manifest.version, version, `Unexpected version in ${file}`);
assert(!manifest.private && !manifest.scripts, `${name}: publish only generated packages without lifecycle scripts.`);
assert.equal(manifest.repository?.url, project.repository.url, `${name}: repository mismatch.`);
assert.deepEqual(manifest.publishConfig, { access: 'public', registry }, `${name}: publishConfig mismatch.`);
const result = JSON.parse(npm(['pack', file, '--dry-run', '--json', '--ignore-scripts']));
// npm 12 returns a name-keyed object; npm 11 and earlier return an array.
const entries = Array.isArray(result) ? result : Object.values(result);
assert.equal(entries.length, 1, `Expected one package in ${file}`);
const [packed] = entries;
const integrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(file)).digest('base64')}`;
assert.equal(packed.integrity, integrity, `${name}: tarball integrity mismatch.`);
return { file, name, version, integrity, manifest, files: packed.files };
}
function collectPackages(directory, version) {
const targets = Object.keys(platforms);
assert.deepEqual(fs.readdirSync(directory).sort(), targets.map((target) => `npm-${target}`).sort(),
'Expected exactly five npm platform artifact directories from the same workflow run.');
const natives = [];
const launchers = [];
const dependencies = Object.fromEntries(Object.values(platforms).map(({ packageName }) => [packageName, version]));
for (const [target, platform] of Object.entries(platforms)) {
const folder = path.join(directory, `npm-${target}`);
const names = [filename(platform.packageName, version), filename(project.name, version)];
assert.deepEqual(fs.readdirSync(folder).sort(), [...names].sort(), `${target}: expected native and entry tarballs.`);
const native = inspect(path.join(folder, names[0]), platform.packageName, version);
assert.deepEqual(native.manifest.os, [platform.os]);
assert.deepEqual(native.manifest.cpu, [platform.cpu]);
assert.deepEqual(native.manifest.libc, platform.libc ? [platform.libc] : undefined);
assert(!native.manifest.bin, `${target}: native packages must not register commands.`);
const binary = `bin/crossdesk-cli${platform.os === 'win32' ? '.exe' : ''}`;
assert(native.files.some((file) => file.path === binary && (platform.os === 'win32' || (file.mode & 0o111))),
`${target}: missing executable binary.`);
const info = JSON.parse(archiveRead(native.file, 'build-info.json'));
assert.equal(info.version, version);
assert.equal(info.target, target);
assert.equal(info.sha256, crypto.createHash('sha256').update(archiveRead(native.file, binary, null)).digest('hex'),
`${target}: native binary checksum mismatch.`);
natives.push(native);
const launcher = inspect(path.join(folder, names[1]), project.name, version);
assert.deepEqual(launcher.manifest.optionalDependencies, dependencies, `${target}: platform dependencies mismatch.`);
assert.deepEqual(launcher.manifest.bin, { 'crossdesk-cli': 'npm/cli.cjs' });
assert.deepEqual(JSON.parse(archiveRead(launcher.file, 'npm/platforms.json')), platforms);
launchers.push({ target, ...launcher });
}
// Pick a Unix entry so the shebang and executable mode work on Unix as well.
const entry = launchers.find(({ target }) => target === 'linux-x64');
assert(entry.files.some((file) => file.path === 'npm/cli.cjs' && (file.mode & 0o111)), 'Entry launcher is not executable.');
const contents = Object.fromEntries(entry.files.map(({ path: member }) =>
[member, archiveRead(entry.file, member).replace(/\r\n/g, '\n')]));
for (const launcher of launchers) {
assert.deepEqual(launcher.files.map((file) => file.path).sort(), Object.keys(contents).sort(),
`${launcher.target}: entry package contents differ.`);
for (const [member, content] of Object.entries(contents)) {
assert.equal(archiveRead(launcher.file, member).replace(/\r\n/g, '\n'), content,
`${launcher.target}: entry file differs: ${member}`);
}
}
return [...natives, entry];
}
async function readRegistry(name) {
const response = await fetch(`${registry}${encodeURIComponent(name)}`, {
headers: { accept: 'application/json', 'cache-control': 'no-cache' },
signal: AbortSignal.timeout(30000),
});
if (response.status === 404) return null;
assert(response.ok, `${name}: registry returned HTTP ${response.status}; refusing to treat it as a missing package.`);
return response.json();
}
function checkPublished(pkg, document) {
const published = document?.versions?.[pkg.version];
if (!published) return false;
assert.equal(published.dist?.integrity, pkg.integrity,
`${pkg.name}@${pkg.version} already exists with different content. Bump the version; do not overwrite it.`);
return true;
}
async function waitForPackages(packages, tag) {
const deadline = Date.now() + 15 * 60 * 1000;
let pending = [...packages];
while (pending.length) {
const remaining = [];
for (const pkg of pending) {
const document = await readRegistry(pkg.name);
if (!checkPublished(pkg, document) || document['dist-tags']?.[tag] !== pkg.version) remaining.push(pkg);
}
if (!remaining.length) return;
assert(Date.now() < deadline, `npm is still processing: ${remaining.map((pkg) => pkg.name).join(', ')}. Wait, then re-run the failed job with the same artifacts.`);
console.log(`Waiting for npm versions and ${tag} tags: ${remaining.map((pkg) => pkg.name).join(', ')}`);
pending = remaining;
await sleep(15000);
}
}
async function publishPackages(packages, { tag, dryRun }) {
const existing = new Set();
// Preflight every package before making any registry changes.
for (const pkg of packages) {
if (dryRun) continue;
const document = await readRegistry(pkg.name);
const current = document?.['dist-tags']?.[tag];
assert(!current || compareVersions(current, pkg.version) <= 0,
`${pkg.name}: ${tag} already points to newer ${current}; refusing to roll it back.`);
if (checkPublished(pkg, document)) {
assert.equal(current, pkg.version, `${pkg.name}: existing version matches but ${tag} does not. Review dist-tags manually before retrying.`);
existing.add(pkg.name);
} else {
const versions = Object.keys(document?.versions || {});
assert(!(versions.length === 1 && versions[0] === '0.0.0-stage'),
`${pkg.name}: npm still has a staging placeholder. Wait for processing before retrying; do not upload it again.`);
}
}
const publish = (pkg) => {
if (dryRun) {
// collectPackages already ran npm pack --dry-run and inspected the archive.
// npm publish --dry-run still exchanges OIDC credentials and rejects
// existing versions, so it cannot be used before trust is configured.
console.log(`Validated tarball without publishing: ${pkg.name}@${pkg.version} (${tag})`);
return;
}
if (existing.has(pkg.name)) {
console.log(`Already published with matching integrity: ${pkg.name}@${pkg.version}`);
return;
}
console.log(`Publishing: ${pkg.name}@${pkg.version} (${tag})`);
npm(['publish', pkg.file, '--access', 'public', '--tag', tag, '--ignore-scripts',
`--registry=${registry}`, '--fetch-retries=0', '--fetch-timeout=120000',
'--provenance'], { stdio: 'inherit' });
};
const native = packages.slice(0, -1);
for (const pkg of native) publish(pkg);
if (!dryRun) await waitForPackages(native, tag);
const entry = packages.at(-1);
publish(entry);
if (!dryRun) await waitForPackages([entry], tag);
const report = `${dryRun ? 'Validated (no packages published)' : 'Published and verified'} CLI ${entry.version} with npm tag ${tag}.\n\n` +
'| Package | Integrity (SHA-512) |\n| --- | --- |\n' + packages.map((pkg) => `| ${pkg.name} | ${pkg.integrity} |`).join('\n') + '\n';
console.log(report);
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report);
}
async function main() {
const [command, directory, ...extra] = process.argv.slice(2);
assert((command === 'plan' && !directory) || (command === 'publish' && directory && !extra.length),
'Usage: release-npm.cjs plan | publish ARTIFACT_DIRECTORY (set RELEASE_DRY_RUN=true or false)');
const plan = releasePlan();
console.log(`CLI ${plan.version}, npm tag ${plan.tag}, dry run: ${plan.dryRun}`);
if (command === 'plan') {
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
`version=${plan.version}\ntag=${plan.tag}\ndry_run=${plan.dryRun}\n`);
return;
}
await publishPackages(collectPackages(path.resolve(directory), plan.version), plan);
}
if (require.main === module) main().catch((error) => {
console.error(`release:npm: ${error.message}`);
process.exitCode = 1;
});
module.exports = { releasePlan, compareVersions, collectPackages, publishPackages };