Skip to content

Commit 6c11443

Browse files
josephperrottalan-agius4
authored andcommitted
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`.
1 parent 1a4330d commit 6c11443

7 files changed

Lines changed: 616 additions & 2 deletions

File tree

github-actions/release/publish/BUILD.bazel

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
load("@devinfra_npm//:defs.bzl", "npm_link_all_packages")
22
load("//tools:defaults.bzl", "esbuild_checked_in", "jasmine_test", "ts_project")
33

4-
package(default_visibility = ["//github-actions/release/publish:__subpackages__"])
4+
package(default_visibility = [
5+
"//github-actions/release/publish:__subpackages__",
6+
"//ng-dev/release:__subpackages__",
7+
])
58

69
npm_link_all_packages()
710

ng-dev/release/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ ts_project(
1616
"//ng-dev/release/npm-dist-tag",
1717
"//ng-dev/release/precheck",
1818
"//ng-dev/release/publish",
19+
"//ng-dev/release/recover-ci-publish",
1920
"//ng-dev/release/snapshot-publish",
2021
"//ng-dev/release/stamping",
2122
"//ng-dev/utils",

ng-dev/release/cli.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {ReleasePublishCommandModule} from './publish/cli.js';
1515
import {ReleasePublishSnapshotsCommandModule} from './snapshot-publish/cli.js';
1616
import {BuildEnvStampCommand} from './stamping/cli.js';
1717
import {ReleaseNpmDistTagCommand} from './npm-dist-tag/cli.js';
18+
import {ReleaseRecoverCiPublishCommandModule} from './recover-ci-publish/cli.js';
1819

1920
/** Build the parser for the release commands. */
2021
export function buildReleaseParser(localYargs: Argv) {
@@ -29,5 +30,6 @@ export function buildReleaseParser(localYargs: Argv) {
2930
.command(ReleasePrecheckCommandModule)
3031
.command(BuildEnvStampCommand)
3132
.command(ReleaseNotesCommandModule)
32-
.command(ReleasePublishSnapshotsCommandModule);
33+
.command(ReleasePublishSnapshotsCommandModule)
34+
.command(ReleaseRecoverCiPublishCommandModule);
3335
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
load("//tools:defaults.bzl", "jasmine_test", "ts_project")
2+
3+
ts_project(
4+
name = "recover-ci-publish",
5+
srcs = [
6+
"cli.ts",
7+
"recover-ci-publish.ts",
8+
],
9+
visibility = ["//ng-dev:__subpackages__"],
10+
deps = [
11+
"//github-actions/release/publish:lib",
12+
"//ng-dev:node_modules/@types/node",
13+
"//ng-dev:node_modules/@types/yargs",
14+
"//ng-dev:node_modules/yargs",
15+
"//ng-dev/release/config",
16+
"//ng-dev/release/versioning",
17+
"//ng-dev/utils",
18+
],
19+
)
20+
21+
ts_project(
22+
name = "test_lib",
23+
testonly = True,
24+
srcs = ["test/recover-ci-publish.spec.ts"],
25+
tsconfig = "//ng-dev:tsconfig_test",
26+
deps = [
27+
":recover-ci-publish",
28+
"//github-actions/release/publish:lib",
29+
"//ng-dev:node_modules/@types/jasmine",
30+
"//ng-dev:node_modules/@types/node",
31+
"//ng-dev/release/versioning",
32+
"//ng-dev/utils",
33+
],
34+
)
35+
36+
jasmine_test(
37+
name = "test",
38+
data = [
39+
":test_lib",
40+
],
41+
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Argv, Arguments, CommandModule} from 'yargs';
10+
11+
import {assertValidGithubConfig, getConfig} from '../../utils/config.js';
12+
import {addGithubTokenOption} from '../../utils/git/github-yargs.js';
13+
import {assertValidReleaseConfig} from '../config/index.js';
14+
import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client.js';
15+
import {ReleaseRecoverCiPublishTool} from './recover-ci-publish.js';
16+
17+
/** Command line options for recovering a CI publish run. */
18+
export interface ReleaseRecoverCiPublishOptions {
19+
runId: number;
20+
dryRun: boolean;
21+
publishRegistry: string | undefined;
22+
}
23+
24+
/** Yargs command builder for configuring the `ng-dev release recover-ci-publish` command. */
25+
function builder(argv: Argv): Argv<ReleaseRecoverCiPublishOptions> {
26+
return addGithubTokenOption(argv)
27+
.positional('run-id', {
28+
type: 'number',
29+
demandOption: true,
30+
description: 'The GitHub Actions workflow run ID containing the release packages to recover.',
31+
})
32+
.option('dry-run', {
33+
type: 'boolean',
34+
default: false,
35+
description: 'Run the recovery process in dry-run mode (skips actual publishing).',
36+
})
37+
.option('publish-registry', {
38+
type: 'string',
39+
description: 'NPM registry URL to publish packages to (overrides config).',
40+
}) as unknown as Argv<ReleaseRecoverCiPublishOptions>;
41+
}
42+
43+
/** Yargs command handler for recovering a CI publish run. */
44+
async function handler(args: Arguments<ReleaseRecoverCiPublishOptions>) {
45+
const git = await AuthenticatedGitClient.get();
46+
const config = await getConfig();
47+
assertValidReleaseConfig(config);
48+
assertValidGithubConfig(config);
49+
50+
const tool = new ReleaseRecoverCiPublishTool(git, config.release, config.github, args.runId, {
51+
dryRun: args.dryRun,
52+
publishRegistry: args.publishRegistry,
53+
});
54+
55+
await tool.run();
56+
}
57+
58+
/** CLI command module for recovering a failed GHA publish run locally. */
59+
export const ReleaseRecoverCiPublishCommandModule: CommandModule<
60+
{},
61+
ReleaseRecoverCiPublishOptions
62+
> = {
63+
builder,
64+
handler,
65+
command: 'recover-ci-publish <run-id>',
66+
describe:
67+
'Recover a failed CI release publish run by downloading built artifacts and publishing them locally.',
68+
};
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import fs from 'node:fs';
10+
import {tmpdir} from 'node:os';
11+
import path from 'node:path';
12+
13+
import {GithubConfig} from '../../utils/config.js';
14+
import {ReleaseConfig} from '../config/index.js';
15+
import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client.js';
16+
import {ChildProcess} from '../../utils/child-process.js';
17+
import {NpmCommand} from '../versioning/npm-command.js';
18+
import {PublishCiTool} from '../../../github-actions/release/publish/lib/publish-ci.js';
19+
import {Log} from '../../utils/logging.js';
20+
import {Prompt} from '../../utils/prompt.js';
21+
22+
/** Options for configuring the ReleaseRecoverCiPublishTool. */
23+
export interface ReleaseRecoverCiPublishToolOptions {
24+
/** Whether to run in dry-run mode. */
25+
dryRun?: boolean;
26+
/** NPM registry URL to publish packages to (overrides config). */
27+
publishRegistry?: string;
28+
}
29+
30+
/**
31+
* Tool to recover a failed CI release publish run locally.
32+
*
33+
* Downloads the built packages (.tgz) from a failed GitHub Actions run,
34+
* extracts them, and publishes them locally using the user's Wombat token session.
35+
*/
36+
export class ReleaseRecoverCiPublishTool {
37+
constructor(
38+
private git: AuthenticatedGitClient,
39+
private releaseConfig: ReleaseConfig,
40+
private githubConfig: GithubConfig,
41+
private runId: number,
42+
private options: ReleaseRecoverCiPublishToolOptions = {},
43+
) {}
44+
45+
/** Runs the recovery process. */
46+
async run(): Promise<void> {
47+
const registry = this.options.publishRegistry ?? this.releaseConfig.publishRegistry;
48+
49+
// 1. Verify NPM Login State (Fail fast)
50+
const loginOk = await this._verifyNpmLoginState(registry);
51+
if (!loginOk) {
52+
Log.error(' ✘ NPM login verification failed. Aborting recovery.');
53+
process.exitCode = 1;
54+
return;
55+
}
56+
57+
// Create temp directory for downloading and extracting artifacts
58+
const tempDir = fs.mkdtempSync(path.join(tmpdir(), 'ng-dev-publish-recovery-'));
59+
Log.debug(`Created temp directory: ${tempDir}`);
60+
61+
try {
62+
// 2. Fetch GHA Run Details
63+
Log.info(`Fetching workflow run details for run ID: ${this.runId}...`);
64+
const {data: run} = await this.git.github.rest.actions.getWorkflowRun({
65+
owner: this.githubConfig.owner,
66+
repo: this.githubConfig.name,
67+
run_id: this.runId,
68+
});
69+
Log.info(`Found run: ${run.name} (Commit SHA: ${run.head_sha})`);
70+
71+
// 3. Fetch Artifacts List
72+
Log.info('Fetching list of artifacts for this run...');
73+
const {data: artifactsData} = await this.git.github.rest.actions.listWorkflowRunArtifacts({
74+
owner: this.githubConfig.owner,
75+
repo: this.githubConfig.name,
76+
run_id: this.runId,
77+
});
78+
79+
const artifactName = 'release-packages-tgz';
80+
const artifact = artifactsData.artifacts.find((art: any) => art.name === artifactName);
81+
if (!artifact) {
82+
throw new Error(`Expected artifact "${artifactName}" not found in run ${this.runId}.`);
83+
}
84+
85+
// 4. Download Artifact ZIP
86+
Log.info(`Downloading artifact "${artifactName}" (ID: ${artifact.id})...`);
87+
const downloadResponse = await this.git.github.rest.actions.downloadArtifact({
88+
owner: this.githubConfig.owner,
89+
repo: this.githubConfig.name,
90+
artifact_id: artifact.id,
91+
archive_format: 'zip',
92+
});
93+
94+
// downloadArtifact returns an ArrayBuffer which we convert to a Buffer to write to disk.
95+
const buffer = Buffer.from(downloadResponse.data as ArrayBuffer);
96+
const zipPath = path.join(tempDir, 'artifacts.zip');
97+
fs.writeFileSync(zipPath, buffer);
98+
Log.info(`Downloaded artifact zip to ${zipPath}`);
99+
100+
// 5. Extract Artifact ZIP
101+
const extractDir = path.join(tempDir, 'extracted');
102+
fs.mkdirSync(extractDir, {recursive: true});
103+
Log.info(`Extracting packages to ${extractDir}...`);
104+
105+
try {
106+
// Spawn native unzip utility
107+
await ChildProcess.spawn('unzip', [zipPath, '-d', extractDir], {mode: 'silent'});
108+
} catch (err: any) {
109+
if (err && err.code === 'ENOENT') {
110+
throw new Error(
111+
`Failed to execute 'unzip'. Please ensure that the 'unzip' utility is installed and available in your PATH.`,
112+
);
113+
}
114+
throw new Error(`Failed to extract packages zip artifact: ${err}`);
115+
}
116+
Log.info('Packages extracted successfully.');
117+
118+
// 6. Publish via PublishCiTool
119+
Log.info('Initializing PublishCiTool for local publishing...');
120+
const tool = new PublishCiTool(
121+
{github: this.githubConfig, release: this.releaseConfig} as any,
122+
this.git,
123+
this.git.baseDir, // Project root directory where local package.json / git is located
124+
{
125+
builtPackagesDir: extractDir,
126+
expectedSha: run.head_sha,
127+
useLocalNpmConfig: true, // Bypasses GHA Wombat token check, uses local configuration
128+
dryRun: this.options.dryRun,
129+
skipTagging: true, // Tagging should be done in GHA, only recover publishing
130+
},
131+
);
132+
133+
Log.info('Starting local publishing of recovered packages...');
134+
await tool.run();
135+
Log.info('Local recovery publishing completed.');
136+
} catch (e) {
137+
Log.error(' ✘ An error occurred during recovery:');
138+
Log.error(e);
139+
process.exitCode = 1;
140+
} finally {
141+
// 7. Cleanup
142+
Log.debug(`Cleaning up temp directory: ${tempDir}`);
143+
try {
144+
fs.rmSync(tempDir, {recursive: true, force: true});
145+
} catch (err) {
146+
Log.warn(`Warning: Could not remove temp directory ${tempDir}:`, err);
147+
}
148+
}
149+
}
150+
151+
/** Verifies that the user is logged into NPM locally. */
152+
private async _verifyNpmLoginState(registry: string | undefined): Promise<boolean> {
153+
const registryName = `NPM at the ${registry ?? 'default NPM'} registry`;
154+
155+
if (registry?.includes('wombat-dressing-room.appspot.com')) {
156+
Log.info('Unable to determine NPM login state for Wombat proxy, requiring login now.');
157+
try {
158+
await NpmCommand.startInteractiveLogin(registry);
159+
} catch {
160+
return false;
161+
}
162+
return true;
163+
}
164+
165+
if (await NpmCommand.checkIsLoggedIn(registry)) {
166+
Log.debug(`Already logged into ${registryName}.`);
167+
return true;
168+
}
169+
170+
Log.warn(` ✘ Not currently logged into ${registryName}.`);
171+
const shouldLogin = await Prompt.confirm({message: 'Would you like to log into NPM now?'});
172+
if (shouldLogin) {
173+
try {
174+
await NpmCommand.startInteractiveLogin(registry);
175+
return true;
176+
} catch (e) {
177+
Log.error('NPM login failed:', e);
178+
return false;
179+
}
180+
}
181+
return false;
182+
}
183+
}

0 commit comments

Comments
 (0)