diff --git a/ng-dev/release/cli.ts b/ng-dev/release/cli.ts index 87a52ba5d7..cf09dc054d 100644 --- a/ng-dev/release/cli.ts +++ b/ng-dev/release/cli.ts @@ -12,6 +12,7 @@ import {ReleaseInfoCommandModule} from './info/cli.js'; import {ReleaseNotesCommandModule} from './notes/cli.js'; import {ReleasePrecheckCommandModule} from './precheck/cli.js'; import {ReleasePublishCommandModule} from './publish/cli.js'; +import {ReleasePublishCiCommandModule} from './publish/cli-ci.js'; import {ReleasePublishSnapshotsCommandModule} from './snapshot-publish/cli.js'; import {BuildEnvStampCommand} from './stamping/cli.js'; import {ReleaseNpmDistTagCommand} from './npm-dist-tag/cli.js'; @@ -23,6 +24,7 @@ export function buildReleaseParser(localYargs: Argv) { .strict() .demandCommand() .command(ReleasePublishCommandModule) + .command(ReleasePublishCiCommandModule) .command(ReleaseBuildCommandModule) .command(ReleaseInfoCommandModule) .command(ReleaseNpmDistTagCommand) diff --git a/ng-dev/release/publish/actions-error.ts b/ng-dev/release/publish/actions-error.ts index e901a078ee..4f65011343 100644 --- a/ng-dev/release/publish/actions-error.ts +++ b/ng-dev/release/publish/actions-error.ts @@ -6,8 +6,17 @@ * found in the LICENSE file at https://angular.io/license */ +import type {PullRequest} from './actions.js'; + /** Error that will be thrown if the user manually aborted a release action. */ export class UserAbortedReleaseActionError extends Error {} /** Error that will be thrown if the action has been aborted due to a fatal error. */ export class FatalReleaseActionError extends Error {} + +/** Error that will be thrown if the stage-only phase is completed successfully. */ +export class StageOnlySuccessError extends Error { + constructor(public pullRequest: PullRequest) { + super('Stage-only phase completed successfully.'); + } +} diff --git a/ng-dev/release/publish/actions.ts b/ng-dev/release/publish/actions.ts index 3b0f671817..20195972ae 100644 --- a/ng-dev/release/publish/actions.ts +++ b/ng-dev/release/publish/actions.ts @@ -29,7 +29,11 @@ import {ActiveReleaseTrains} from '../versioning/active-release-trains.js'; import {createExperimentalSemver} from '../versioning/experimental-versions.js'; import {NpmCommand} from '../versioning/npm-command.js'; import {getReleaseTagForVersion} from '../versioning/version-tags.js'; -import {FatalReleaseActionError, UserAbortedReleaseActionError} from './actions-error.js'; +import { + FatalReleaseActionError, + StageOnlySuccessError, + UserAbortedReleaseActionError, +} from './actions-error.js'; import { analyzeAndExtendBuiltPackagesWithInfo, assertIntegrityOfBuiltPackages, @@ -78,7 +82,15 @@ export interface ReleaseActionConstructor; /** Constructs a release action. */ - new (...args: [ActiveReleaseTrains, AuthenticatedGitClient, ReleaseConfig, string]): T; + new ( + ...args: [ + active: ActiveReleaseTrains, + git: AuthenticatedGitClient, + config: ReleaseConfig, + projectDir: string, + stageOnly?: boolean, + ] + ): T; } /** @@ -106,6 +118,7 @@ export abstract class ReleaseAction { protected git: AuthenticatedGitClient, protected config: ReleaseConfig, protected projectDir: string, + protected stageOnly = false, ) {} /** @@ -519,6 +532,18 @@ export abstract class ReleaseAction { Log.info(green(' ✓ Release staging pull request has been created.')); + if (this.stageOnly) { + await Promise.all( + builtPackagesWithInfo.map(async (pkg) => { + if (existsSync(pkg.outputPath)) { + await fs.rm(pkg.outputPath, {recursive: true, force: true}); + Log.info(`Cleaned up built package directory: ${pkg.outputPath}`); + } + }), + ); + throw new StageOnlySuccessError(pullRequest); + } + return {releaseNotes, pullRequest, builtPackagesWithInfo}; } diff --git a/ng-dev/release/publish/cli-ci.ts b/ng-dev/release/publish/cli-ci.ts new file mode 100644 index 0000000000..549073ab3f --- /dev/null +++ b/ng-dev/release/publish/cli-ci.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Argv, Arguments, CommandModule} from 'yargs'; + +import {assertValidGithubConfig, getConfig} from '../../utils/config.js'; +import {addGithubTokenOption} from '../../utils/git/github-yargs.js'; +import {assertValidReleaseConfig} from '../config/index.js'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client.js'; +import {PublishCiTool} from './index-ci.js'; +import {green, Log} from '../../utils/logging.js'; + +export interface ReleasePublishCiOptions { + builtPackagesDir: string; + expectedSha: string; + dryRun?: boolean; +} + +function builder(argv: Argv): Argv { + return addGithubTokenOption(argv) + .option('built-packages-dir' as 'builtPackagesDir', { + type: 'string', + demandOption: true, + description: 'Path to the directory containing pre-built packages.', + }) + .option('expected-sha' as 'expectedSha', { + type: 'string', + demandOption: true, + description: 'The expected Git SHA of the release commit.', + }) + .option('dry-run' as 'dryRun', { + type: 'boolean', + default: false, + description: + 'Run the publish command in dry-run mode, skipping tag/release creation and NPM publishing.', + }); +} + +async function handler(flags: Arguments) { + const git = await AuthenticatedGitClient.get(); + const config = await getConfig(); + assertValidReleaseConfig(config); + assertValidGithubConfig(config); + + const tool = new PublishCiTool(config, git, git.baseDir, flags); + + try { + await tool.run(); + Log.info(green('Release CI publish completed successfully.')); + } catch (e) { + if (e instanceof Error) { + Log.error(`Release CI publish failed: ${e.message}`); + if (e.stack) { + Log.debug(e.stack); + } + } else { + Log.error(`Release CI publish failed with unknown error: ${e}`); + } + process.exitCode = 1; + } +} + +export const ReleasePublishCiCommandModule: CommandModule<{}, ReleasePublishCiOptions> = { + builder, + handler, + command: 'publish-ci', + describe: 'Publish a release from CI.', +}; diff --git a/ng-dev/release/publish/cli.ts b/ng-dev/release/publish/cli.ts index b6945bd660..3ba0d574d7 100644 --- a/ng-dev/release/publish/cli.ts +++ b/ng-dev/release/publish/cli.ts @@ -21,9 +21,16 @@ export interface ReleasePublishOptions extends ReleaseToolFlags {} /** Yargs command builder for configuring the `ng-dev release publish` command. */ function builder(argv: Argv): Argv { - return addGithubTokenOption(argv).option('publishRegistry', { - type: 'string', - }); + return addGithubTokenOption(argv) + .option('publishRegistry', { + type: 'string', + }) + .option('stage-only', { + type: 'boolean', + default: false, + description: + 'Only stage the release (bump version, generate changelog, build, precheck, create PR) and exit.', + }); } /** Yargs command handler for staging a release. */ diff --git a/ng-dev/release/publish/index-ci.ts b/ng-dev/release/publish/index-ci.ts new file mode 100644 index 0000000000..dd26038e8b --- /dev/null +++ b/ng-dev/release/publish/index-ci.ts @@ -0,0 +1,334 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {join} from 'path'; +import {readdirSync, statSync, readFileSync, existsSync, writeFileSync, rmSync} from 'fs'; +import semver from 'semver'; +import {ReleaseConfig, BuiltPackage} from '../config/index.js'; +import {GithubConfig, NgDevConfig} from '../../utils/config.js'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client.js'; +import {ReleaseNotes, workspaceRelativeChangelogPath} from '../notes/release-notes.js'; +import {NpmCommand} from '../versioning/npm-command.js'; +import {getFileContentsUrl} from '../../utils/git/github-urls.js'; +import {isGithubApiError} from '../../utils/git/github.js'; +import {githubReleaseBodyLimit} from './constants.js'; +import {green, Log} from '../../utils/logging.js'; +import {fetchLongTermSupportBranchesFromNpm} from '../versioning/long-term-support.js'; +import {ActiveReleaseTrains} from '../versioning/active-release-trains.js'; +import {NpmDistTag} from '../versioning/npm-registry.js'; + +export interface PublishCiToolOptions { + builtPackagesDir: string; + expectedSha: string; + dryRun?: boolean; +} + +export class PublishCiTool { + constructor( + protected config: NgDevConfig<{release: ReleaseConfig; github: GithubConfig}>, + protected git: AuthenticatedGitClient, + protected projectDir: string, + protected options: PublishCiToolOptions, + ) {} + + async run() { + if (!this.options.dryRun && !process.env['WOMBOT_TOKEN']) { + throw new Error('WOMBOT_TOKEN environment variable is not defined.'); + } + + // Assert SHA: Verify git rev-parse HEAD matches expectedSha. + const headSha = this.git.run(['rev-parse', 'HEAD']).stdout.trim(); + if (headSha !== this.options.expectedSha) { + throw new Error(`Expected HEAD SHA to be ${this.options.expectedSha}, but got ${headSha}.`); + } + + // Resolve and verify built packages exist before proceeding with any release actions (tags, releases) + const builtPackages = findBuiltPackages(this.options.builtPackagesDir); + if (builtPackages.length === 0) { + throw new Error(`No built packages found under directory ${this.options.builtPackagesDir}`); + } + + // Determine beforeStagingSha + const parentsOutput = this.git.run(['show', '--format=%P', '-s', 'HEAD']).stdout.trim(); + const parents = parentsOutput ? parentsOutput.split(' ') : []; + let beforeStagingSha: string; + if (parents.length >= 2) { + const stagingCommitSha = parents[1]; + const stagingCommitParentsOutput = this.git + .run(['show', '--format=%P', '-s', stagingCommitSha]) + .stdout.trim(); + const stagingCommitParents = stagingCommitParentsOutput + ? stagingCommitParentsOutput.split(' ') + : []; + if (stagingCommitParents.length === 0) { + throw new Error(`Could not find parent for staging commit ${stagingCommitSha}`); + } + beforeStagingSha = stagingCommitParents[0]; + } else if (parents.length === 1) { + beforeStagingSha = parents[0]; + } else { + throw new Error('HEAD commit has no parents.'); + } + + // Parse Versions + const newVersion = readPackageJsonAtRef(this.git, 'HEAD').version; + const versionAtBeforeStaging = readPackageJsonAtRef(this.git, beforeStagingSha).version; + + const newSemver = semver.parse(newVersion); + if (!newSemver) { + throw new Error(`Failed to parse version ${newVersion} as semver.`); + } + const versionAtBeforeStagingSemver = semver.parse(versionAtBeforeStaging); + if (!versionAtBeforeStagingSemver) { + throw new Error(`Failed to parse version ${versionAtBeforeStaging} as semver.`); + } + + // Resolve previousVersionTag + let previousVersionTag: string; + if (newSemver.prerelease.length === 0 && versionAtBeforeStagingSemver.prerelease.length > 0) { + // Stable release compared to prerelease. We must find the previous stable version. + const tagsOutput = this.git.run(['tag', '-l', 'v*']).stdout.trim(); + const tags = tagsOutput ? tagsOutput.split('\n').map((t) => t.trim()) : []; + let highestStableVersion: semver.SemVer | null = null; + for (const tag of tags) { + const versionStr = tag.startsWith('v') ? tag.slice(1) : tag; + const parsed = semver.parse(versionStr); + if (parsed && parsed.prerelease.length === 0) { + if (semver.lt(parsed, newSemver)) { + if (highestStableVersion === null || semver.gt(parsed, highestStableVersion)) { + highestStableVersion = parsed; + } + } + } + } + if (highestStableVersion === null) { + throw new Error( + `Could not find a previous stable version tag matching v* less than ${newVersion}`, + ); + } + previousVersionTag = `v${highestStableVersion.format()}`; + } else { + previousVersionTag = `v${versionAtBeforeStagingSemver.format()}`; + } + + // Generate Release Notes + const releaseNotes = await ReleaseNotes.forRange( + this.git, + newSemver, + previousVersionTag, + beforeStagingSha, + ); + + // Determine NPM dist tag + const npmDistTag = await determineNpmDistTag(newSemver, this.config.release, this.git); + + // Create GitHub Release and Tags (Idempotent) + const globalTagName = `v${newVersion}`; + if (this.options.dryRun) { + Log.info(`[Dry-Run] Would tag global tag: ${globalTagName}`); + } else { + try { + await this.git.github.git.createRef({ + ...this.git.remoteParams, + ref: `refs/tags/${globalTagName}`, + sha: this.options.expectedSha, + }); + Log.info(green(` ✓ Tagged ${globalTagName} release upstream.`)); + } catch (e) { + if (isGithubApiError(e) && e.status === 422) { + Log.warn(`Warning: Tag ${globalTagName} already exists, skipping tag creation.`); + } else { + throw e; + } + } + } + + let releaseBody = await releaseNotes.getGithubReleaseEntry(); + if (releaseBody.length > githubReleaseBodyLimit) { + const baseUrl = getFileContentsUrl(this.git, globalTagName, workspaceRelativeChangelogPath); + const urlFragment = await releaseNotes.getUrlFragmentForRelease(); + const releaseNotesUrl = `${baseUrl}#${urlFragment}`; + releaseBody = + `Release notes are too large to be captured here. ` + + `[View all changes here](${releaseNotesUrl}).`; + } + + if (this.options.dryRun) { + Log.info(`[Dry-Run] Would create GitHub Release for tag: ${globalTagName}`); + } else { + try { + await this.git.github.repos.createRelease({ + ...this.git.remoteParams, + name: globalTagName, + tag_name: globalTagName, + prerelease: newSemver.prerelease.length > 0, + make_latest: npmDistTag === 'latest' ? 'true' : 'false', + body: releaseBody, + }); + Log.info(green(` ✓ Created ${globalTagName} release in Github.`)); + } catch (e) { + if (isGithubApiError(e) && e.status === 422) { + Log.warn( + `Warning: GitHub release for ${globalTagName} already exists, skipping release creation.`, + ); + } else { + throw e; + } + } + } + + // Monorepo Tags + if (this.config.release.npmPackages.length > 1) { + for (const npmPkg of this.config.release.npmPackages) { + const monorepoTagName = `${npmPkg.name}@${newVersion}`; + if (this.options.dryRun) { + Log.info(`[Dry-Run] Would tag monorepo package: ${monorepoTagName}`); + } else { + try { + await this.git.github.git.createRef({ + ...this.git.remoteParams, + ref: `refs/tags/${monorepoTagName}`, + sha: this.options.expectedSha, + }); + Log.info(green(` ✓ Tagged monorepo package release: ${monorepoTagName}`)); + } catch (e) { + if (isGithubApiError(e) && e.status === 422) { + Log.warn(`Warning: Tag ${monorepoTagName} already exists, skipping tag creation.`); + } else { + throw e; + } + } + } + } + } + + // Publish to NPM/Wombat + + if (this.options.dryRun) { + for (const pkg of builtPackages) { + Log.info(`[Dry-Run] Would write .npmrc and publish package: ${pkg.name} to Wombat`); + } + } else { + const npmrcPath = join(this.projectDir, '.npmrc'); + let originalNpmrc: string | null = null; + if (existsSync(npmrcPath)) { + originalNpmrc = readFileSync(npmrcPath, 'utf8'); + } + + try { + const wombatNpmrcContent = + [ + `registry=https://wombat-dressing-room.appspot.com/`, + `//wombat-dressing-room.appspot.com/:_authToken=\${WOMBOT_TOKEN}`, + ].join('\n') + '\n'; + writeFileSync(npmrcPath, wombatNpmrcContent); + Log.info(green(` ✓ Configured .npmrc to use Wombat registry.`)); + + for (const pkg of builtPackages) { + Log.info(`Publishing "${pkg.name}"...`); + await NpmCommand.publish( + pkg.outputPath, + npmDistTag, + 'https://wombat-dressing-room.appspot.com/', + ); + Log.info(green(` ✓ Successfully published "${pkg.name}".`)); + } + } finally { + if (originalNpmrc !== null) { + writeFileSync(npmrcPath, originalNpmrc); + } else if (existsSync(npmrcPath)) { + rmSync(npmrcPath); + } + } + } + } +} + +function readPackageJsonAtRef(git: AuthenticatedGitClient, ref: string): any { + const content = git.run(['show', `${ref}:package.json`]).stdout.trim(); + return JSON.parse(content); +} + +function findBuiltPackages(dir: string): BuiltPackage[] { + if (!existsSync(dir)) { + throw new Error(`The built packages directory does not exist: ${dir}`); + } + const packages: BuiltPackage[] = []; + const walk = (currentDir: string) => { + let files: string[]; + try { + files = readdirSync(currentDir); + } catch (e) { + return; + } + if (files.includes('package.json')) { + try { + const pkgJson = JSON.parse(readFileSync(join(currentDir, 'package.json'), 'utf8')); + if (pkgJson.name) { + packages.push({ + name: pkgJson.name, + outputPath: currentDir, + }); + return; + } + } catch (e) { + // Ignore parsing errors + } + } + for (const file of files) { + const fullPath = join(currentDir, file); + try { + if (statSync(fullPath).isDirectory()) { + walk(fullPath); + } + } catch (e) { + // Ignore broken symlinks or unreadable files + } + } + }; + walk(dir); + return packages; +} + +async function determineNpmDistTag( + newSemver: semver.SemVer, + config: ReleaseConfig, + git: AuthenticatedGitClient, +): Promise { + const {active: activeLts, inactive: inactiveLts} = + await fetchLongTermSupportBranchesFromNpm(config); + const ltsBranch = [...activeLts, ...inactiveLts].find((b) => b.version.major === newSemver.major); + if (ltsBranch) { + return ltsBranch.npmDistTag; + } + + const repo = { + owner: git.remoteConfig.owner, + name: git.remoteConfig.name, + api: git.github, + nextBranchName: git.mainBranchName, + }; + const activeTrains = await ActiveReleaseTrains.fetch(repo); + + if (newSemver.prerelease.length > 0) { + if ( + activeTrains.exceptionalMinor !== null && + newSemver.major === activeTrains.exceptionalMinor.version.major && + newSemver.minor === activeTrains.exceptionalMinor.version.minor + ) { + return 'do-not-use-exceptional-minor'; + } + return 'next'; + } + + if (newSemver.major > activeTrains.latest.version.major) { + return 'next'; + } + + return 'latest'; +} diff --git a/ng-dev/release/publish/index.ts b/ng-dev/release/publish/index.ts index 4edf470870..047b17bff3 100644 --- a/ng-dev/release/publish/index.ts +++ b/ng-dev/release/publish/index.ts @@ -17,10 +17,14 @@ import {getNextBranchName, ReleaseRepoWithApi} from '../versioning/version-branc import {ReleaseAction} from './actions.js'; import {ExternalCommands} from './external-commands.js'; -import {FatalReleaseActionError, UserAbortedReleaseActionError} from './actions-error.js'; +import { + FatalReleaseActionError, + StageOnlySuccessError, + UserAbortedReleaseActionError, +} from './actions-error.js'; import {actions} from './actions/index.js'; import {verifyNgDevToolIsUpToDate} from '../../utils/version-check.js'; -import {Log, yellow} from '../../utils/logging.js'; +import {green, Log, yellow} from '../../utils/logging.js'; import {Prompt} from '../../utils/prompt.js'; import {setMergeModeRelease} from '../../caretaker/merge-mode/release.js'; @@ -33,6 +37,7 @@ export enum CompletionState { /** A set of flags available to be used to override settings at runtime. */ export interface ReleaseToolFlags { publishRegistry?: string; + stageOnly?: boolean; } export class ReleaseTool { @@ -46,7 +51,7 @@ export class ReleaseTool { config: ReleaseConfig, protected _github: GithubConfig, protected _projectRoot: string, - _flags: ReleaseToolFlags, + protected _flags: ReleaseToolFlags, ) { this._config = { ...config, @@ -99,6 +104,10 @@ export class ReleaseTool { try { await action.perform(); } catch (e) { + if (e instanceof StageOnlySuccessError) { + Log.info(green(`✓ Staging completed successfully. PR URL: ${e.pullRequest.url}`)); + return CompletionState.SUCCESS; + } if (e instanceof UserAbortedReleaseActionError) { return CompletionState.MANUALLY_ABORTED; } @@ -137,6 +146,7 @@ export class ReleaseTool { this._git, this._config, this._projectRoot, + this._flags.stageOnly, ); choices.push({name: await action.getDescription(), value: action}); } diff --git a/ng-dev/release/publish/test/common.spec.ts b/ng-dev/release/publish/test/common.spec.ts index 1281d80758..9ca5a1429d 100644 --- a/ng-dev/release/publish/test/common.spec.ts +++ b/ng-dev/release/publish/test/common.spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {readFileSync, writeFileSync} from 'fs'; +import {existsSync, readFileSync, writeFileSync} from 'fs'; import {join} from 'path'; import semver from 'semver'; @@ -25,6 +25,7 @@ import {ActiveReleaseTrains} from '../../versioning/active-release-trains.js'; import {NpmCommand} from '../../versioning/npm-command.js'; import {ReleaseTrain} from '../../versioning/release-trains.js'; import {actions} from '../actions/index.js'; +import {StageOnlySuccessError} from '../actions-error.js'; import {githubReleaseBodyLimit} from '../constants.js'; import {DelegateTestAction} from './delegate-test-action.js'; import {getTestConfigurationsForAction, testReleasePackages} from './test-utils/action-mocks.js'; @@ -619,6 +620,58 @@ describe('common release action logic', () => { ); }); }); + + describe('stage-only mode', () => { + it('should throw StageOnlySuccessError and clean up output directories', async () => { + const action = setupReleaseActionForTesting(DelegateTestAction, baseReleaseTrains, { + stageOnly: true, + useSandboxGitClient: true, + }); + const {version, branchName} = baseReleaseTrains.next; + const expectedStagingForkBranch = `release-stage-${version.format()}`; + + const git = SandboxGitRepo.withInitialCommit(action.githubConfig).createTagForHead( + '0.0.0-compare-base', + ); + git.commit('feat(test): first commit'); + + // Mock only the staging API requests (exclude merge/publish) + action.repo + .expectBranchRequest(branchName, {sha: 'PRE_STAGING_SHA'}) + .expectCommitStatusCheck('PRE_STAGING_SHA', 'success') + .expectFindForkRequest(action.fork) + .expectPullRequestToBeCreated(branchName, action.fork, expectedStagingForkBranch, 200); + + action.fork.expectBranchRequest(expectedStagingForkBranch); + + // We need to simulate that we have built packages and they exist on disk. + // After staging succeeds, they should be cleaned up. + // Let's create dummy package output files/directories. + for (const pkg of action.builtPackagesWithInfo) { + await writePackageJson(pkg.name, version.format()); + expect(existsSync(pkg.outputPath)).toBe(true); + } + + const stagePromise = action.instance.testStagingWithBuild( + version, + branchName, + parse('0.0.0-compare-base'), + ); + + // Confirm changelog changes + action.promptConfirmSpy.and.returnValue(Promise.resolve(true)); + + await expectAsync(stagePromise).toBeRejectedWithError( + StageOnlySuccessError, + 'Stage-only phase completed successfully.', + ); + + // Verify they are cleaned up + for (const pkg of action.builtPackagesWithInfo) { + expect(existsSync(pkg.outputPath)).toBe(false); + } + }); + }); }); /** Mock class for `ReleaseNotes` which accepts a list of in-memory commit objects. */ diff --git a/ng-dev/release/publish/test/publish-ci.spec.ts b/ng-dev/release/publish/test/publish-ci.spec.ts new file mode 100644 index 0000000000..023882aac4 --- /dev/null +++ b/ng-dev/release/publish/test/publish-ci.spec.ts @@ -0,0 +1,899 @@ +/** + * @license + * Copyright Google LLC + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {PublishCiTool} from '../index-ci.js'; +import {ReleaseConfig} from '../../config/index.js'; +import {GithubConfig, setConfig} from '../../../utils/config.js'; +import { + getMockGitClient, + installSandboxGitClient, + SandboxGitRepo, + testTmpDir, + runGitInTmpDir, +} from '../../../utils/testing/index.js'; +import {prepareTempDirectory} from './test-utils/action-mocks.js'; +import {fakeNpmPackageQueryRequest} from './test-utils/test-utils.js'; +import {ReleaseNotes} from '../../notes/release-notes.js'; +import {NpmCommand} from '../../versioning/npm-command.js'; +import {ActiveReleaseTrains} from '../../versioning/active-release-trains.js'; +import {ReleaseTrain} from '../../versioning/release-trains.js'; +import semver from 'semver'; +import * as fs from 'fs'; +import * as path from 'path'; +import {Log} from '../../../utils/logging.js'; + +class RequestError extends Error { + request = {}; + constructor( + message: string, + public status: number, + ) { + super(message); + this.name = 'RequestError'; + } +} + +describe('PublishCiTool', () => { + let githubConfig: GithubConfig; + let releaseConfig: ReleaseConfig; + let gitClient: any; + let createRefSpy: jasmine.Spy; + let createReleaseSpy: jasmine.Spy; + let publishSpy: jasmine.Spy; + + beforeEach(() => { + prepareTempDirectory(); + githubConfig = { + mergeMode: 'caretaker-only' as any, + owner: 'angular', + name: 'angular', + mainBranchName: 'main', + }; + releaseConfig = { + representativeNpmPackage: '@angular/core', + npmPackages: [{name: '@angular/core'}], + buildPackages: async () => [], + }; + setConfig({github: githubConfig, release: releaseConfig}); + gitClient = getMockGitClient(githubConfig, true); + installSandboxGitClient(gitClient); + + // Populate NPM package info cache to avoid real fetch calls + fakeNpmPackageQueryRequest('@angular/core', {}); + fakeNpmPackageQueryRequest('@angular/common', {}); + + // Mock NpmCommand.publish + publishSpy = spyOn(NpmCommand, 'publish').and.resolveTo(); + + // Mock GitHub API calls using a plain mock object to avoid Proxy issues + createRefSpy = jasmine.createSpy('createRef').and.resolveTo({}); + createReleaseSpy = jasmine.createSpy('createRelease').and.resolveTo({}); + const mockGithub = { + git: { + createRef: createRefSpy, + }, + repos: { + createRelease: createReleaseSpy, + }, + }; + Object.defineProperty(gitClient, 'github', { + value: mockGithub, + writable: true, + configurable: true, + }); + + // Mock ActiveReleaseTrains.fetch + spyOn(ActiveReleaseTrains, 'fetch').and.resolveTo( + new ActiveReleaseTrains({ + exceptionalMinor: null, + releaseCandidate: null, + next: new ReleaseTrain('main', semver.parse('10.1.0-next.0')!), + latest: new ReleaseTrain('10.0.x', semver.parse('10.0.0')!), + }), + ); + }); + + it('should verify HEAD SHA matches expectedSha', async () => { + // Write package.json v10.0.0 + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + + // Get the HEAD SHA of the sandbox repo + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const toolWithMismatchedSha = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir: path.join(testTmpDir, 'dist'), + expectedSha: 'incorrect-sha-12345', + }, + ); + + await expectAsync(toolWithMismatchedSha.run()).toBeRejectedWithError( + `Expected HEAD SHA to be incorrect-sha-12345, but got ${headSha}.`, + ); + + const toolWithCorrectSha = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir: path.join(testTmpDir, 'dist'), + expectedSha: headSha, + }, + ); + + // Mock ReleaseNotes.forRange and other dependencies so it runs successfully + const releaseNotesSpy = spyOn(ReleaseNotes, 'forRange').and.resolveTo({ + getGithubReleaseEntry: async () => 'release notes body', + getUrlFragmentForRelease: async () => 'url-frag', + } as any); + + // Prepare dummy built package output + const builtPackagesDir = path.join(testTmpDir, 'dist'); + const pkgDir = path.join(builtPackagesDir, 'pkg1'); + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({name: '@angular/core'})); + + process.env['WOMBOT_TOKEN'] = 'mock-wombat-token'; + + await expectAsync(toolWithCorrectSha.run()).toBeResolved(); + }); + + describe('fail-fast validation', () => { + let originalWombotToken: string | undefined; + + beforeEach(() => { + originalWombotToken = process.env['WOMBOT_TOKEN']; + delete process.env['WOMBOT_TOKEN']; + }); + + afterEach(() => { + if (originalWombotToken !== undefined) { + process.env['WOMBOT_TOKEN'] = originalWombotToken; + } else { + delete process.env['WOMBOT_TOKEN']; + } + }); + + it('should throw if WOMBOT_TOKEN is missing (dryRun: false) and not call GitHub API', async () => { + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir: path.join(testTmpDir, 'dist'), + expectedSha: headSha, + dryRun: false, + }, + ); + + await expectAsync(tool.run()).toBeRejectedWithError( + 'WOMBOT_TOKEN environment variable is not defined.', + ); + + expect(createRefSpy).not.toHaveBeenCalled(); + expect(createReleaseSpy).not.toHaveBeenCalled(); + }); + + it('should NOT throw if WOMBOT_TOKEN is missing when dryRun is true', async () => { + spyOn(ReleaseNotes, 'forRange').and.resolveTo({ + getGithubReleaseEntry: async () => 'release notes body', + getUrlFragmentForRelease: async () => 'url-frag', + } as any); + + const builtPackagesDir = path.join(testTmpDir, 'dist'); + const pkgDir = path.join(builtPackagesDir, 'pkg1'); + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({name: '@angular/core'})); + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir: builtPackagesDir, + expectedSha: headSha, + dryRun: true, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + }); + + it('should throw if built packages directory does not exist and not call GitHub API', async () => { + process.env['WOMBOT_TOKEN'] = 'mock-wombat-token'; + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const nonExistentDir = path.join(testTmpDir, 'non-existent-dist'); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir: nonExistentDir, + expectedSha: headSha, + }, + ); + + await expectAsync(tool.run()).toBeRejectedWithError( + `The built packages directory does not exist: ${nonExistentDir}`, + ); + + expect(createRefSpy).not.toHaveBeenCalled(); + expect(createReleaseSpy).not.toHaveBeenCalled(); + }); + + it('should throw if no built packages are found and not call GitHub API', async () => { + process.env['WOMBOT_TOKEN'] = 'mock-wombat-token'; + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const emptyDir = path.join(testTmpDir, 'empty-dist'); + fs.mkdirSync(emptyDir, {recursive: true}); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir: emptyDir, + expectedSha: headSha, + }, + ); + + await expectAsync(tool.run()).toBeRejectedWithError( + `No built packages found under directory ${emptyDir}`, + ); + + expect(createRefSpy).not.toHaveBeenCalled(); + expect(createReleaseSpy).not.toHaveBeenCalled(); + }); + }); + + describe('git graph traversal (beforeStagingSha)', () => { + let releaseNotesSpy: jasmine.Spy; + let builtPackagesDir: string; + + beforeEach(() => { + releaseNotesSpy = spyOn(ReleaseNotes, 'forRange').and.resolveTo({ + getGithubReleaseEntry: async () => 'release notes body', + getUrlFragmentForRelease: async () => 'url-frag', + } as any); + + builtPackagesDir = path.join(testTmpDir, 'dist'); + const pkgDir = path.join(builtPackagesDir, 'pkg1'); + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({name: '@angular/core'})); + + process.env['WOMBOT_TOKEN'] = 'mock-wombat-token'; + }); + + it('should resolve beforeStagingSha for standard merge commit', async () => { + // 1. Initial commit (v10.0.0) + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + const mainParentSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + // 2. Tag v10.0.0 + sandbox.createTagForHead('v10.0.0'); + + // 3. Create branch `staging-branch` + sandbox.branchOff('staging-branch'); + + // 4. On `staging-branch`, make commit bumping version to 10.1.0-next.0 and update CHANGELOG.md + fs.writeFileSync( + path.join(testTmpDir, 'package.json'), + JSON.stringify({version: '10.1.0-next.0'}), + ); + fs.writeFileSync(path.join(testTmpDir, 'CHANGELOG.md'), 'changelog contents'); + sandbox.commit('release: bump version to 10.1.0-next.0'); + + // 5. Switch back to main branch + sandbox.switchToBranch('main'); + + // 6. Merge staging-branch into main with a merge commit + runGitInTmpDir(['merge', 'staging-branch', '--no-ff', '-m', 'Merge branch staging-branch']); + const mergeCommitSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: mergeCommitSha, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + + // Assert that ReleaseNotes.forRange was called with beforeStagingSha as baseRef + expect(releaseNotesSpy).toHaveBeenCalledTimes(1); + const args = releaseNotesSpy.calls.mostRecent().args; + expect(args[1]).toEqual(semver.parse('10.1.0-next.0')!); // newVersion + expect(args[2]).toBe('v10.0.0'); // previousVersionTag + expect(args[3]).toBe(mainParentSha); // beforeStagingSha + }); + + it('should resolve beforeStagingSha for squash/rebase commit', async () => { + // 1. Initial commit (v10.0.0) + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + const initialCommitSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + // 2. Create single commit bumping version to 10.1.0-next.0 + fs.writeFileSync( + path.join(testTmpDir, 'package.json'), + JSON.stringify({version: '10.1.0-next.0'}), + ); + sandbox.commit('release: bump version to 10.1.0-next.0'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + + expect(releaseNotesSpy).toHaveBeenCalledTimes(1); + const args = releaseNotesSpy.calls.mostRecent().args; + expect(args[1]).toEqual(semver.parse('10.1.0-next.0')!); // newVersion + expect(args[2]).toBe('v10.0.0'); // previousVersionTag + expect(args[3]).toBe(initialCommitSha); // beforeStagingSha + }); + }); + + describe('previousVersionTag resolution', () => { + let releaseNotesSpy: jasmine.Spy; + let builtPackagesDir: string; + + beforeEach(() => { + releaseNotesSpy = spyOn(ReleaseNotes, 'forRange').and.resolveTo({ + getGithubReleaseEntry: async () => 'release notes body', + getUrlFragmentForRelease: async () => 'url-frag', + } as any); + + builtPackagesDir = path.join(testTmpDir, 'dist'); + const pkgDir = path.join(builtPackagesDir, 'pkg1'); + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({name: '@angular/core'})); + + process.env['WOMBOT_TOKEN'] = 'mock-wombat-token'; + }); + + it('should resolve highest stable tag when transitioning from prerelease to stable', async () => { + // 1. Create tags v9.0.0, v10.0.0, v10.1.0-rc.0 pointing to different commits + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '9.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + sandbox.createTagForHead('v9.0.0'); + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + sandbox.commit('v10.0.0 commit'); + sandbox.createTagForHead('v10.0.0'); + + fs.writeFileSync( + path.join(testTmpDir, 'package.json'), + JSON.stringify({version: '10.1.0-rc.0'}), + ); + sandbox.commit('v10.1.0-rc.0 commit'); + sandbox.createTagForHead('v10.1.0-rc.0'); + + // 2. Set version at HEAD to 10.1.0 (stable) + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.1.0'})); + sandbox.commit('v10.1.0 stable commit'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + + expect(releaseNotesSpy).toHaveBeenCalledTimes(1); + const args = releaseNotesSpy.calls.mostRecent().args; + expect(args[2]).toBe('v10.0.0'); // previousVersionTag (should skip v10.1.0-rc.0 because it's prerelease) + }); + + it('should resolve previous prerelease tag when incrementing prerelease', async () => { + // 1. Set version at beforeStagingSha to 10.1.0-next.0 + fs.writeFileSync( + path.join(testTmpDir, 'package.json'), + JSON.stringify({version: '10.1.0-next.0'}), + ); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + + // 2. Set version at HEAD to 10.1.0-next.1 + fs.writeFileSync( + path.join(testTmpDir, 'package.json'), + JSON.stringify({version: '10.1.0-next.1'}), + ); + sandbox.commit('v10.1.0-next.1 commit'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + + expect(releaseNotesSpy).toHaveBeenCalledTimes(1); + const args = releaseNotesSpy.calls.mostRecent().args; + expect(args[2]).toBe('v10.1.0-next.0'); // previousVersionTag + }); + }); + + describe('GitHub release and tag creation (idempotency)', () => { + let releaseNotesSpy: jasmine.Spy; + let builtPackagesDir: string; + + beforeEach(() => { + releaseNotesSpy = spyOn(ReleaseNotes, 'forRange').and.resolveTo({ + getGithubReleaseEntry: async () => 'release notes body', + getUrlFragmentForRelease: async () => 'url-frag', + } as any); + + builtPackagesDir = path.join(testTmpDir, 'dist'); + const pkgDir = path.join(builtPackagesDir, 'pkg1'); + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({name: '@angular/core'})); + + process.env['WOMBOT_TOKEN'] = 'mock-wombat-token'; + }); + + it('should create tags and releases with correct arguments', async () => { + fs.writeFileSync( + path.join(testTmpDir, 'package.json'), + JSON.stringify({version: '10.1.0-next.0'}), + ); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + + fs.writeFileSync( + path.join(testTmpDir, 'package.json'), + JSON.stringify({version: '10.1.0-next.1'}), + ); + sandbox.commit('v10.1.0-next.1 commit'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + + expect(createRefSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + ref: 'refs/tags/v10.1.0-next.1', + sha: headSha, + }), + ); + expect(createReleaseSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + name: 'v10.1.0-next.1', + tag_name: 'v10.1.0-next.1', + prerelease: true, + make_latest: 'false', + body: 'release notes body', + }), + ); + }); + + it('should proceed to publish even if tag or release already exists', async () => { + fs.writeFileSync( + path.join(testTmpDir, 'package.json'), + JSON.stringify({version: '10.1.0-next.0'}), + ); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + + fs.writeFileSync( + path.join(testTmpDir, 'package.json'), + JSON.stringify({version: '10.1.0-next.1'}), + ); + sandbox.commit('v10.1.0-next.1 commit'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + createRefSpy.and.rejectWith(new RequestError('Reference already exists', 422)); + createReleaseSpy.and.rejectWith(new RequestError('Release already exists', 422)); + + const warnSpy = spyOn(Log, 'warn'); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + + expect(warnSpy).toHaveBeenCalledWith( + jasmine.stringMatching('Tag v10.1.0-next.1 already exists, skipping tag creation.'), + ); + expect(warnSpy).toHaveBeenCalledWith( + jasmine.stringMatching( + 'GitHub release for v10.1.0-next.1 already exists, skipping release creation.', + ), + ); + expect(publishSpy).toHaveBeenCalled(); + }); + }); + + it('should create monorepo tags if there are multiple npm packages configured', async () => { + const monorepoReleaseConfig = { + representativeNpmPackage: '@angular/core', + npmPackages: [{name: '@angular/core'}, {name: '@angular/common'}], + buildPackages: async () => [], + }; + setConfig({github: githubConfig, release: monorepoReleaseConfig}); + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.1.0'})); + sandbox.commit('v10.1.0 commit'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + spyOn(ReleaseNotes, 'forRange').and.resolveTo({ + getGithubReleaseEntry: async () => 'release notes body', + getUrlFragmentForRelease: async () => 'url-frag', + } as any); + + const builtPackagesDir = path.join(testTmpDir, 'dist'); + // We need to create mock directories for both packages so findBuiltPackages works + const pkg1Dir = path.join(builtPackagesDir, 'pkg1'); + const pkg2Dir = path.join(builtPackagesDir, 'pkg2'); + fs.mkdirSync(pkg1Dir, {recursive: true}); + fs.mkdirSync(pkg2Dir, {recursive: true}); + fs.writeFileSync(path.join(pkg1Dir, 'package.json'), JSON.stringify({name: '@angular/core'})); + fs.writeFileSync(path.join(pkg2Dir, 'package.json'), JSON.stringify({name: '@angular/common'})); + + process.env['WOMBOT_TOKEN'] = 'mock-wombat-token'; + + const tool = new PublishCiTool( + {github: githubConfig, release: monorepoReleaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + + expect(createRefSpy).toHaveBeenCalledTimes(3); + expect(createRefSpy.calls.argsFor(0)[0]).toEqual( + jasmine.objectContaining({ + ref: 'refs/tags/v10.1.0', + sha: headSha, + }), + ); + expect(createRefSpy.calls.argsFor(1)[0]).toEqual( + jasmine.objectContaining({ + ref: 'refs/tags/@angular/core@10.1.0', + sha: headSha, + }), + ); + expect(createRefSpy.calls.argsFor(2)[0]).toEqual( + jasmine.objectContaining({ + ref: 'refs/tags/@angular/common@10.1.0', + sha: headSha, + }), + ); + }); + + describe('NPM publishing & Wombat registry setup', () => { + let releaseNotesSpy: jasmine.Spy; + let builtPackagesDir: string; + let npmrcPath: string; + + beforeEach(() => { + releaseNotesSpy = spyOn(ReleaseNotes, 'forRange').and.resolveTo({ + getGithubReleaseEntry: async () => 'release notes body', + getUrlFragmentForRelease: async () => 'url-frag', + } as any); + + builtPackagesDir = path.join(testTmpDir, 'dist'); + npmrcPath = path.join(testTmpDir, '.npmrc'); + + process.env['WOMBOT_TOKEN'] = 'mock-wombat-token'; + }); + + it('should temporarily write wombat registry token to .npmrc and clean up afterwards', async () => { + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.1.0'})); + sandbox.commit('v10.1.0 commit'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const pkg1Dir = path.join(builtPackagesDir, 'pkg1'); + const pkg2Dir = path.join(builtPackagesDir, 'pkg2'); + fs.mkdirSync(pkg1Dir, {recursive: true}); + fs.mkdirSync(pkg2Dir, {recursive: true}); + fs.writeFileSync(path.join(pkg1Dir, 'package.json'), JSON.stringify({name: '@angular/core'})); + fs.writeFileSync( + path.join(pkg2Dir, 'package.json'), + JSON.stringify({name: '@angular/common'}), + ); + + let npmrcContentDuringPublish: string | null = null; + publishSpy.and.callFake(async () => { + if (fs.existsSync(npmrcPath)) { + npmrcContentDuringPublish = fs.readFileSync(npmrcPath, 'utf8'); + } + }); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + }, + ); + + // Verify .npmrc does not exist initially + expect(fs.existsSync(npmrcPath)).toBe(false); + + await expectAsync(tool.run()).toBeResolved(); + + // Verify it was correctly configured during publish + expect(npmrcContentDuringPublish).toContain( + 'registry=https://wombat-dressing-room.appspot.com/', + ); + expect(npmrcContentDuringPublish).toContain( + '//wombat-dressing-room.appspot.com/:_authToken=${WOMBOT_TOKEN}', + ); + + // Verify that original npmrc is restored (since it did not exist, it should be deleted) + expect(fs.existsSync(npmrcPath)).toBe(false); + + // Verify NpmCommand.publish was called for both packages with correct arguments + expect(publishSpy).toHaveBeenCalledTimes(2); + expect(publishSpy.calls.argsFor(0)).toEqual([ + pkg1Dir, + 'latest', + 'https://wombat-dressing-room.appspot.com/', + ]); + expect(publishSpy.calls.argsFor(1)).toEqual([ + pkg2Dir, + 'latest', + 'https://wombat-dressing-room.appspot.com/', + ]); + }); + + it('should restore original .npmrc if it existed beforehand', async () => { + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.1.0'})); + sandbox.commit('v10.1.0 commit'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const pkg1Dir = path.join(builtPackagesDir, 'pkg1'); + fs.mkdirSync(pkg1Dir, {recursive: true}); + fs.writeFileSync(path.join(pkg1Dir, 'package.json'), JSON.stringify({name: '@angular/core'})); + + const originalNpmrcContent = 'registry=https://my-custom-registry.com/\n'; + fs.writeFileSync(npmrcPath, originalNpmrcContent); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + + // Verify that original npmrc is restored + expect(fs.existsSync(npmrcPath)).toBe(true); + expect(fs.readFileSync(npmrcPath, 'utf8')).toBe(originalNpmrcContent); + }); + + it('should restore .npmrc even if publishing fails', async () => { + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.1.0'})); + sandbox.commit('v10.1.0 commit'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const pkg1Dir = path.join(builtPackagesDir, 'pkg1'); + fs.mkdirSync(pkg1Dir, {recursive: true}); + fs.writeFileSync(path.join(pkg1Dir, 'package.json'), JSON.stringify({name: '@angular/core'})); + + const originalNpmrcContent = 'registry=https://my-custom-registry.com/\n'; + fs.writeFileSync(npmrcPath, originalNpmrcContent); + + publishSpy.and.rejectWith(new Error('Npm publish error')); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + }, + ); + + await expectAsync(tool.run()).toBeRejectedWithError('Npm publish error'); + + // Verify that original npmrc is restored + expect(fs.existsSync(npmrcPath)).toBe(true); + expect(fs.readFileSync(npmrcPath, 'utf8')).toBe(originalNpmrcContent); + }); + }); + + describe('dry-run mode', () => { + let releaseNotesSpy: jasmine.Spy; + let builtPackagesDir: string; + let logInfoSpy: jasmine.Spy; + + beforeEach(() => { + releaseNotesSpy = spyOn(ReleaseNotes, 'forRange').and.resolveTo({ + getGithubReleaseEntry: async () => 'release notes body', + getUrlFragmentForRelease: async () => 'url-frag', + } as any); + + builtPackagesDir = path.join(testTmpDir, 'dist'); + const pkgDir = path.join(builtPackagesDir, 'pkg1'); + fs.mkdirSync(pkgDir, {recursive: true}); + fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({name: '@angular/core'})); + + logInfoSpy = spyOn(Log, 'info'); + }); + + it('should skip API calls and log dry-run actions', async () => { + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.1.0'})); + sandbox.commit('v10.1.0 commit'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const tool = new PublishCiTool( + {github: githubConfig, release: releaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + dryRun: true, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + + // Verify no external actions were performed + expect(createRefSpy).not.toHaveBeenCalled(); + expect(createReleaseSpy).not.toHaveBeenCalled(); + expect(publishSpy).not.toHaveBeenCalled(); + + // Verify dry-run logs + expect(logInfoSpy).toHaveBeenCalledWith('[Dry-Run] Would tag global tag: v10.1.0'); + expect(logInfoSpy).toHaveBeenCalledWith( + '[Dry-Run] Would create GitHub Release for tag: v10.1.0', + ); + expect(logInfoSpy).toHaveBeenCalledWith( + '[Dry-Run] Would write .npmrc and publish package: @angular/core to Wombat', + ); + }); + + it('should skip monorepo tag creation and log dry-run actions', async () => { + const monorepoReleaseConfig = { + representativeNpmPackage: '@angular/core', + npmPackages: [{name: '@angular/core'}, {name: '@angular/common'}], + buildPackages: async () => [], + }; + setConfig({github: githubConfig, release: monorepoReleaseConfig}); + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.0.0'})); + const sandbox = SandboxGitRepo.withInitialCommit(githubConfig); + + fs.writeFileSync(path.join(testTmpDir, 'package.json'), JSON.stringify({version: '10.1.0'})); + sandbox.commit('v10.1.0 commit'); + const headSha = gitClient.run(['rev-parse', 'HEAD']).stdout.trim(); + + const pkg1Dir = path.join(builtPackagesDir, 'pkg1'); + const pkg2Dir = path.join(builtPackagesDir, 'pkg2'); + fs.mkdirSync(pkg1Dir, {recursive: true}); + fs.mkdirSync(pkg2Dir, {recursive: true}); + fs.writeFileSync(path.join(pkg1Dir, 'package.json'), JSON.stringify({name: '@angular/core'})); + fs.writeFileSync( + path.join(pkg2Dir, 'package.json'), + JSON.stringify({name: '@angular/common'}), + ); + + const tool = new PublishCiTool( + {github: githubConfig, release: monorepoReleaseConfig} as any, + gitClient, + testTmpDir, + { + builtPackagesDir, + expectedSha: headSha, + dryRun: true, + }, + ); + + await expectAsync(tool.run()).toBeResolved(); + + // Verify no external actions were performed + expect(createRefSpy).not.toHaveBeenCalled(); + expect(createReleaseSpy).not.toHaveBeenCalled(); + expect(publishSpy).not.toHaveBeenCalled(); + + // Verify dry-run logs + expect(logInfoSpy).toHaveBeenCalledWith('[Dry-Run] Would tag global tag: v10.1.0'); + expect(logInfoSpy).toHaveBeenCalledWith( + '[Dry-Run] Would create GitHub Release for tag: v10.1.0', + ); + expect(logInfoSpy).toHaveBeenCalledWith( + '[Dry-Run] Would tag monorepo package: @angular/core@10.1.0', + ); + expect(logInfoSpy).toHaveBeenCalledWith( + '[Dry-Run] Would tag monorepo package: @angular/common@10.1.0', + ); + expect(logInfoSpy).toHaveBeenCalledWith( + '[Dry-Run] Would write .npmrc and publish package: @angular/core to Wombat', + ); + expect(logInfoSpy).toHaveBeenCalledWith( + '[Dry-Run] Would write .npmrc and publish package: @angular/common to Wombat', + ); + }); + }); +}); diff --git a/ng-dev/release/publish/test/test-utils/test-action.ts b/ng-dev/release/publish/test/test-utils/test-action.ts index dede93fe62..cbb56cae65 100644 --- a/ng-dev/release/publish/test/test-utils/test-action.ts +++ b/ng-dev/release/publish/test/test-utils/test-action.ts @@ -39,6 +39,11 @@ export interface TestOptions { * appear as published to NPM or not. */ isExceptionalMinorPublishedToNpm?: boolean; + + /** + * Whether the release action is running in stage-only mode. + */ + stageOnly?: boolean; } /** Type describing the default options. Used for narrowing in generics. */ @@ -47,6 +52,7 @@ export type defaultTestOptionsType = TestOptions & { stubBuiltPackageOutputChecks: true; isNextPublishedToNpm: true; isExceptionalMinorPublishedToNpm: true; + stageOnly: false; }; /** Default options for tests. Need to match with the default options type. */ @@ -55,6 +61,7 @@ export const defaultTestOptions: defaultTestOptionsType = { stubBuiltPackageOutputChecks: true, isNextPublishedToNpm: true, isExceptionalMinorPublishedToNpm: true, + stageOnly: false, }; /** Type describing test options with defaults merged. */ diff --git a/ng-dev/release/publish/test/test-utils/test-utils.ts b/ng-dev/release/publish/test/test-utils/test-utils.ts index de6a2d13a6..508be34879 100644 --- a/ng-dev/release/publish/test/test-utils/test-utils.ts +++ b/ng-dev/release/publish/test/test-utils/test-utils.ts @@ -82,7 +82,13 @@ export function setupReleaseActionForTesting< testOptionsWithDefaults.useSandboxGitClient, ); - const action = new actionCtor(active, gitClient, releaseConfig, projectDir); + const action = new actionCtor( + active, + gitClient, + releaseConfig, + projectDir, + testOptionsWithDefaults.stageOnly, + ); return { instance: action,