angular/vscode-ng-language-service/tools/release.mts
Alan Agius cda013c60f
build: improve vscode-ng-language-service release process
This commit improves the release process for the vscode-ng-language-service by adding automated changelog generation and updating the package.json version.
2025-11-06 10:53:09 -08:00

393 lines
14 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
/**
* @fileoverview
* This script orchestrates the release process for the Angular Language Service VSCode extension.
* It handles versioning, changelog generation, building, and publishing the extension.
*/
// tslint:disable:no-console
import {input} from '@inquirer/prompts';
import chalk from 'chalk';
import semver from 'semver';
import {writeFile, readFile} from 'node:fs/promises';
import {exec as nodeExec, spawn, SpawnOptions} from 'node:child_process';
import {promisify} from 'node:util';
import {join} from 'node:path';
const exec = promisify(nodeExec);
/** Additional Bazel arguments for release builds to ensure colored output and display progress. */
const additionBazelReleaseArgs = [
'--color=yes',
'--curses=yes',
'--show_progress_rate_limit=5',
] as const;
/** The absolute path to the repository root. */
const rootPath = join(import.meta.dirname, '../../');
/** The prefix for all release tags created by this script. */
const tagPrefix = 'vsix-';
/** The path to the `package.json` file for the extension. */
const packageJsonPath = 'vscode-ng-language-service/package.json';
/** The path to the `CHANGELOG.md` file for the extension. */
const changelogPath = 'vscode-ng-language-service/CHANGELOG.md';
/** The remote URL for the angular/angular repository. */
const angularRepoRemote = 'https://github.com/angular/angular.git';
/**
* The prefix for all release commits created by this script. This is used to filter commits when
* determining the last release.
*/
const releaseCommitPrefix = 'release: bump VSCode extension version to ';
/** The path to the packaged VSCode extension file. */
const extensionPath = join(rootPath, 'dist/bin/vscode-ng-language-service/ng-template.vsix');
/**
* Orchestrates the release process of the VSCode extension.
*
* This function ensures that the user has a clean working directory, determines the new version
* number, creates a release branch, generates the changelog, builds the extension, and prepares a
* pull request for the release.
*/
async function main(): Promise<void> {
process.chdir(rootPath);
// Ensure the user has a clean working directory before starting the release process.
await checkCleanWorkingDirectory();
let branchToReleaseFrom: string | undefined = process.env['BRANCH_TO_RELEASE'];
if (!branchToReleaseFrom) {
const {stdout: currentBranch} = await exec(`git rev-parse --abbrev-ref HEAD`);
branchToReleaseFrom = currentBranch.trim();
}
if (branchToReleaseFrom !== 'main' && !/^\d+\.\d+\.x$/.test(branchToReleaseFrom)) {
throw new Error(`Cannot release from non releasable branch ${branchToReleaseFrom}.`);
}
console.log(chalk.blue(`Releasing from ${branchToReleaseFrom}.`));
await exec(`git fetch ${angularRepoRemote} ${branchToReleaseFrom}`);
const currentVersion = await getCurrentVersion();
const newVersion = await getNewVersion(currentVersion);
const releaseBranch = await createReleaseBranch(newVersion);
await generateChangelog(currentVersion, newVersion);
await updatingPackageJsonVersion(newVersion);
await installDependencies();
await buildExtension();
await prepareReleasePullRequest(newVersion, releaseBranch);
await waitForPRToBeMergedAndTag(newVersion, branchToReleaseFrom);
await publishExtension();
console.log(chalk.green('VSCode extension release process complete!'));
}
/**
* Creates a new release branch for the given version.
*
* The branch will be named `vscode-release-<newVersion>`.
*
* @param newVersion The version number for which to create a release branch.
* @returns A promise that resolves to the name of the newly created branch.
*/
async function createReleaseBranch(newVersion: string): Promise<string> {
console.log(chalk.blue('Creating release branch...'));
console.log('');
const releaseBranch = `vscode-release-${newVersion}`;
await exec(`git branch -D ${releaseBranch}`).catch(() => {});
await exec(`git checkout -b ${releaseBranch} FETCH_HEAD`);
return releaseBranch;
}
/**
* Checks that the working directory is clean.
*
* If the working directory is not clean, an error is thrown.
*/
async function checkCleanWorkingDirectory(): Promise<void> {
const {stdout: status} = await exec('git status --porcelain');
if (status.length > 0) {
throw new Error('Your working directory is not clean. There are uncommitted changes.');
}
}
/**
* Gets the SHA of the last release commit.
*
* This is used to determine which commits to include in the changelog.
*
* @param version The version to look for in the release commit message. If empty, it finds the most recent release commit.
* @returns A promise that resolves to the SHA of the last release commit.
*/
async function getLastReleaseSha(version = ''): Promise<string> {
const commitMessagePattern = releaseCommitPrefix + version;
let {stdout: sha} = await exec(
`git log FETCH_HEAD --grep="${commitMessagePattern}" --format=format:%H -n 1`,
);
sha = sha.trim();
if (!sha) {
throw new Error(`Could not find commit that matches pattern: "${commitMessagePattern}"`);
}
return sha;
}
/**
* Prompts the user for the new version number.
*
* The current version is read from the `package.json` file and a patch release is suggested. The
* user is then prompted to enter the new version number. The input is validated to ensure that it
* is a valid semantic version and that it is greater than the current version.
*
* @param currentVersion The current extension version.
*
* @returns A promise that resolves to the new version string.
*/
async function getNewVersion(currentVersion: string): Promise<string> {
const suggestedVersion = semver.inc(currentVersion, 'patch') ?? currentVersion;
const newVersion = await input({
message: 'Enter the new version number',
default: suggestedVersion,
validate: (value) => {
if (!semver.valid(value)) {
return chalk.red('Please enter a valid version number.');
}
if (semver.lte(value, currentVersion)) {
return chalk.red(
`Please enter a version number greater than the current version (${currentVersion}).`,
);
}
return true;
},
});
console.log(chalk.green(`New version set to: ${newVersion}`));
return newVersion;
}
/**
* Reads the current version from the `package.json` file.
* @returns A promise that resolves to the current version string.
*/
async function getCurrentVersion(): Promise<string> {
const manifest = JSON.parse(await readFile(packageJsonPath, 'utf-8'));
return manifest.version;
}
/**
* Creates the release commit and pushes the release branch to the user's fork.
* This function stages the `package.json` and `CHANGELOG.md` files, creates a commit with a
* standardized release message, and pushes the release branch to the `origin` remote. It then
* provides a URL to create a pull request.
*
* @param newVersion The new version number to include in the commit message.
* @param releaseBranch The name of the release branch to push.
*/
async function prepareReleasePullRequest(newVersion: string, releaseBranch: string): Promise<void> {
await exec(
`git commit -m "${releaseCommitPrefix}${newVersion}" "${packageJsonPath}" "${changelogPath}"`,
);
await exec(`git push origin ${releaseBranch} --force-with-lease`);
const {stdout: remoteUrl} = await exec('git remote get-url origin');
const match = remoteUrl.trim().match(/github\.com[/:]([\w-]+)\/([\w-]+)/);
const originUser = match ? match[1] : 'angular';
const originRepo = match ? match[2] : 'angular';
console.log(
chalk.yellow(
`Please create a pull request by visiting: https://github.com/${originUser}/${originRepo}/pull/new/${releaseBranch}`,
),
);
}
/**
* Generates the changelog for the new version.
*
* This function gets all commits between the last release and the current `HEAD`, filters them
* to include only those that are relevant for the changelog, and then prepends them to the
* `CHANGELOG.md` file.
*
* @param fromVersion The version to generate the changelog from.
* @param toVersion The version to generate the changelog for.
*/
async function generateChangelog(fromVersion: string, toVersion: string): Promise<void> {
let {stdout: commits} = await exec(
`git log --left-only FETCH_HEAD...${tagPrefix}${fromVersion} -E ` +
'--grep="^(feat|fix|perf)\\((vscode-extension|language-server|language-service)\\):" ' +
'--format="format:- %s (%h)[https://github.com/angular/angular/commit/%H]"',
);
commits = commits.trim();
const now = new Date();
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const newChangelogEntry = `## ${toVersion} (${date})\n\n${commits}\n\n`;
const changelogContents = await readFile(changelogPath, 'utf-8');
await writeFile(changelogPath, newChangelogEntry + changelogContents);
console.log(chalk.yellow(`Please review the changes the changelog here: ${changelogPath}`));
await input({
message: 'Please press Enter to proceed.',
});
await exec(`pnpm ng-dev format "${changelogPath}"`);
}
/**
* Updates the `version` in the `package.json` file.
*
* @param newVersion The new version number to set in `package.json`.
*/
async function updatingPackageJsonVersion(newVersion: string): Promise<void> {
const manifest = JSON.parse(await readFile(packageJsonPath, 'utf-8'));
manifest.version = newVersion;
await writeFile(packageJsonPath, JSON.stringify(manifest, undefined, 2));
}
/**
* Waits for the release PR to be merged and then tags the merged commit.
*
* This function prompts the user to confirm that the release PR has been merged. Once confirmed,
* it fetches the latest changes from the upstream repository, finds the SHA of the merged release
* commit, and then creates a Git tag with the format `vsix-<newVersion>` on that commit. Finally,
* it pushes the new tag to the origin.
*
* @param newVersion The new version number used to create the Git tag.
* @param branchToReleaseFrom The branch that the release PR was merged into.
*/
async function waitForPRToBeMergedAndTag(
newVersion: string,
branchToReleaseFrom: string,
): Promise<void> {
console.log('');
console.log('Waiting for release PR to be merged...');
await input({
message: 'Press Enter once the release PR has been merged.',
});
await exec(`git fetch ${angularRepoRemote} ${branchToReleaseFrom}`);
const mergedCommitSha = await getLastReleaseSha(newVersion);
console.log(chalk.green(`Tagging the commit: ${mergedCommitSha}`));
const tagName = `${tagPrefix}${newVersion}`;
await exec(`git tag ${tagName} ${mergedCommitSha}`);
await exec(`git push ${angularRepoRemote} tag ${tagName}`);
console.log(chalk.green('Release tag pushed to origin.'));
}
/**
* Installs the project's dependencies.
*
* This function executes `pnpm install --frozen-lockfile` to ensure that all necessary
* dependencies are installed before building the extension.
*/
async function installDependencies(): Promise<void> {
console.log('');
console.log(chalk.blue(`Installing dependencies...`));
await execAndStream('pnpm', ['install', '--frozen-lockfile']);
console.log(chalk.green('Successfully installed dependencies.'));
}
/**
* Builds the VSCode extension.
*
* This function first cleans the Bazel output and then executes the `pnpm --filter=ng-template run
* package` command with additional Bazel release arguments to build and package the VSCode
* extension.
*/
async function buildExtension(): Promise<void> {
console.log('');
console.log(chalk.blue('Building VSCode extension...'));
await execAndStream('pnpm', ['bazel', 'clean']);
await execAndStream('pnpm', [
'pnpm --filter=ng-template run package',
...additionBazelReleaseArgs,
]);
console.log(chalk.green(`VSCode extension packaged at ${extensionPath}`));
}
/**
* Provides instructions for manually publishing the VSCode extension.
*
* This function is a placeholder for future automation. Currently, it guides the user through the
* manual steps of publishing the extension to the marketplace, including logging in with `vsce`
* and executing the publish command.
*/
async function publishExtension(): Promise<void> {
console.log(chalk.yellow('Publishing the extension to the market place.'));
console.log(`VSIX path: ${extensionPath}`);
console.log(`Please get a PAT token from: http://go/secret-tunnel/1575675884599726`);
console.log('');
await execAndStream('pnpm', ['--filter="ng-template"', 'vsce', 'login', 'Angular']);
console.log('');
console.log(chalk.blue('Publishing extension'));
await execAndStream('pnpm', [
'--filter="ng-template"',
'vsce',
'publish',
`--packagePath="${extensionPath}"`,
]);
}
/**
* Executes a command and streams its stdout and stderr.
*
* This function spawns a child process to execute the given command with the provided arguments.
* The stdout and stderr of the child process are inherited by the current process, allowing for
* real-time output. The function returns a promise that resolves when the command exits with a
* zero exit code, and rejects otherwise.
*
* @param command The command to execute.
* @param args The arguments to pass to the command.
* @param options The options to pass to `spawn`.
* @returns A promise that resolves when the command has finished successfully.
*/
function execAndStream(command: string, args: string[], options: SpawnOptions = {}): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(`${command} ${args.join(' ')}`, [], {
...options,
stdio: 'inherit',
shell: true,
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command "${command} ${args.join(' ')}" failed with exit code ${code}`));
}
});
child.on('error', reject);
});
}
// Start the release process.
main().catch((err) => {
console.error(chalk.red(err));
process.exit(1);
});