tmagic-editor/scripts/release.mjs
2025-01-21 19:13:01 +08:00

405 lines
12 KiB
JavaScript

// @ts-check
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import enquirer from 'enquirer';
import execa from 'execa';
import minimist from 'minimist';
import pico from 'picocolors';
import semver from 'semver';
/**
* @typedef {{
* name: string
* version: string
* dependencies?: { [dependenciesPackageName: string]: string }
* peerDependencies?: { [peerDependenciesPackageName: string]: string }
* }} Package
*/
let versionUpdated = false;
const { prompt } = enquirer;
const currentVersion = createRequire(import.meta.url)('../package.json').version;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const args = minimist(process.argv.slice(2), {
alias: {
skipBuild: 'skip-build',
skipTests: 'skip-tests',
skipGit: 'skip-git',
skipPrompts: 'skip-prompts',
},
});
const preId = args.preid || semver.prerelease(currentVersion)?.[0];
const isDryRun = args.dry;
/** @type {boolean | undefined} */
// eslint-disable-next-line prefer-destructuring
let skipTests = args.skipTests;
const { skipBuild } = args;
const skipPrompts = args.skipPrompts || args.canary;
const skipGit = args.skipGit || args.canary;
const packages = fs.readdirSync(path.resolve(__dirname, '../packages')).filter((p) => {
const pkgRoot = path.resolve(__dirname, '../packages', p);
if (fs.statSync(pkgRoot).isDirectory()) {
const pkg = JSON.parse(fs.readFileSync(path.resolve(pkgRoot, 'package.json'), 'utf-8'));
return !pkg.private;
}
});
const keepThePackageName = (/** @type {string} */ pkgName) => pkgName;
/** @type {string[]} */
const skippedPackages = [];
/** @type {ReadonlyArray<import('semver').ReleaseType>} */
const versionIncrements = [
'patch',
'minor',
'major',
...(preId ? /** @type {const} */ (['prepatch', 'preminor', 'premajor', 'prerelease']) : []),
];
const inc = (/** @type {import('semver').ReleaseType} */ i) => semver.inc(currentVersion, i, preId);
const run = async (
/** @type {string} */ bin,
/** @type {ReadonlyArray<string>} */ args,
/** @type {import('execa').Options} */ opts = {},
) => execa(bin, args, { stdio: 'inherit', ...opts });
const dryRun = async (
/** @type {string} */ bin,
/** @type {ReadonlyArray<string>} */ args,
/** @type {import('execa').Options} */ opts = {},
) => console.log(pico.blue(`[dryrun] ${bin} ${args.join(' ')}`), opts);
const runIfNotDry = isDryRun ? dryRun : run;
const getPkgRoot = (/** @type {string} */ pkg) => path.resolve(__dirname, `../packages/${pkg}`);
const getRunTimeRoot = (pkg) => path.resolve(__dirname, `../runtime/${pkg}`);
const getPlayground = () => path.resolve(__dirname, `../playground`);
const step = (/** @type {string} */ msg) => console.log(pico.cyan(msg));
async function main() {
if (!(await isInSyncWithRemote())) {
return;
}
console.log(`${pico.green(``)} commit is up-to-date with remote.\n`);
let targetVersion = args._[0];
if (!targetVersion) {
// no explicit version, offer suggestions
/** @type {{ release: string }} */
const { release } = await prompt({
type: 'select',
name: 'release',
message: 'Select release type',
choices: versionIncrements.map((i) => `${i} (${inc(i)})`).concat(['custom']),
});
if (release === 'custom') {
/** @type {{ version: string }} */
const result = await prompt({
type: 'input',
name: 'version',
message: 'Input custom version',
initial: currentVersion,
});
targetVersion = result.version;
} else {
targetVersion = release.match(/\((.*)\)/)?.[1] ?? '';
}
}
if (!semver.valid(targetVersion)) {
throw new Error(`invalid target version: ${targetVersion}`);
}
if (skipPrompts) {
step(`Releasing v${targetVersion}...`);
} else {
/** @type {{ yes: boolean }} */
const { yes: confirmRelease } = await prompt({
type: 'confirm',
name: 'yes',
message: `Releasing v${targetVersion}. Confirm?`,
});
if (!confirmRelease) {
return;
}
}
if (!skipTests) {
step('Checking CI status for HEAD...');
const isCIPassed = await getCIResult();
skipTests ||= isCIPassed;
if (isCIPassed && !skipPrompts) {
/** @type {{ yes: boolean }} */
const { yes: promptSkipTests } = await prompt({
type: 'confirm',
name: 'yes',
message: `CI for this commit passed. Skip local tests?`,
});
skipTests = promptSkipTests;
}
}
if (!skipTests) {
step('\nRunning tests...');
if (!isDryRun) {
await run('pnpm', ['run', 'test', '--run']);
} else {
console.log(`Skipped (dry run)`);
}
} else {
step('Tests skipped.');
}
// update all package versions and inter-dependencies
step('\nUpdating cross dependencies...');
updateVersions(targetVersion);
versionUpdated = true;
// build all packages with types
step('\nBuilding all packages...');
if (!skipBuild && !isDryRun) {
await run('pnpm', ['run', 'build']);
} else {
console.log(`(skipped)`);
}
// generate changelog
step('\nGenerating changelog...');
await run(`pnpm`, ['run', 'changelog']);
if (!skipPrompts) {
/** @type {{ yes: boolean }} */
const { yes: changelogOk } = await prompt({
type: 'confirm',
name: 'yes',
message: `Changelog generated. Does it look good?`,
});
if (!changelogOk) {
return;
}
}
if (!skipGit) {
const { stdout } = await run('git', ['diff'], { stdio: 'pipe' });
if (stdout) {
step('\nCommitting changes...');
await runIfNotDry('git', ['add', '-A']);
await runIfNotDry('git', ['commit', '-m', `chore: release v${targetVersion}`, '--verify']);
} else {
console.log('No changes to commit.');
}
}
// publish packages
step('\nPublishing packages...');
const additionalPublishFlags = [];
if (isDryRun) {
additionalPublishFlags.push('--dry-run');
}
if (isDryRun || skipGit) {
additionalPublishFlags.push('--no-git-checks');
}
// bypass the pnpm --publish-branch restriction which isn't too useful to us
// otherwise it leads to a prompt and blocks the release script
const branch = await getBranch();
if (branch !== 'master') {
additionalPublishFlags.push('--publish-branch', branch);
}
for (const pkg of packages) {
await publishPackage(pkg, targetVersion, additionalPublishFlags);
}
// update pnpm-lock.yaml
step('\nUpdating lockfile...');
await run(`pnpm`, ['install', '--prefer-offline']);
if (!skipGit) {
const { stdout } = await run('git', ['diff'], { stdio: 'pipe' });
if (stdout) {
step('\nCommitting changes...');
await runIfNotDry('git', ['add', '-A']);
await runIfNotDry('git', ['commit', '-m', `chore: update lockfile v${targetVersion}`, '--verify']);
} else {
console.log('No changes to commit.');
}
}
// push to GitHub
if (!skipGit) {
step('\nPushing to GitHub...');
await runIfNotDry('git', ['tag', `v${targetVersion}`]);
await runIfNotDry('git', ['push', 'origin', `refs/tags/v${targetVersion}`]);
await runIfNotDry('git', ['push']);
}
if (isDryRun) {
console.log(`\nDry run finished - run git diff to see package changes.`);
}
if (skippedPackages.length) {
console.log(
pico.yellow(`The following packages are skipped and NOT published:\n- ${skippedPackages.join('\n- ')}`),
);
}
console.log();
}
async function getCIResult() {
try {
const sha = await getSha();
const res = await fetch(
`https://api.github.com/repos/vuejs/core/actions/runs?head_sha=${sha}` +
`&status=success&exclude_pull_requests=true`,
);
const data = await res.json();
return data.workflow_runs.length > 0;
} catch {
console.error('Failed to get CI status for current commit.');
return false;
}
}
async function isInSyncWithRemote() {
try {
const branch = await getBranch();
const res = await fetch(`https://api.github.com/repos/vuejs/core/commits/${branch}?per_page=1`);
const data = await res.json();
if (data.sha === (await getSha())) {
return true;
}
/** @type {{ yes: boolean }} */
const { yes } = await prompt({
type: 'confirm',
name: 'yes',
message: pico.red(`Local HEAD is not up-to-date with remote. Are you sure you want to continue?`),
});
return yes;
} catch {
console.error(pico.red('Failed to check whether local HEAD is up-to-date with remote.'));
return false;
}
}
async function getSha() {
return (await execa('git', ['rev-parse', 'HEAD'])).stdout;
}
async function getBranch() {
return (await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout;
}
/**
* @param {string} version
* @param {(pkgName: string) => string} getNewPackageName
*/
function updateVersions(version, getNewPackageName = keepThePackageName) {
// 1. update root package.json
updatePackage(path.resolve(__dirname, '..'), version, getNewPackageName);
// 2. update all packages
packages.forEach((p) => updatePackage(getPkgRoot(p), version, getNewPackageName));
['vue3', 'react', 'vue2'].forEach((p) => updatePackage(getRunTimeRoot(p), version, getNewPackageName, true));
updatePackage(getPlayground(), version, getNewPackageName, true);
}
/**
* @param {string} pkgRoot
* @param {string} version
* @param {(pkgName: string) => string} getNewPackageName
*/
function updatePackage(pkgRoot, version, getNewPackageName, updateDep = false) {
const pkgPath = path.resolve(pkgRoot, 'package.json');
/** @type {Package} */
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
pkg.name = getNewPackageName(pkg.name);
pkg.version = version;
if (updateDep) {
updateDeps(pkg, 'dependencies', version);
updateDeps(pkg, 'peerDependencies', version);
updateDeps(pkg, 'devDependencies', version);
}
fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
}
/**
* @param {Package} pkg
* @param {'dependencies' | 'peerDependencies' | 'devDependencies'} depType
* @param {string} version
*/
function updateDeps(pkg, depType, version) {
const deps = pkg[depType];
if (!deps) return;
Object.keys(deps).forEach((dep) => {
if (dep.startsWith('@tmagic') && packages.includes(dep.replace(/^@tmagic\//, ''))) {
console.log(pico.yellow(`${pkg.name} -> ${depType} -> ${dep}@${version}`));
deps[dep] = version;
}
});
}
/**
* @param {string} pkgName
* @param {string} version
* @param {ReadonlyArray<string>} additionalFlags
*/
async function publishPackage(pkgName, version, additionalFlags) {
if (skippedPackages.includes(pkgName)) {
return;
}
let releaseTag = null;
if (args.tag) {
releaseTag = args.tag;
} else if (version.includes('alpha')) {
releaseTag = 'alpha';
} else if (version.includes('beta')) {
releaseTag = 'beta';
} else if (version.includes('rc')) {
releaseTag = 'rc';
}
step(`Publishing ${pkgName}...`);
try {
// Don't change the package manager here as we rely on pnpm to handle
// workspace:* deps
await run(
'pnpm',
['publish', ...(releaseTag ? ['--tag', releaseTag] : []), '--access', 'public', ...additionalFlags],
{
cwd: getPkgRoot(pkgName),
stdio: 'pipe',
},
);
console.log(pico.green(`Successfully published ${pkgName}@${version}`));
} catch (/** @type {any} */ e) {
if (e.stderr.match(/previously published/)) {
console.log(pico.red(`Skipping already published: ${pkgName}`));
} else {
throw e;
}
}
}
main().catch((err) => {
if (versionUpdated) {
// revert to current version on failed releases
updateVersions(currentVersion);
}
console.error(err);
process.exit(1);
});