angular/vscode-ng-language-service/tools/release.mts
2026-01-14 07:56:38 -08:00

667 lines
22 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, select} from '@inquirer/prompts';
import chalk from 'chalk';
import semver from 'semver';
import {writeFile, readFile} from 'node:fs/promises';
import {exec as nodeExec, spawn, type 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');
/** The marker used to split the changelog between releases. */
const CHANGELOG_RELEASE_MARKER = '<!-- CHANGELOG SPLIT MARKER -->';
/**
* 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();
// Ensure there is a github token before starting the release process.
ensureGithubToken();
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} --tags`);
const currentVersion = await getCurrentVersion();
const newVersion = await getNewVersion(currentVersion);
const releaseBranch = await createReleaseBranch(newVersion);
const changelog = await generateChangelog(currentVersion, newVersion);
await updatingPackageJsonVersion(newVersion);
await installDependencies();
await buildExtension();
const forkRemote = await getForkRemoteName();
await prepareReleasePullRequest(
releaseBranch,
`${releaseCommitPrefix}${newVersion}`,
[packageJsonPath, changelogPath],
forkRemote,
);
await waitForPRToBeMergedAndTag(newVersion, branchToReleaseFrom);
await publishExtension();
await createGithubRelease(newVersion, changelog);
if (branchToReleaseFrom !== 'main') {
await cherryPickChangelog(changelog, newVersion, forkRemote);
}
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 a commit and pushes the branch to the user's fork.
*
* @param branch The name of the branch to push.
* @param commitMessage The commit message.
* @param files The files to commit.
*/
async function prepareReleasePullRequest(
branch: string,
commitMessage: string,
files: string[],
forkRemote: string,
): Promise<void> {
await exec(`git commit -m "${commitMessage}" "${files.join('" "')}"`);
await exec(`git push ${forkRemote} ${branch} --force-with-lease`);
const {stdout: remoteUrl} = await exec(`git remote get-url ${forkRemote}`);
const {owner, repo} = getRepoDetails(remoteUrl);
console.log(
chalk.yellow(
`Please create a pull request by visiting: https://github.com/${owner}/${repo}/pull/new/${branch}`,
),
);
}
/**
* 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<string> {
const previousTag = await getPreviousTag(fromVersion);
// Get all subjects from the previous release to filter out duplicates (cherry-picks).
const {stdout: existingSubjectsOutput} = await exec(
`git log ${previousTag} -E ` +
'--grep="^(feat|fix|perf)\\((vscode-extension|language-server|language-service)\\):" ' +
'--format="%s"',
);
const existingSubjects = new Set(
existingSubjectsOutput
.trim()
.split('\n')
.map((s) => s.trim()),
);
const {stdout: newCommitsRaw} = await exec(
`git log --left-only FETCH_HEAD...${previousTag} -E ` +
'--grep="^(feat|fix|perf)\\((vscode-extension|language-server|language-service)\\):" ' +
'--format="%s|%h|%H"',
);
const commits = newCommitsRaw
.trim()
.split('\n')
.filter((line) => {
if (!line) return false;
const [subject, shortHash, hash] = line.split('|');
return !existingSubjects.has(subject.trim());
})
.map((line) => {
const [subject, shortHash, hash] = line.split('|');
return `- ${subject} ([${shortHash}](https://github.com/angular/angular/commit/${hash}))`;
})
.join('\n');
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}`;
const changelogContents = await readFile(changelogPath, 'utf-8');
await writeFile(
changelogPath,
[newChangelogEntry, CHANGELOG_RELEASE_MARKER, changelogContents].join('\n\n'),
);
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}"`);
// Read the formatted changelog from disk to get the correct content for the release.
const formattedChangelog = await readFile(changelogPath, 'utf-8');
return formattedChangelog.split(CHANGELOG_RELEASE_MARKER)[0].trim();
}
/**
* 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(
chalk.yellow(`
=======================
⚠️ ACTION REQUIRED ⚠️
=======================
Before merging the PR, you should install the extension at: ${extensionPath} and test it.
Once you press Enter, the process will tag and publish automatically.
`),
);
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 = getTagName(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}`));
}
function ensureGithubToken(): string {
// https://github.com/angular/dev-infra/blob/8ce8257f740613a7291256173e2706fb2ed8aefa/ng-dev/utils/git/github-yargs.ts#L45
const token = process.env['GITHUB_TOKEN'] ?? process.env['TOKEN'];
if (!token) {
throw new Error(
'GITHUB_TOKEN nor TOKEN environment variable is not set. Cannot create GitHub release.',
);
}
return token;
}
/**
* Creates a GitHub release and uploads the extension asset.
*
* @param version The version of the release.
* @param changelog The changelog content for the release.
*/
async function createGithubRelease(version: string, changelog: string): Promise<void> {
const token = ensureGithubToken();
console.log(chalk.blue('Creating GitHub release...'));
const commonHeaders = {
'Authorization': `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
};
const {owner, repo} = getRepoDetails(angularRepoRemote);
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`, {
method: 'POST',
headers: {
...commonHeaders,
'Content-Type': 'application/json',
},
body: JSON.stringify({
tag_name: getTagName(version),
name: `VSCode Extension: ${version}`,
body: changelog
// Remove the version header from the changelog as it is already in the release title.
.replace(/## .*? \(\d{4}-\d{2}-\d{2}\)/, '')
.trim(),
make_latest: 'false',
prerelease: false,
}),
});
if (!response.ok) {
throw new Error(`Failed to create release: ${response.statusText} ${await response.text()}`);
}
const release = (await response.json()) as {upload_url: string};
const uploadUrl = release.upload_url.replace(
'{?name,label}',
`?name=ng-template-${version}.vsix`,
);
const vsixContent = await readFile(extensionPath);
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
...commonHeaders,
'Content-Type': 'application/zip',
'Content-Length': vsixContent.length.toString(),
},
body: vsixContent,
});
if (!uploadResponse.ok) {
throw new Error(
`Failed to upload asset: ${uploadResponse.statusText} ${await uploadResponse.text()}`,
);
}
console.log(chalk.green('GitHub release created and asset uploaded.'));
}
/**
* 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`);
// NOTE: `vsce publish` will prompt for login if the user is not already authenticated.
console.log('');
console.log(chalk.blue('Publishing extension'));
await execAndStream('pnpm', [
'--filter="ng-template"',
'vsce',
'publish',
`--packagePath="${extensionPath}"`,
]);
}
/**
* Cherry-picks the changelog changes to the main branch.
*
* @param changelog The changelog content to add.
* @param newVersion The new version number.
*/
async function cherryPickChangelog(
changelog: string,
newVersion: string,
forkRemote: string,
): Promise<void> {
console.log(chalk.blue('Cherry-picking changelog to main...'));
await exec(`git stash`);
await exec(`git fetch ${angularRepoRemote} main`);
const cherryPickBranch = `vscode-changelog-cherry-pick${newVersion}`;
await exec(`git branch -D ${cherryPickBranch}`).catch(() => {});
await exec(`git checkout -b ${cherryPickBranch} FETCH_HEAD`);
const changelogContents = await readFile(changelogPath, 'utf-8');
await writeFile(
changelogPath,
[changelog, CHANGELOG_RELEASE_MARKER, changelogContents].join('\n\n'),
);
await prepareReleasePullRequest(
cherryPickBranch,
`docs: release notes for the vscode extension ${newVersion} release`,
[changelogPath],
forkRemote,
);
}
/**
* 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);
});
}
/**
* Gets the owner and repo name from a remote URL.
*
* @param remoteUrl The remote URL to parse.
* @returns An object containing the owner and repo name.
*/
function getRepoDetails(remoteUrl: string): {owner: string; repo: string} {
const match = remoteUrl.trim().match(/github\.com[/:]([\w-]+)\/([\w-]+)/);
return {
owner: match ? match[1] : 'angular',
repo: match ? match[2] : 'angular',
};
}
/**
* Gets the previous tag to version from.
*
* It checks if the tag for the `currentVersion` exists. If it does, returning it.
* If not, it finds the latest tag that adheres to the semver versioning of the extension.
*/
async function getPreviousTag(currentVersion: string): Promise<string> {
const currentTag = getTagName(currentVersion);
try {
await exec(`git rev-parse "${currentTag}"`);
return currentTag;
} catch {
// Tag does not exist.
}
const {stdout: tags} = await exec(`git tag --list "${tagPrefix}*"`);
const versions = tags
.trim()
.split('\n')
.map((t) => t.trim())
.filter((t) => t.startsWith(tagPrefix))
.map((t) => t.slice(tagPrefix.length))
.filter((v) => semver.valid(v));
if (versions.length === 0) {
throw new Error('No previous release tags found.');
}
// Sort versions in descending order
versions.sort((a, b) => semver.rcompare(a, b));
return getTagName(versions[0]);
}
/**
* Gets the tag name for the given version.
*
* @param version The version to generate the tag name for.
* @returns The tag name.
*/
function getTagName(version: string): string {
return `${tagPrefix}${version}`;
}
/**
* Gets the name of the remote to use as the user's fork.
*
* This function lists all configured remotes and attempts to identify the user's fork.
* - If 'origin' exists and is not the upstream angular repo, it is used.
* - If there are other candidates (remotes that are not the upstream angular repo),
* it asks the user to select one if there are multiple.
*
* @returns The name of the remote to use.
*/
async function getForkRemoteName(): Promise<string> {
const {stdout} = await exec('git remote -v');
const remotes = new Map<string, string>();
for (const line of stdout.split('\n')) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const [name, url] = parts;
remotes.set(name, url);
}
}
const candidates: string[] = [];
for (const [name, url] of remotes) {
if (getRepoDetails(url).owner !== 'angular') {
candidates.push(name);
}
}
// If origin is a candidate, we prefer it appropriately IF it's likely the user's fork.
// The check `getRepoDetails(url).owner !== 'angular'` already filters out upstream.
// So if `origin` is in candidates, it's safe to use?
// User wanted: "If origin exists and is a candidate ... return 'origin'".
if (candidates.includes('origin')) {
return 'origin';
}
if (candidates.length === 0) {
throw new Error('No suitable fork remote found. Please add a remote for your fork.');
}
if (candidates.length === 1) {
return candidates[0];
}
return await select({
message: 'Which remote should be used as your fork (to push the release commit to for the PR)?',
choices: candidates.map((c) => ({value: c})),
});
}
// Start the release process.
main().catch((err) => {
console.error(chalk.red(err));
process.exit(1);
});