From 05df6a25523f5ff7aff517d20a16d619ae2b0125 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 18 Feb 2026 16:03:02 -0800 Subject: [PATCH 1/2] build(dev-infra): add release automation script for zone.js This commit introduces a new release automation script for zone.js, located at packages/zone.js/tools/release.mts. It also updates: - packages/zone.js/package.json: Adds 'release' script. - package.json: Adds 'zonejs:release' script for root access. - packages/zone.js/DEVELOPER.md: Documents the new automated release process. - tools/gulp-tasks/changelog-zonejs.js: Filters changelog to only include 'feat', 'fix', and 'perf' commits. --- package.json | 3 +- packages/zone.js/DEVELOPER.md | 14 + packages/zone.js/package.json | 3 +- packages/zone.js/tools/release.mts | 375 +++++++++++++++++++++++++++ tools/gulp-tasks/changelog-zonejs.js | 2 +- 5 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 packages/zone.js/tools/release.mts diff --git a/package.json b/package.json index 6c13b6e42ca6..973af40be8d7 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "dev:prod": "pnpm --filter=dev-app dev:prod", "dev:build": "pnpm --filter=dev-app dev:build", "benchmarks": "node scripts/benchmarks/index.mts", - "diff-release-package": "node scripts/diff-release-package.mts" + "diff-release-package": "node scripts/diff-release-package.mts", + "zonejs:release": "node packages/zone.js/tools/release.mts" }, "// 1": "dependencies are used locally and by bazel", "dependencies": { diff --git a/packages/zone.js/DEVELOPER.md b/packages/zone.js/DEVELOPER.md index 6e83b6a24b72..8fce2d55e49d 100644 --- a/packages/zone.js/DEVELOPER.md +++ b/packages/zone.js/DEVELOPER.md @@ -70,6 +70,20 @@ Releasing `zone.js` is a two step process. 1. Create a PR which updates the changelog, and get it merged using normal merge process. 2. Once the PR is merged check out the merge SHA of the PR and release `zone.js` from that SHA and tag it. +### Automated Release + +You can use the automated release script which handles both steps (run from the root of the repo): + +```bash +pnpm zonejs:release +``` + +Follow the interactive prompts to either create a PR or cut a release. + +--- + +### Manual Release (Legacy) + #### 1. Creating a PR for release ``` diff --git a/packages/zone.js/package.json b/packages/zone.js/package.json index a8895c4e33cb..a101a18ce6f1 100644 --- a/packages/zone.js/package.json +++ b/packages/zone.js/package.json @@ -36,7 +36,8 @@ "jest:test": "jest --config ./test/jest/jest.config.js", "jest:nodetest": "jest --config ./test/jest/jest.node.config.js", "vitest:test": "vitest ./test/vitest/vitest.spec.js", - "promisefinallytest": "mocha ./test/promise/promise.finally.spec.mjs" + "promisefinallytest": "mocha ./test/promise/promise.finally.spec.mjs", + "release": "node tools/release.mts" }, "repository": { "type": "git", diff --git a/packages/zone.js/tools/release.mts b/packages/zone.js/tools/release.mts new file mode 100644 index 000000000000..8dfa217da5c6 --- /dev/null +++ b/packages/zone.js/tools/release.mts @@ -0,0 +1,375 @@ +/** + * @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 zone.js. + * It handles versioning, changelog generation, building, and publishing. + */ + +// tslint:disable:no-console +import {input, select} from '@inquirer/prompts'; +import chalk from 'chalk'; +import semver from 'semver'; +import {readFile, writeFile} 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); + +/** The absolute path to the repository root. */ +const rootPath = join(import.meta.dirname, '../../..'); + +/** The path to the `package.json` file for zone.js. */ +const packageJsonPath = join(rootPath, 'packages/zone.js/package.json'); + +/** The path to the `CHANGELOG.md` file for zone.js. */ +const changelogPath = join(rootPath, 'packages/zone.js/CHANGELOG.md'); + +async function main(): Promise { + // Ensure we are in the root directory + process.chdir(rootPath); + + const choice = await select({ + message: 'What do you want to do?', + choices: [ + {name: '1. Create a PR for release', value: 'create-pr'}, + {name: '2. Cut a release (publish) - after PR merge', value: 'cut-release'}, + ], + }); + + if (choice === 'create-pr') { + await createPrWorkflow(); + } else { + await cutReleaseWorkflow(); + } +} + +/** + * Workflow for creating a PR for release. + * 1. Clean node_modules and install. + * 2. Determine previous tag. + * 3. Update version in package.json. + * 4. Generate changelog. + * 5. Dry run build. + * 6. Create branch and commit. + * 7. Push and suggest PR. + */ +async function createPrWorkflow(): Promise { + // Ensure the user has a clean working directory. + await checkCleanWorkingDirectory(); + // Ensure there is a github token. + ensureGithubToken(); + + console.log(chalk.blue('Preparing for release PR...')); + + // rm -rf node_modules && pnpm install + await cleanAndInstall(); + + const currentVersion = await getCurrentVersion(); + const previousTag = await getPreviousTag(); + const newVersion = await getNewVersion(currentVersion); + const tagName = `zone.js-${newVersion}`; + + console.log( + chalk.blue(`Releasing zone.js version ${tagName}. Previous release was ${previousTag}.`), + ); + + // Update version in package.json + await updatingPackageJsonVersion(newVersion); + + // Generate changelog + // pnpm gulp changelog:zonejs + console.log(chalk.blue('Generating changelog...')); + await execAndStream('pnpm', ['gulp', 'changelog:zonejs'], { + env: { + ...process.env, + TAG: tagName, + PREVIOUS_ZONE_TAG: previousTag, + }, + }); + + console.log(chalk.yellow(`Please review the changes in ${changelogPath}`)); + await input({ + message: 'Please press Enter to proceed after reviewing the changelog.', + }); + + // Dry run build + console.log(chalk.blue('Running dry run build...')); + await execAndStream('pnpm', [ + 'bazel', + 'build', + '//packages/zone.js:npm_package', + `--workspace_status_command="echo STABLE_PROJECT_VERSION ${newVersion}"`, + ]); + + // Create branch + const releaseBranch = `release_${tagName}-${Date.now()}`; + console.log(chalk.blue(`Creating branch ${releaseBranch}...`)); + await exec(`git checkout -b "${releaseBranch}"`); + + // Add files + await exec(`git add packages/zone.js/CHANGELOG.md packages/zone.js/package.json`); + const commitMessage = `release: cut the ${tagName} release`; + await exec(`git commit -m "${commitMessage}"`); + + // Push + const forkRemote = await getForkRemoteName(); + console.log(chalk.blue(`Pushing to ${forkRemote}...`)); + await exec(`git push ${forkRemote} "${releaseBranch}"`); + + 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/${releaseBranch}`, + ), + ); +} + +/** + * Workflow for cutting a release. + * 1. Checkout upstream/main. + * 2. Find SHA of the release commit. + * 3. Checkout SHA. + * 4. Build. + * 5. Publish. + * 6. Tag and push. + */ +async function cutReleaseWorkflow(): Promise { + await checkCleanWorkingDirectory(); + + console.log(chalk.blue('Fetching upstream...')); + await exec(`git fetch upstream`); + await exec(`git checkout upstream/main`); + + // rm -rf node_modules && pnpm install + await cleanAndInstall(); + + const currentVersion = await getCurrentVersion(); + const tagName = `zone.js-${currentVersion}`; + + console.log(chalk.blue(`Looking for release commit for ${tagName}...`)); + const commitMessagePattern = `release: cut the ${tagName} release`; + const {stdout: sha} = await exec( + `git log upstream/main --oneline -n 1000 | grep "${commitMessagePattern}" | cut -f 1 -d " "`, + ); + + const trimmedSha = sha.trim(); + if (!trimmedSha) { + throw new Error(`Could not find commit with message containing: "${commitMessagePattern}"`); + } + + console.log(chalk.green(`Found release SHA: ${trimmedSha}`)); + console.log(chalk.blue(`Checking out ${trimmedSha}...`)); + await exec(`git checkout ${trimmedSha}`); + + // Build + console.log(chalk.blue('Building for release...')); + await execAndStream('pnpm', [ + 'bazel', + 'build', + '//packages/zone.js:npm_package', + '--config=release', + `--workspace_status_command="echo STABLE_PROJECT_VERSION ${currentVersion}"`, + ]); + + // Publish + console.log(chalk.yellow('Ready to publish.')); + await ensureNpmLogin(); + + await input({ + message: 'Press Enter to publish to npm.', + }); + + const otp = await input({ + message: 'Please enter your 2FA OTP code:', + }); + + await execAndStream('npm', [ + 'publish', + 'dist/bin/packages/zone.js/npm_package', + '--access', + 'public', + '--tag', + 'latest', + '--otp', + otp, + ]); + + // Tag + console.log(chalk.blue(`Tagging ${tagName}...`)); + await exec(`git tag ${tagName} ${trimmedSha}`); + + console.log(chalk.blue(`Pushing tag ${tagName} to upstream...`)); + // Note: pushing to upstream requires permissions. + await exec(`git push upstream ${tagName}`); + + console.log(chalk.green('Zone.js release complete!')); +} + +async function cleanAndInstall() { + console.log(chalk.blue('Cleaning node_modules and installing dependencies...')); + await exec('rm -rf node_modules'); + await execAndStream('pnpm', ['install']); +} + +async function checkCleanWorkingDirectory(): Promise { + 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.'); + } +} + +async function getCurrentVersion(): Promise { + const manifest = JSON.parse(await readFile(packageJsonPath, 'utf-8')); + return manifest.version; +} + +async function getNewVersion(currentVersion: string): Promise { + 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; + }, + }); + return newVersion; +} + +async function getPreviousTag(): Promise { + const {stdout: tags} = await exec(`git tag --list "zone.js-*"`); + const versions = tags + .trim() + .split('\n') + .map((t) => t.trim()) + .filter((t) => t.startsWith('zone.js-')) + .map((t) => t.slice('zone.js-'.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 `zone.js-${versions[0]}`; +} + +async function updatingPackageJsonVersion(newVersion: string): Promise { + const manifest = JSON.parse(await readFile(packageJsonPath, 'utf-8')); + manifest.version = newVersion; + await writeFile(packageJsonPath, JSON.stringify(manifest, undefined, 2) + '\n'); +} + +async function ensureNpmLogin(): Promise { + const registry = 'https://wombat-dressing-room.appspot.com'; + try { + const {stdout: user} = await exec(`npm whoami --registry ${registry}`); + console.log(chalk.green(`Logged in to ${registry} as ${user.trim()}.`)); + } catch (e) { + console.log(chalk.yellow(`Not logged in to ${registry}. Logging in now...`)); + await execAndStream('npm', ['login', '--registry', registry]); + } +} + +function execAndStream(command: string, args: string[], options: SpawnOptions = {}): Promise { + 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); + }); +} + +function ensureGithubToken(): string { + const token = process.env['GITHUB_TOKEN'] ?? process.env['TOKEN']; + if (!token) { + console.warn( + chalk.yellow( + 'GITHUB_TOKEN nor TOKEN environment variable is set. GitHub operations might fail or prompt for credentials.', + ), + ); + return ''; + } + return token; +} + +async function getForkRemoteName(): Promise { + const {stdout} = await exec('git remote -v'); + const remotes = new Map(); + 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 (candidates.includes('origin')) { + return 'origin'; + } + + if (candidates.length === 0) { + // If no fork found, might be directly on upstream or just no fork configured. + // Return origin as fallback. + return 'origin'; + } + + if (candidates.length === 1) { + return candidates[0]; + } + + return await select({ + message: 'Which remote should be used as your fork?', + choices: candidates.map((c) => ({value: c})), + }); +} + +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', + }; +} + +main().catch((err) => { + console.error(chalk.red(err)); + process.exit(1); +}); diff --git a/tools/gulp-tasks/changelog-zonejs.js b/tools/gulp-tasks/changelog-zonejs.js index 9309b2be709c..2c8853e0865c 100644 --- a/tools/gulp-tasks/changelog-zonejs.js +++ b/tools/gulp-tasks/changelog-zonejs.js @@ -24,7 +24,7 @@ module.exports = (gulp) => () => { { // Ignore commits that have a different scope than `zone.js`. extendedRegexp: true, - grep: '^[^(]+\\(zone\\.js\\)', + grep: '^((feat|fix|perf)\\(zone\\.js\\)|revert:.*\\(zone\\.js\\))', from: ptag, to: 'HEAD', }, From c1ef944a440f19a285a75711e1a1e798bb73b571 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 19 Feb 2026 11:30:18 -0800 Subject: [PATCH 2/2] fixup! build(dev-infra): add release automation script for zone.js --- package.json | 2 +- packages/zone.js/tools/release.mts | 48 +++++++++++++++++++----------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 973af40be8d7..79169294976c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "dev:build": "pnpm --filter=dev-app dev:build", "benchmarks": "node scripts/benchmarks/index.mts", "diff-release-package": "node scripts/diff-release-package.mts", - "zonejs:release": "node packages/zone.js/tools/release.mts" + "zonejs:release": "pnpm --filter=zone.js release" }, "// 1": "dependencies are used locally and by bazel", "dependencies": { diff --git a/packages/zone.js/tools/release.mts b/packages/zone.js/tools/release.mts index 8dfa217da5c6..9644161dc00e 100644 --- a/packages/zone.js/tools/release.mts +++ b/packages/zone.js/tools/release.mts @@ -124,14 +124,33 @@ async function createPrWorkflow(): Promise { console.log(chalk.blue(`Pushing to ${forkRemote}...`)); await exec(`git push ${forkRemote} "${releaseBranch}"`); - 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/${releaseBranch}`, + `Please create a pull request by visiting: https://github.com/angular/angular/pull/new/${releaseBranch}`, ), ); + + const continueToPublish = await select({ + message: 'Do you want to continue to the publish step once the PR is merged?', + choices: [ + { + name: 'Yes, I will merge the PR and then continue to publish from here.', + value: true, + }, + { + name: 'No, I will run the publish step separately later.', + value: false, + }, + ], + }); + + if (continueToPublish) { + await input({ + message: + 'Please create the pull request, get it merged, and then press Enter to continue to publish.', + }); + await cutReleaseWorkflow(); + } } /** @@ -147,10 +166,9 @@ async function cutReleaseWorkflow(): Promise { await checkCleanWorkingDirectory(); console.log(chalk.blue('Fetching upstream...')); - await exec(`git fetch upstream`); - await exec(`git checkout upstream/main`); + await exec(`git fetch https://github.com/angular/angular.git main`); + await exec(`git checkout FETCH_HEAD`); - // rm -rf node_modules && pnpm install await cleanAndInstall(); const currentVersion = await getCurrentVersion(); @@ -159,7 +177,7 @@ async function cutReleaseWorkflow(): Promise { console.log(chalk.blue(`Looking for release commit for ${tagName}...`)); const commitMessagePattern = `release: cut the ${tagName} release`; const {stdout: sha} = await exec( - `git log upstream/main --oneline -n 1000 | grep "${commitMessagePattern}" | cut -f 1 -d " "`, + `git log FETCH_HEAD --oneline -n 1000 | grep "${commitMessagePattern}" | cut -f 1 -d " "`, ); const trimmedSha = sha.trim(); @@ -189,10 +207,6 @@ async function cutReleaseWorkflow(): Promise { message: 'Press Enter to publish to npm.', }); - const otp = await input({ - message: 'Please enter your 2FA OTP code:', - }); - await execAndStream('npm', [ 'publish', 'dist/bin/packages/zone.js/npm_package', @@ -200,8 +214,6 @@ async function cutReleaseWorkflow(): Promise { 'public', '--tag', 'latest', - '--otp', - otp, ]); // Tag @@ -210,15 +222,15 @@ async function cutReleaseWorkflow(): Promise { console.log(chalk.blue(`Pushing tag ${tagName} to upstream...`)); // Note: pushing to upstream requires permissions. - await exec(`git push upstream ${tagName}`); + await exec(`git push https://github.com/angular/angular.git ${tagName}`); console.log(chalk.green('Zone.js release complete!')); } async function cleanAndInstall() { - console.log(chalk.blue('Cleaning node_modules and installing dependencies...')); - await exec('rm -rf node_modules'); - await execAndStream('pnpm', ['install']); + console.log(chalk.blue('Cleaning and installing dependencies...')); + await exec('git clean -dxf'); + await execAndStream('pnpm', ['install', , '--frozen-lockfile']); } async function checkCleanWorkingDirectory(): Promise {