import { git, npm, readPackageJSON } from './utils.ts';
let args: ParsedArgs;
try {
args = parseArgs();
validateBranchState(args.releaseBranch);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
console.log('Installing dependencies...');
npm().ci('--ignore-scripts');
console.log('Bumping package version without creating a tag...');
npm().version(...args.npmVersionArgs, '--no-git-tag-version');
console.log('Updating src/version.ts...');
npm().exec(
'--',
'node',
'--experimental-strip-types',
'resources/gen-version.ts',
);
console.log('Running test suite...');
npm().run('test');
const { version } = readPackageJSON();
console.log(`Generating changelog for v${version}...`);
const changelogArgs = ['--silent', 'changelog'];
if (args.fromRev != null) {
changelogArgs.push('--', args.fromRev);
}
const releaseChangelog = npm().runOutput(...changelogArgs);
const releaseCommitTitle = `chore(release): v${version}`;
console.log('Creating release commit...');
git().add('package.json', 'package-lock.json', 'src/version.ts');
git().commit('-m', releaseCommitTitle, '-m', releaseChangelog);
const currentBranch = git({ quiet: true }).revParse('--abbrev-ref', 'HEAD');
console.log('');
console.log(`Release commit created for v${version}.`);
console.log(
`Next steps: push "${currentBranch}", open a PR to "${args.releaseBranch}", wait for CI, then merge.`,
);
interface ParsedArgs {
fromRev: string | null;
releaseBranch: string;
npmVersionArgs: Array<string>;
}
function parseArgs(): ParsedArgs {
const rawArgs = process.argv.slice(2);
const fromRevArgName = '--fromRev';
let fromRev: string | null = null;
let releasePrepareArgs = rawArgs;
if (rawArgs[0] === fromRevArgName) {
fromRev = rawArgs[1] ?? null;
releasePrepareArgs = rawArgs.slice(2);
} else if (rawArgs.includes(fromRevArgName)) {
throwUsage(`${fromRevArgName} must be the first argument when provided.`);
}
const releaseBranch = releasePrepareArgs[0];
if (releaseBranch == null || releaseBranch.trim() === '') {
throwUsage('Missing required release branch as the first argument.');
}
if (releaseBranch.startsWith('-')) {
throwUsage(
'Missing required release branch as the first argument (before options).',
);
}
const npmVersionArgs = releasePrepareArgs.slice(1);
if (npmVersionArgs.length === 0) {
throwUsage(
'Missing npm version arguments (e.g. patch, major, prerelease --preid beta).',
);
}
return {
fromRev,
releaseBranch,
npmVersionArgs,
};
}
function validateBranchState(releaseBranch: string): void {
const checkedBranch = git({ quiet: true }).revParse('--abbrev-ref', 'HEAD');
if (checkedBranch === 'HEAD') {
throw new Error(
'Git is in detached HEAD state (not on a local branch). ' +
'Switch to a local branch based on the release branch first, for example:\n' +
` git switch -c release-${releaseBranch.replace(
/[^a-zA-Z0-9._-]/g,
'-',
)} ${releaseBranch}`,
);
}
if (checkedBranch === releaseBranch) {
throw new Error(
`Release prepare must not run on "${releaseBranch}". Create a local release branch first.`,
);
}
const status = git().status('--porcelain').trim();
if (status !== '') {
throw new Error(
'Working directory must be clean before running release:prepare.',
);
}
const branchStatus = git().status('--porcelain', '--branch');
const branchSummary = branchStatus.split('\n')[0] ?? '';
if (/\[[^\]]+\]/.test(branchSummary)) {
throw new Error(
`Current branch "${checkedBranch}" is not up to date with its upstream.`,
);
}
let releaseBranchHead: string;
try {
releaseBranchHead = git({ quiet: true }).revParse(releaseBranch);
} catch (error) {
throw new Error(
`Release branch "${releaseBranch}" does not exist locally.`,
{
cause: error,
},
);
}
let releaseBranchUpstream: string;
try {
releaseBranchUpstream = git({ quiet: true }).revParse(
'--abbrev-ref',
`${releaseBranch}@{upstream}`,
);
} catch (error) {
throw new Error(
`Release branch "${releaseBranch}" does not track a remote branch. ` +
'Set one first (for example: git branch --set-upstream-to ' +
`<remote>/${releaseBranch} ${releaseBranch}).`,
{ cause: error },
);
}
const upstreamRemote = releaseBranchUpstream.split('/')[0];
try {
git().fetch('--quiet', '--tags', upstreamRemote, releaseBranch);
} catch (error) {
throw new Error(
`Failed to fetch "${releaseBranchUpstream}" and tags from "${upstreamRemote}". ` +
'Check remote access, authentication, git remote configuration, ' +
'and local/remote tag state.',
{ cause: error },
);
}
const upstreamReleaseBranchHead = git({ quiet: true }).revParse(
`${releaseBranch}@{upstream}`,
);
const localOnlyCommits = git().revList(
`${upstreamReleaseBranchHead}..${releaseBranchHead}`,
);
const upstreamOnlyCommits = git().revList(
`${releaseBranchHead}..${upstreamReleaseBranchHead}`,
);
if (localOnlyCommits.length > 0 && upstreamOnlyCommits.length > 0) {
throw new Error(
`Local "${releaseBranch}" has diverged from "${releaseBranchUpstream}". ` +
'Resolve conflicts and synchronize first (for example: ' +
`git switch ${releaseBranch} && git pull --rebase).`,
);
}
if (upstreamOnlyCommits.length > 0) {
throw new Error(
`Local "${releaseBranch}" is behind "${releaseBranchUpstream}". ` +
`Update it first (for example: git switch ${releaseBranch} && git pull --ff-only).`,
);
}
if (localOnlyCommits.length > 0) {
throw new Error(
`Local "${releaseBranch}" is ahead of "${releaseBranchUpstream}". ` +
`Push or reset it before release prepare (for example: git switch ${releaseBranch} && git push).`,
);
}
const currentHead = git({ quiet: true }).revParse('HEAD');
if (currentHead !== releaseBranchHead) {
throw new Error(
`Current branch "${checkedBranch}" must match "${releaseBranch}" before preparing a release.`,
);
}
}
function throwUsage(message: string): never {
throw new Error(
`${message}\n` +
'Usage: npm run release:prepare -- [--fromRev <fromRev>] <release-branch> <npm version args>\n' +
'Examples:\n' +
' npm run release:prepare -- 17.x.x patch\n' +
' npm run release:prepare -- 17.x.x prerelease --preid alpha\n' +
' npm run release:prepare -- 17.x.x prerelease --preid beta\n' +
' npm run release:prepare -- --fromRev <fromRev> 17.x.x prerelease --preid rc',
);
}