From b69ff40479c60f00091d96641eece308febb16cf Mon Sep 17 00:00:00 2001 From: Joey Perrott Date: Wed, 17 Jun 2026 16:52:20 +0000 Subject: [PATCH] feat(ng-dev/release): create recover-ci-publish CLI command - Registered the new command `recover-ci-publish` in `ng-dev/release/cli.ts` and updated `ng-dev/release/BUILD.bazel` to depend on it. - Created `ReleaseRecoverCiPublishTool` to download packages zip from GHA run, unzip it, and invoke `PublishCiTool` with local config enabled. - Created yargs module `cli.ts` to configure positional run-id, dry-run, and registry options. - Extended visibility in `github-actions/release/publish/BUILD.bazel` to allow `ng-dev/release/recover-ci-publish` to import `PublishCiTool`. - Created comprehensive unit tests in `recover-ci-publish.spec.ts`. --- github-actions/release/publish/BUILD.bazel | 5 +- ng-dev/release/BUILD.bazel | 1 + ng-dev/release/cli.ts | 4 +- ng-dev/release/recover-ci-publish/BUILD.bazel | 41 +++ ng-dev/release/recover-ci-publish/cli.ts | 68 ++++ .../recover-ci-publish/recover-ci-publish.ts | 183 ++++++++++ .../test/recover-ci-publish.spec.ts | 316 ++++++++++++++++++ 7 files changed, 616 insertions(+), 2 deletions(-) create mode 100644 ng-dev/release/recover-ci-publish/BUILD.bazel create mode 100644 ng-dev/release/recover-ci-publish/cli.ts create mode 100644 ng-dev/release/recover-ci-publish/recover-ci-publish.ts create mode 100644 ng-dev/release/recover-ci-publish/test/recover-ci-publish.spec.ts diff --git a/github-actions/release/publish/BUILD.bazel b/github-actions/release/publish/BUILD.bazel index 67b5b06202..3af18bcb1f 100644 --- a/github-actions/release/publish/BUILD.bazel +++ b/github-actions/release/publish/BUILD.bazel @@ -1,7 +1,10 @@ load("@devinfra_npm//:defs.bzl", "npm_link_all_packages") load("//tools:defaults.bzl", "esbuild_checked_in", "jasmine_test", "ts_project") -package(default_visibility = ["//github-actions/release/publish:__subpackages__"]) +package(default_visibility = [ + "//github-actions/release/publish:__subpackages__", + "//ng-dev/release:__subpackages__", +]) npm_link_all_packages() diff --git a/ng-dev/release/BUILD.bazel b/ng-dev/release/BUILD.bazel index 5db92e86ec..890869218a 100644 --- a/ng-dev/release/BUILD.bazel +++ b/ng-dev/release/BUILD.bazel @@ -16,6 +16,7 @@ ts_project( "//ng-dev/release/npm-dist-tag", "//ng-dev/release/precheck", "//ng-dev/release/publish", + "//ng-dev/release/recover-ci-publish", "//ng-dev/release/snapshot-publish", "//ng-dev/release/stamping", "//ng-dev/utils", diff --git a/ng-dev/release/cli.ts b/ng-dev/release/cli.ts index 87a52ba5d7..f6092fa938 100644 --- a/ng-dev/release/cli.ts +++ b/ng-dev/release/cli.ts @@ -15,6 +15,7 @@ import {ReleasePublishCommandModule} from './publish/cli.js'; import {ReleasePublishSnapshotsCommandModule} from './snapshot-publish/cli.js'; import {BuildEnvStampCommand} from './stamping/cli.js'; import {ReleaseNpmDistTagCommand} from './npm-dist-tag/cli.js'; +import {ReleaseRecoverCiPublishCommandModule} from './recover-ci-publish/cli.js'; /** Build the parser for the release commands. */ export function buildReleaseParser(localYargs: Argv) { @@ -29,5 +30,6 @@ export function buildReleaseParser(localYargs: Argv) { .command(ReleasePrecheckCommandModule) .command(BuildEnvStampCommand) .command(ReleaseNotesCommandModule) - .command(ReleasePublishSnapshotsCommandModule); + .command(ReleasePublishSnapshotsCommandModule) + .command(ReleaseRecoverCiPublishCommandModule); } diff --git a/ng-dev/release/recover-ci-publish/BUILD.bazel b/ng-dev/release/recover-ci-publish/BUILD.bazel new file mode 100644 index 0000000000..762b6a2b88 --- /dev/null +++ b/ng-dev/release/recover-ci-publish/BUILD.bazel @@ -0,0 +1,41 @@ +load("//tools:defaults.bzl", "jasmine_test", "ts_project") + +ts_project( + name = "recover-ci-publish", + srcs = [ + "cli.ts", + "recover-ci-publish.ts", + ], + visibility = ["//ng-dev:__subpackages__"], + deps = [ + "//github-actions/release/publish:lib", + "//ng-dev:node_modules/@types/node", + "//ng-dev:node_modules/@types/yargs", + "//ng-dev:node_modules/yargs", + "//ng-dev/release/config", + "//ng-dev/release/versioning", + "//ng-dev/utils", + ], +) + +ts_project( + name = "test_lib", + testonly = True, + srcs = ["test/recover-ci-publish.spec.ts"], + tsconfig = "//ng-dev:tsconfig_test", + deps = [ + ":recover-ci-publish", + "//github-actions/release/publish:lib", + "//ng-dev:node_modules/@types/jasmine", + "//ng-dev:node_modules/@types/node", + "//ng-dev/release/versioning", + "//ng-dev/utils", + ], +) + +jasmine_test( + name = "test", + data = [ + ":test_lib", + ], +) diff --git a/ng-dev/release/recover-ci-publish/cli.ts b/ng-dev/release/recover-ci-publish/cli.ts new file mode 100644 index 0000000000..54b5099dd2 --- /dev/null +++ b/ng-dev/release/recover-ci-publish/cli.ts @@ -0,0 +1,68 @@ +/** + * @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 {ReleaseRecoverCiPublishTool} from './recover-ci-publish.js'; + +/** Command line options for recovering a CI publish run. */ +export interface ReleaseRecoverCiPublishOptions { + runId: number; + dryRun: boolean; + publishRegistry: string | undefined; +} + +/** Yargs command builder for configuring the `ng-dev release recover-ci-publish` command. */ +function builder(argv: Argv): Argv { + return addGithubTokenOption(argv) + .positional('run-id', { + type: 'number', + demandOption: true, + description: 'The GitHub Actions workflow run ID containing the release packages to recover.', + }) + .option('dry-run', { + type: 'boolean', + default: false, + description: 'Run the recovery process in dry-run mode (skips actual publishing).', + }) + .option('publish-registry', { + type: 'string', + description: 'NPM registry URL to publish packages to (overrides config).', + }) as unknown as Argv; +} + +/** Yargs command handler for recovering a CI publish run. */ +async function handler(args: Arguments) { + const git = await AuthenticatedGitClient.get(); + const config = await getConfig(); + assertValidReleaseConfig(config); + assertValidGithubConfig(config); + + const tool = new ReleaseRecoverCiPublishTool(git, config.release, config.github, args.runId, { + dryRun: args.dryRun, + publishRegistry: args.publishRegistry, + }); + + await tool.run(); +} + +/** CLI command module for recovering a failed GHA publish run locally. */ +export const ReleaseRecoverCiPublishCommandModule: CommandModule< + {}, + ReleaseRecoverCiPublishOptions +> = { + builder, + handler, + command: 'recover-ci-publish ', + describe: + 'Recover a failed CI release publish run by downloading built artifacts and publishing them locally.', +}; diff --git a/ng-dev/release/recover-ci-publish/recover-ci-publish.ts b/ng-dev/release/recover-ci-publish/recover-ci-publish.ts new file mode 100644 index 0000000000..aa1f0c25fd --- /dev/null +++ b/ng-dev/release/recover-ci-publish/recover-ci-publish.ts @@ -0,0 +1,183 @@ +/** + * @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 fs from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; + +import {GithubConfig} from '../../utils/config.js'; +import {ReleaseConfig} from '../config/index.js'; +import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client.js'; +import {ChildProcess} from '../../utils/child-process.js'; +import {NpmCommand} from '../versioning/npm-command.js'; +import {PublishCiTool} from '../../../github-actions/release/publish/lib/publish-ci.js'; +import {Log} from '../../utils/logging.js'; +import {Prompt} from '../../utils/prompt.js'; + +/** Options for configuring the ReleaseRecoverCiPublishTool. */ +export interface ReleaseRecoverCiPublishToolOptions { + /** Whether to run in dry-run mode. */ + dryRun?: boolean; + /** NPM registry URL to publish packages to (overrides config). */ + publishRegistry?: string; +} + +/** + * Tool to recover a failed CI release publish run locally. + * + * Downloads the built packages (.tgz) from a failed GitHub Actions run, + * extracts them, and publishes them locally using the user's Wombat token session. + */ +export class ReleaseRecoverCiPublishTool { + constructor( + private git: AuthenticatedGitClient, + private releaseConfig: ReleaseConfig, + private githubConfig: GithubConfig, + private runId: number, + private options: ReleaseRecoverCiPublishToolOptions = {}, + ) {} + + /** Runs the recovery process. */ + async run(): Promise { + const registry = this.options.publishRegistry ?? this.releaseConfig.publishRegistry; + + // 1. Verify NPM Login State (Fail fast) + const loginOk = await this._verifyNpmLoginState(registry); + if (!loginOk) { + Log.error(' ✘ NPM login verification failed. Aborting recovery.'); + process.exitCode = 1; + return; + } + + // Create temp directory for downloading and extracting artifacts + const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'ng-dev-publish-recovery-')); + Log.debug(`Created temp directory: ${tempDir}`); + + try { + // 2. Fetch GHA Run Details + Log.info(`Fetching workflow run details for run ID: ${this.runId}...`); + const {data: run} = await this.git.github.rest.actions.getWorkflowRun({ + owner: this.githubConfig.owner, + repo: this.githubConfig.name, + run_id: this.runId, + }); + Log.info(`Found run: ${run.name} (Commit SHA: ${run.head_sha})`); + + // 3. Fetch Artifacts List + Log.info('Fetching list of artifacts for this run...'); + const {data: artifactsData} = await this.git.github.rest.actions.listWorkflowRunArtifacts({ + owner: this.githubConfig.owner, + repo: this.githubConfig.name, + run_id: this.runId, + }); + + const artifactName = 'release-packages-tgz'; + const artifact = artifactsData.artifacts.find((art: any) => art.name === artifactName); + if (!artifact) { + throw new Error(`Expected artifact "${artifactName}" not found in run ${this.runId}.`); + } + + // 4. Download Artifact ZIP + Log.info(`Downloading artifact "${artifactName}" (ID: ${artifact.id})...`); + const downloadResponse = await this.git.github.rest.actions.downloadArtifact({ + owner: this.githubConfig.owner, + repo: this.githubConfig.name, + artifact_id: artifact.id, + archive_format: 'zip', + }); + + // downloadArtifact returns an ArrayBuffer which we convert to a Buffer to write to disk. + const buffer = Buffer.from(downloadResponse.data as ArrayBuffer); + const zipPath = path.join(tempDir, 'artifacts.zip'); + fs.writeFileSync(zipPath, buffer); + Log.info(`Downloaded artifact zip to ${zipPath}`); + + // 5. Extract Artifact ZIP + const extractDir = path.join(tempDir, 'extracted'); + fs.mkdirSync(extractDir, {recursive: true}); + Log.info(`Extracting packages to ${extractDir}...`); + + try { + // Spawn native unzip utility + await ChildProcess.spawn('unzip', [zipPath, '-d', extractDir], {mode: 'silent'}); + } catch (err: any) { + if (err && err.code === 'ENOENT') { + throw new Error( + `Failed to execute 'unzip'. Please ensure that the 'unzip' utility is installed and available in your PATH.`, + ); + } + throw new Error(`Failed to extract packages zip artifact: ${err}`); + } + Log.info('Packages extracted successfully.'); + + // 6. Publish via PublishCiTool + Log.info('Initializing PublishCiTool for local publishing...'); + const tool = new PublishCiTool( + {github: this.githubConfig, release: this.releaseConfig} as any, + this.git, + this.git.baseDir, // Project root directory where local package.json / git is located + { + builtPackagesDir: extractDir, + expectedSha: run.head_sha, + useLocalNpmConfig: true, // Bypasses GHA Wombat token check, uses local configuration + dryRun: this.options.dryRun, + skipTagging: true, // Tagging should be done in GHA, only recover publishing + }, + ); + + Log.info('Starting local publishing of recovered packages...'); + await tool.run(); + Log.info('Local recovery publishing completed.'); + } catch (e) { + Log.error(' ✘ An error occurred during recovery:'); + Log.error(e); + process.exitCode = 1; + } finally { + // 7. Cleanup + Log.debug(`Cleaning up temp directory: ${tempDir}`); + try { + fs.rmSync(tempDir, {recursive: true, force: true}); + } catch (err) { + Log.warn(`Warning: Could not remove temp directory ${tempDir}:`, err); + } + } + } + + /** Verifies that the user is logged into NPM locally. */ + private async _verifyNpmLoginState(registry: string | undefined): Promise { + const registryName = `NPM at the ${registry ?? 'default NPM'} registry`; + + if (registry?.includes('wombat-dressing-room.appspot.com')) { + Log.info('Unable to determine NPM login state for Wombat proxy, requiring login now.'); + try { + await NpmCommand.startInteractiveLogin(registry); + } catch { + return false; + } + return true; + } + + if (await NpmCommand.checkIsLoggedIn(registry)) { + Log.debug(`Already logged into ${registryName}.`); + return true; + } + + Log.warn(` ✘ Not currently logged into ${registryName}.`); + const shouldLogin = await Prompt.confirm({message: 'Would you like to log into NPM now?'}); + if (shouldLogin) { + try { + await NpmCommand.startInteractiveLogin(registry); + return true; + } catch (e) { + Log.error('NPM login failed:', e); + return false; + } + } + return false; + } +} diff --git a/ng-dev/release/recover-ci-publish/test/recover-ci-publish.spec.ts b/ng-dev/release/recover-ci-publish/test/recover-ci-publish.spec.ts new file mode 100644 index 0000000000..f8698a14e3 --- /dev/null +++ b/ng-dev/release/recover-ci-publish/test/recover-ci-publish.spec.ts @@ -0,0 +1,316 @@ +/** + * @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 fs from 'node:fs'; +import path from 'node:path'; +import {ReleaseRecoverCiPublishTool} from '../recover-ci-publish.js'; +import {AuthenticatedGitClient} from '../../../utils/git/authenticated-git-client.js'; +import {ChildProcess} from '../../../utils/child-process.js'; +import {NpmCommand} from '../../versioning/npm-command.js'; +import {PublishCiTool} from '../../../../github-actions/release/publish/lib/publish-ci.js'; +import {Prompt} from '../../../utils/prompt.js'; +import {Log} from '../../../utils/logging.js'; + +describe('ReleaseRecoverCiPublishTool', () => { + let gitClient: any; + let releaseConfig: any; + let githubConfig: any; + let tempDirCreated: string | null = null; + + // Spies + let getWorkflowRunSpy: jasmine.Spy; + let listWorkflowRunArtifactsSpy: jasmine.Spy; + let downloadArtifactSpy: jasmine.Spy; + let spawnSpy: jasmine.Spy; + let publishCiRunSpy: jasmine.Spy; + let checkIsLoggedInSpy: jasmine.Spy; + let startInteractiveLoginSpy: jasmine.Spy; + let promptConfirmSpy: jasmine.Spy; + + beforeEach(() => { + tempDirCreated = null; + // Mock fs.mkdtempSync to track the created directory + const orgMkdtemp = fs.mkdtempSync; + spyOn(fs, 'mkdtempSync').and.callFake(((prefix: string) => { + const dir = orgMkdtemp(prefix); + tempDirCreated = dir; + return dir; + }) as any); + + // Mock fs.rmSync to avoid actually deleting if we want to inspect, or just to spy + spyOn(fs, 'rmSync').and.callThrough(); + + // Mock Git Client and Github API + getWorkflowRunSpy = jasmine.createSpy('getWorkflowRun').and.resolveTo({ + data: { + name: 'release-run', + head_sha: 'mock-sha-12345', + }, + }); + listWorkflowRunArtifactsSpy = jasmine.createSpy('listWorkflowRunArtifacts').and.resolveTo({ + data: { + artifacts: [ + {name: 'other-artifact', id: 111}, + {name: 'release-packages-tgz', id: 222}, + ], + }, + }); + downloadArtifactSpy = jasmine.createSpy('downloadArtifact').and.resolveTo({ + data: new ArrayBuffer(8), // Mock zip content + }); + + gitClient = { + baseDir: '/mock-project-root', + github: { + rest: { + actions: { + getWorkflowRun: getWorkflowRunSpy, + listWorkflowRunArtifacts: listWorkflowRunArtifactsSpy, + downloadArtifact: downloadArtifactSpy, + }, + }, + }, + }; + + releaseConfig = { + publishRegistry: 'https://registry.npmjs.org', + npmPackages: [{name: '@angular/core'}], + representativeNpmPackage: '@angular/core', + }; + + githubConfig = { + owner: 'angular', + name: 'angular', + }; + + // Mock ChildProcess.spawn (for unzip) + spawnSpy = spyOn(ChildProcess, 'spawn').and.resolveTo({stdout: '', stderr: ''} as any); + + // Mock NpmCommand static methods + checkIsLoggedInSpy = spyOn(NpmCommand, 'checkIsLoggedIn').and.resolveTo(true); + startInteractiveLoginSpy = spyOn(NpmCommand, 'startInteractiveLogin').and.resolveTo(); + + // Mock Prompt + promptConfirmSpy = spyOn(Prompt, 'confirm').and.resolveTo(true); + + // Mock PublishCiTool.prototype.run + publishCiRunSpy = spyOn(PublishCiTool.prototype, 'run').and.resolveTo(); + + // Mock Log to avoid spamming console + spyOn(Log, 'info'); + spyOn(Log, 'debug'); + spyOn(Log, 'warn'); + spyOn(Log, 'error'); + }); + + afterEach(() => { + // Clean up any leaked temp directories if test failed before cleanup + if (tempDirCreated && fs.existsSync(tempDirCreated)) { + try { + fs.rmSync(tempDirCreated, {recursive: true, force: true}); + } catch {} + } + }); + + describe('NPM login verification', () => { + it('should proceed if already logged into NPM', async () => { + checkIsLoggedInSpy.and.resolveTo(true); + + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + expect(checkIsLoggedInSpy).toHaveBeenCalledWith('https://registry.npmjs.org'); + expect(startInteractiveLoginSpy).not.toHaveBeenCalled(); + expect(publishCiRunSpy).toHaveBeenCalled(); + }); + + it('should prompt and login if not logged in and user confirms', async () => { + checkIsLoggedInSpy.and.resolveTo(false); + promptConfirmSpy.and.resolveTo(true); + + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + expect(checkIsLoggedInSpy).toHaveBeenCalled(); + expect(promptConfirmSpy).toHaveBeenCalled(); + expect(startInteractiveLoginSpy).toHaveBeenCalledWith('https://registry.npmjs.org'); + expect(publishCiRunSpy).toHaveBeenCalled(); + }); + + it('should abort if not logged in and user declines login', async () => { + checkIsLoggedInSpy.and.resolveTo(false); + promptConfirmSpy.and.resolveTo(false); + + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + expect(checkIsLoggedInSpy).toHaveBeenCalled(); + expect(promptConfirmSpy).toHaveBeenCalled(); + expect(startInteractiveLoginSpy).not.toHaveBeenCalled(); + expect(publishCiRunSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; // reset + }); + + it('should force interactive login for Wombat registry without checking login state', async () => { + releaseConfig.publishRegistry = 'https://wombat-dressing-room.appspot.com/publish'; + checkIsLoggedInSpy.and.resolveTo(true); // Should be ignored + + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + expect(checkIsLoggedInSpy).not.toHaveBeenCalled(); + expect(startInteractiveLoginSpy).toHaveBeenCalledWith( + 'https://wombat-dressing-room.appspot.com/publish', + ); + expect(publishCiRunSpy).toHaveBeenCalled(); + }); + }); + + describe('Artifact download and extraction', () => { + it('should fetch GHA run and list artifacts', async () => { + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + expect(getWorkflowRunSpy).toHaveBeenCalledWith({ + owner: 'angular', + repo: 'angular', + run_id: 12345, + }); + expect(listWorkflowRunArtifactsSpy).toHaveBeenCalledWith({ + owner: 'angular', + repo: 'angular', + run_id: 12345, + }); + }); + + it('should download and extract the correct zip artifact', async () => { + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + // Verify downloadArtifact called with correct artifact ID (222 for release-packages-tgz) + expect(downloadArtifactSpy).toHaveBeenCalledWith({ + owner: 'angular', + repo: 'angular', + artifact_id: 222, + archive_format: 'zip', + }); + + // Verify unzip called + expect(spawnSpy).toHaveBeenCalledTimes(1); + expect(spawnSpy.calls.first().args[0]).toBe('unzip'); + const unzipArgs = spawnSpy.calls.first().args[1]; + expect(unzipArgs[0]).toContain('artifacts.zip'); + expect(unzipArgs[1]).toBe('-d'); + expect(unzipArgs[2]).toContain('extracted'); + }); + + it('should throw error if expected artifact is missing in the run', async () => { + listWorkflowRunArtifactsSpy.and.resolveTo({ + data: { + artifacts: [{name: 'some-other-artifact', id: 999}], + }, + }); + + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + expect(downloadArtifactSpy).not.toHaveBeenCalled(); + expect(spawnSpy).not.toHaveBeenCalled(); + expect(publishCiRunSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; // reset + }); + }); + + describe('PublishCiTool execution', () => { + it('should instantiate and run PublishCiTool with correct local options', async () => { + // Setup prototype spy to capture 'this' context and assert options + publishCiRunSpy.and.callFake(function (this: PublishCiTool) { + const options = this['options']; + expect(options.builtPackagesDir).toContain('extracted'); + expect(options.expectedSha).toBe('mock-sha-12345'); + expect(options.useLocalNpmConfig).toBe(true); + expect(options.dryRun).toBe(false); + expect(options.skipTagging).toBe(true); + return Promise.resolve(); + }); + + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345, { + dryRun: false, + }); + await tool.run(); + + expect(publishCiRunSpy).toHaveBeenCalledTimes(1); + }); + + it('should propagate dry-run option to PublishCiTool', async () => { + publishCiRunSpy.and.callFake(function (this: PublishCiTool) { + expect(this['options'].dryRun).toBe(true); + return Promise.resolve(); + }); + + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345, { + dryRun: true, + }); + await tool.run(); + + expect(publishCiRunSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Cleanup', () => { + it('should delete temp directory on successful execution', async () => { + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + expect(tempDirCreated).not.toBeNull(); + expect(fs.existsSync(tempDirCreated!)).toBe(false); + expect(fs.rmSync).toHaveBeenCalledWith(tempDirCreated!, {recursive: true, force: true}); + }); + + it('should delete temp directory even if workflow run fetch fails', async () => { + getWorkflowRunSpy.and.rejectWith(new Error('GHA API error')); + + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + expect(tempDirCreated).not.toBeNull(); + expect(fs.existsSync(tempDirCreated!)).toBe(false); + expect(fs.rmSync).toHaveBeenCalledWith(tempDirCreated!, {recursive: true, force: true}); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + }); + + it('should delete temp directory even if unzip fails', async () => { + spawnSpy.and.rejectWith(new Error('unzip failed')); + + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + expect(tempDirCreated).not.toBeNull(); + expect(fs.existsSync(tempDirCreated!)).toBe(false); + expect(fs.rmSync).toHaveBeenCalledWith(tempDirCreated!, {recursive: true, force: true}); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + }); + + it('should delete temp directory even if publishing fails', async () => { + publishCiRunSpy.and.rejectWith(new Error('Publish error')); + + const tool = new ReleaseRecoverCiPublishTool(gitClient, releaseConfig, githubConfig, 12345); + await tool.run(); + + expect(tempDirCreated).not.toBeNull(); + expect(fs.existsSync(tempDirCreated!)).toBe(false); + expect(fs.rmSync).toHaveBeenCalledWith(tempDirCreated!, {recursive: true, force: true}); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + }); + }); +});