mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
6f848db435
Previously, the release script hardcoded the upstream repository URL as an unauthenticated HTTPS URL (https://github.com/angular/angular.git). Although the script verified that a GITHUB_TOKEN environment variable was present, it only used that token for REST API calls (such as creating the GitHub release) and did not provide it to Git commands. As a result, users who authenticate to GitHub via SSH (and do not have an HTTPS Git credential helper configured) were prompted interactively for GitHub login credentials when pushing the release tag. This change dynamically resolves the upstream remote name from the user's configured remotes by checking for any remote pointing to angular/angular. When pushing over SSH, Git uses the user's existing SSH credentials. When pushing over HTTPS (or falling back), the script injects GITHUB_TOKEN into the push URL to prevent interactive authentication prompts.
742 lines
24 KiB
TypeScript
742 lines
24 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 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}.`);
|
|
}
|
|
|
|
const upstreamRemote = await getUpstreamRemoteName();
|
|
const forkRemote = await getForkRemoteName();
|
|
|
|
console.log(chalk.blue(`Using upstream remote: ${upstreamRemote}`));
|
|
console.log(chalk.blue(`Using fork remote: ${forkRemote}`));
|
|
|
|
console.log(chalk.blue(`Releasing from ${branchToReleaseFrom}.`));
|
|
await exec(`git fetch ${upstreamRemote} ${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();
|
|
|
|
await prepareReleasePullRequest(
|
|
releaseBranch,
|
|
`${releaseCommitPrefix}${newVersion}`,
|
|
[packageJsonPath, changelogPath],
|
|
forkRemote,
|
|
);
|
|
await waitForPRToBeMergedAndTag(newVersion, branchToReleaseFrom, upstreamRemote);
|
|
|
|
await publishExtension();
|
|
|
|
await createGithubRelease(newVersion, changelog);
|
|
|
|
if (branchToReleaseFrom !== 'main') {
|
|
await cherryPickChangelog(changelog, newVersion, forkRemote, upstreamRemote);
|
|
}
|
|
|
|
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 upstream repository.
|
|
*
|
|
* @param newVersion The new version number used to create the Git tag.
|
|
* @param branchToReleaseFrom The branch that the release PR was merged into.
|
|
* @param upstreamRemote The remote name or URL used for the upstream repository.
|
|
*/
|
|
async function waitForPRToBeMergedAndTag(
|
|
newVersion: string,
|
|
branchToReleaseFrom: string,
|
|
upstreamRemote: 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 ${upstreamRemote} ${branchToReleaseFrom}`);
|
|
const mergedCommitSha = await getLastReleaseSha(newVersion);
|
|
|
|
console.log(chalk.green(`Tagging the commit: ${mergedCommitSha}`));
|
|
const tagName = getTagName(newVersion);
|
|
await exec(`git tag ${tagName} ${mergedCommitSha}`);
|
|
const pushTarget = await getPushTarget(upstreamRemote);
|
|
await exec(`git push ${pushTarget} 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 githubReleasesApiUrl = 'https://api.github.com/repos/angular/angular/releases';
|
|
|
|
const response = await fetch(githubReleasesApiUrl, {
|
|
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,
|
|
draft: true,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to create release: ${response.statusText} ${await response.text()}`);
|
|
}
|
|
|
|
const release = (await response.json()) as {id: number; 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()}`,
|
|
);
|
|
}
|
|
|
|
const publishResponse = await fetch(`${githubReleasesApiUrl}/${release.id}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
...commonHeaders,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
draft: false,
|
|
make_latest: 'false',
|
|
}),
|
|
});
|
|
|
|
if (!publishResponse.ok) {
|
|
throw new Error(
|
|
`Failed to publish release: ${publishResponse.statusText} ${await publishResponse.text()}`,
|
|
);
|
|
}
|
|
|
|
console.log(chalk.green('GitHub release created, asset uploaded, and release published.'));
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @param forkRemote The remote name for the fork repository.
|
|
* @param upstreamRemote The remote name or URL for the upstream repository.
|
|
*/
|
|
async function cherryPickChangelog(
|
|
changelog: string,
|
|
newVersion: string,
|
|
forkRemote: string,
|
|
upstreamRemote: string,
|
|
): Promise<void> {
|
|
console.log(chalk.blue('Cherry-picking changelog to main...'));
|
|
|
|
await exec(`git stash`);
|
|
await exec(`git fetch ${upstreamRemote} 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 all configured Git remotes as a map from remote name to remote URL.
|
|
*
|
|
* @returns A promise that resolves to a map of remote names to their URLs.
|
|
*/
|
|
async function getRemotes(): Promise<Map<string, 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);
|
|
}
|
|
}
|
|
return remotes;
|
|
}
|
|
|
|
/**
|
|
* 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].toLowerCase() : 'angular',
|
|
repo: match ? match[2].toLowerCase() : '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 remote name or authenticated URL for the upstream angular repository (angular/angular).
|
|
*
|
|
* It checks existing configured remotes:
|
|
* - If any remote points to angular/angular, uses that remote.
|
|
* - If no remote points to angular/angular, falls back to an authenticated HTTPS URL using GITHUB_TOKEN.
|
|
*
|
|
* @returns The remote name or URL to use for upstream.
|
|
*/
|
|
async function getUpstreamRemoteName(): Promise<string> {
|
|
const remotes = await getRemotes();
|
|
for (const [name, url] of remotes) {
|
|
const details = getRepoDetails(url);
|
|
if (details.owner === 'angular' && details.repo === 'angular') {
|
|
return name;
|
|
}
|
|
}
|
|
|
|
const token = ensureGithubToken();
|
|
return `https://${token}@github.com/angular/angular.git`;
|
|
}
|
|
|
|
/**
|
|
* Determines the target to push to for the upstream repository.
|
|
* If the remote URL is HTTPS without embedded credentials, it injects GITHUB_TOKEN to avoid interactive login prompts.
|
|
*/
|
|
async function getPushTarget(upstreamRemote: string): Promise<string> {
|
|
const remotes = await getRemotes();
|
|
const remoteUrl = remotes.get(upstreamRemote) ?? upstreamRemote;
|
|
|
|
if (remoteUrl.startsWith('git@') || remoteUrl.startsWith('ssh://')) {
|
|
return upstreamRemote;
|
|
}
|
|
|
|
const token = ensureGithubToken();
|
|
return `https://${token}@github.com/angular/angular.git`;
|
|
}
|
|
|
|
/**
|
|
* 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 remotes = await getRemotes();
|
|
|
|
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.
|
|
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);
|
|
});
|