package-registry-manager 0.22.7 → 0.22.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,259 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { inspectRepository } from "./discovery.mjs";
5
+ import { githubServices } from "./github.mjs";
6
+ import { probeRegistryState } from "./registry-state.mjs";
7
+ import { RELEASE_FAILURE_PATTERNS } from "./publishing-policy.mjs";
8
+ import { credentialPolicy } from "./credential-cycle.mjs";
9
+ import { identityCommand } from "./repository-identity.mjs";
10
+
11
+ /** Only files used by existing discovery; no full repository clones for scans. */
12
+ export const SCAN_MATCHES = [
13
+ ...[
14
+ "package.json",
15
+ "Cargo.toml",
16
+ "pyproject.toml",
17
+ "setup.py",
18
+ "go.mod",
19
+ "pom.xml",
20
+ "build.gradle",
21
+ "build.gradle.kts",
22
+ "composer.json",
23
+ "jsr.json",
24
+ "deno.json",
25
+ "*.csproj",
26
+ "*.fsproj",
27
+ "*.vbproj",
28
+ "*.gemspec",
29
+ "Dockerfile*",
30
+ "Containerfile*",
31
+ "docker-compose*.yml",
32
+ "docker-compose*.yaml",
33
+ "compose*.yml",
34
+ "compose*.yaml",
35
+ ].map((name) => `**/${name}`),
36
+ ".github/workflows/*.yml",
37
+ ".github/workflows/*.yaml",
38
+ ".package-registry-manager.json",
39
+ ];
40
+
41
+ export function packageFindings(inspection) {
42
+ const slug = `${inspection.repository.github_owner}/${inspection.repository.github_repository}`;
43
+ return inspection.packages
44
+ .filter((item) => item.publishable)
45
+ .flatMap((item) => {
46
+ const common = {
47
+ repository: slug,
48
+ registry: item.registry,
49
+ package: item.name,
50
+ manifest: item.manifest,
51
+ };
52
+ const findings = [];
53
+ if (item.exists_on_registry === false) {
54
+ findings.push({ ...common, type: "unpublished" });
55
+ }
56
+ if (
57
+ item.exists_on_registry === true &&
58
+ (item.trusted_publishing === false ||
59
+ credentialPolicy(item.registry).mode === "token")
60
+ ) {
61
+ findings.push({
62
+ ...common,
63
+ type: "published-without-trusted-publishing",
64
+ });
65
+ }
66
+ const others = (item.configured_publishers ?? []).filter(
67
+ (publisher) =>
68
+ publisher.repository.toLowerCase() !== slug.toLowerCase(),
69
+ );
70
+ if (others.length) {
71
+ findings.push({
72
+ ...common,
73
+ type: "trusted-publisher-other-repository",
74
+ evidence: others,
75
+ });
76
+ }
77
+ return findings;
78
+ });
79
+ }
80
+
81
+ async function writeSnapshot(root, slug, files) {
82
+ for (const file of files) {
83
+ if (
84
+ typeof file.path !== "string" ||
85
+ file.path.includes("\\") ||
86
+ file.path.includes(":") ||
87
+ file.path
88
+ .split("/")
89
+ .some(
90
+ (part) => !part || part === "." || part === ".." || part === ".git",
91
+ )
92
+ ) {
93
+ throw new Error("unsafe repository file path");
94
+ }
95
+ if (typeof file.content !== "string") {
96
+ throw new Error("gh-manager returned no file content");
97
+ }
98
+ const target = path.join(root, file.path);
99
+ await mkdir(path.dirname(target), { recursive: true });
100
+ await writeFile(target, file.content);
101
+ }
102
+ await mkdir(path.join(root, ".git"), { recursive: true });
103
+ await writeFile(
104
+ path.join(root, ".git/config"),
105
+ `[remote "origin"]\n url = https://github.com/${slug}.git\n`,
106
+ );
107
+ }
108
+
109
+ /** Keep snapshots alive only for the callback; cleanup also runs after errors. */
110
+ export async function withAccountScan(options, callback) {
111
+ if (
112
+ Boolean(options.org) === Boolean(options.user) ||
113
+ !/^[\w.-]+$/.test(options.org ?? options.user)
114
+ ) {
115
+ throw new Error("choose exactly one valid --org or --user");
116
+ }
117
+ const github = options.github ?? githubServices(options);
118
+ const directory = await mkdtemp(path.join(os.tmpdir(), "prm-account-"));
119
+ try {
120
+ const repositories = await github.repos.list({
121
+ org: options.org,
122
+ user: options.user,
123
+ });
124
+ const scan = {
125
+ schema_version: 1,
126
+ account: options.org ?? options.user,
127
+ repositories: [],
128
+ findings: [],
129
+ };
130
+ for (const [index, repo] of repositories.entries()) {
131
+ const slug = repo.full_name;
132
+ if (!/^[\w.-]+\/[\w.-]+$/.test(slug)) {
133
+ throw new Error("gh-manager returned an invalid repository name");
134
+ }
135
+ try {
136
+ const root = path.join(directory, String(index));
137
+ await writeSnapshot(
138
+ root,
139
+ slug,
140
+ await github.repos.files(slug, {
141
+ match: SCAN_MATCHES,
142
+ content: true,
143
+ ref: repo.default_branch,
144
+ }),
145
+ );
146
+ const discovered = await inspectRepository(root, {
147
+ includeSkipped: options.verbose,
148
+ });
149
+ const inspection = options.offline
150
+ ? discovered
151
+ : await (options.probe ?? probeRegistryState)(discovered, {
152
+ ...options,
153
+ runIdentity: async (program, args) =>
154
+ program === "gh"
155
+ ? slug
156
+ : identityCommand(program, args, {
157
+ ...options,
158
+ repository: root,
159
+ }),
160
+ repository: root,
161
+ });
162
+ scan.repositories.push({
163
+ repository: slug,
164
+ default_branch: repo.default_branch,
165
+ inspection,
166
+ });
167
+ scan.findings.push(...packageFindings(inspection));
168
+ } catch (error) {
169
+ scan.repositories.push({ repository: slug, error: error.message });
170
+ }
171
+ }
172
+ if (!options.offline) {
173
+ let failures;
174
+ if (options.org) {
175
+ failures = await github.runs.failures({
176
+ org: options.org,
177
+ grep: RELEASE_FAILURE_PATTERNS,
178
+ });
179
+ } else {
180
+ failures = [];
181
+ for (const repo of repositories) {
182
+ const [latest] = await github.runs.list(repo.full_name, {
183
+ branch: repo.default_branch,
184
+ limit: 1,
185
+ });
186
+ if (latest?.conclusion === "failure") {
187
+ const matches = await github.runs.logs(latest.id, {
188
+ repo: repo.full_name,
189
+ grep: RELEASE_FAILURE_PATTERNS,
190
+ });
191
+ if (matches.length) {
192
+ failures.push({
193
+ repository: repo.full_name,
194
+ runUrl: latest.html_url,
195
+ matches,
196
+ });
197
+ }
198
+ }
199
+ }
200
+ }
201
+ scan.findings.push(
202
+ ...failures.map((evidence) => ({
203
+ repository: evidence.repository,
204
+ type: "release-failing",
205
+ evidence,
206
+ })),
207
+ );
208
+ }
209
+ return await callback(scan, github);
210
+ } finally {
211
+ await rm(directory, { recursive: true, force: true });
212
+ }
213
+ }
214
+
215
+ export async function scanRepositories(options) {
216
+ return withAccountScan(options, (scan) => {
217
+ for (const item of scan.repositories) {
218
+ if (item.inspection) {
219
+ item.inspection.repository.root = `https://github.com/${item.repository}`;
220
+ }
221
+ }
222
+ return scan;
223
+ });
224
+ }
225
+
226
+ export function renderScan(scan) {
227
+ const rows = ["Repository\tRegistry\tPackage\tFinding"];
228
+ for (const item of scan.findings) {
229
+ rows.push(
230
+ `${item.repository}\t${item.registry ?? "-"}\t${item.package ?? "-"}\t${item.type.replaceAll("-", " ")}`,
231
+ );
232
+ }
233
+ for (const item of scan.repositories.filter((repo) => repo.error)) {
234
+ rows.push(`${item.repository}\t-\t-\tscan failed: ${item.error}`);
235
+ }
236
+ for (const repo of scan.repositories) {
237
+ for (const pkg of repo.inspection?.packages ?? []) {
238
+ if (
239
+ !pkg.publishable ||
240
+ scan.findings.some(
241
+ (finding) =>
242
+ finding.repository === repo.repository &&
243
+ finding.package === pkg.name &&
244
+ finding.registry === pkg.registry,
245
+ )
246
+ ) {
247
+ continue;
248
+ }
249
+ const status =
250
+ pkg.exists_on_registry === undefined
251
+ ? "registry state unknown"
252
+ : pkg.trusted_publishing === true
253
+ ? "published with trusted publishing"
254
+ : "published; trusted publishing unknown";
255
+ rows.push(`${repo.repository}\t${pkg.registry}\t${pkg.name}\t${status}`);
256
+ }
257
+ }
258
+ return `${rows.join("\n")}\n`;
259
+ }
@@ -0,0 +1,203 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFile, rm, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { withAccountScan, renderScan } from "./organization-scan.mjs";
5
+ import { buildPlans } from "./plan.mjs";
6
+ import { probeEnvironment } from "./prerequisites.mjs";
7
+ import { executePlans } from "./setup-batch.mjs";
8
+ import { secretName } from "./ci-credential-cycle.mjs";
9
+ import { TOKEN_PROVIDERS } from "./credential-browser.mjs";
10
+
11
+ async function prepareRepository(item) {
12
+ const root = item.inspection.repository.root;
13
+ await rm(`${root}/.git`, { recursive: true, force: true });
14
+ const commands = [
15
+ ["init", "--quiet"],
16
+ ["remote", "add", "origin", `https://github.com/${item.repository}.git`],
17
+ ["fetch", "--quiet", "--depth=1", "origin", item.default_branch],
18
+ ["checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"],
19
+ ];
20
+ for (const args of commands) {
21
+ const result = spawnSync("git", args, { cwd: root, stdio: "pipe" });
22
+ if (result.status !== 0) {
23
+ throw new Error(`cannot prepare ${item.repository} for setup`);
24
+ }
25
+ }
26
+ }
27
+
28
+ /** Plan from API snapshots; fetch worktrees only for repositories being changed. */
29
+ export async function accountCommand(command, options, outputPlans) {
30
+ return withAccountScan(options, async (scan, github) => {
31
+ if (command === "inspect") {
32
+ for (const item of scan.repositories) {
33
+ if (item.inspection) {
34
+ item.inspection.repository.root = `https://github.com/${item.repository}`;
35
+ }
36
+ }
37
+ process.stdout.write(
38
+ options.format === "json"
39
+ ? `${JSON.stringify(scan, null, 2)}\n`
40
+ : renderScan(scan),
41
+ );
42
+ return scan;
43
+ }
44
+ const items = scan.repositories.filter((item) => item.inspection);
45
+ const errors = scan.repositories.filter((item) => item.error);
46
+ if (errors.length) {
47
+ console.error(renderScan({ repositories: errors, findings: [] }));
48
+ }
49
+ for (const item of items) {
50
+ const workflow = options.planOptions?.workflow;
51
+ if (workflow !== undefined) {
52
+ if (!/^[\w.-]+\.ya?ml$/.test(workflow)) {
53
+ throw new Error(
54
+ "--workflow must be a workflow file name in .github/workflows",
55
+ );
56
+ }
57
+ if (
58
+ !options.planOptions.addPublishJob &&
59
+ !(await stat(
60
+ path.join(
61
+ item.inspection.repository.root,
62
+ ".github/workflows",
63
+ workflow,
64
+ ),
65
+ ).then(
66
+ (metadata) => metadata.isFile(),
67
+ () => false,
68
+ ))
69
+ ) {
70
+ throw new Error(
71
+ `--workflow: ${item.repository} has no .github/workflows/${workflow}`,
72
+ );
73
+ }
74
+ }
75
+ }
76
+ const packages = items.flatMap((item) => item.inspection.packages);
77
+ const environment = await (options.probeEnvironment ?? probeEnvironment)({
78
+ offline: options.offline,
79
+ verbose: options.verbose,
80
+ npm: packages.some((item) => item.registry === "npm"),
81
+ python: packages.some((item) => item.registry === "pypi"),
82
+ });
83
+ let plans = items
84
+ .flatMap((item) => {
85
+ const relevant = scan.findings.filter(
86
+ (finding) => finding.repository === item.repository,
87
+ );
88
+ const inspection = {
89
+ ...item.inspection,
90
+ packages: item.inspection.packages.filter(
91
+ (pkg) =>
92
+ (!options.package || pkg.name === options.package) &&
93
+ (command === "plan" ||
94
+ relevant.some(
95
+ (finding) => !finding.package || finding.package === pkg.name,
96
+ )),
97
+ ),
98
+ };
99
+ return buildPlans(inspection, options.registries ?? [], {
100
+ ...options.planOptions,
101
+ environment,
102
+ }).filter((plan) => plan.package.publishable);
103
+ })
104
+ .sort(
105
+ (a, b) => Number(b.registry === "npm") - Number(a.registry === "npm"),
106
+ );
107
+ const display = structuredClone(plans);
108
+ for (const plan of display) {
109
+ plan.repository.root = `https://github.com/${plan.repository.github_owner}/${plan.repository.github_repository}`;
110
+ }
111
+ outputPlans(display, options.format);
112
+ if (options.package && !plans.length) {
113
+ throw new Error(`package '${options.package}' was not found`);
114
+ }
115
+ if (command === "plan" || plans.length === 0) {
116
+ if (errors.length) {
117
+ throw new Error(
118
+ "some repositories could not be scanned; see scan errors",
119
+ );
120
+ }
121
+ if (!plans.length && options.format !== "json") {
122
+ console.log("No package findings require setup.");
123
+ }
124
+ return [];
125
+ }
126
+ if (options.execute) {
127
+ for (const item of items.filter((item) =>
128
+ plans.some(
129
+ (plan) => plan.repository.root === item.inspection.repository.root,
130
+ ),
131
+ )) {
132
+ try {
133
+ await (options.prepareRepository ?? prepareRepository)(item);
134
+ } catch (error) {
135
+ item.error = error.message;
136
+ console.error(`${item.repository}: ${item.error}`);
137
+ plans = plans.filter(
138
+ (plan) => plan.repository.root !== item.inspection.repository.root,
139
+ );
140
+ }
141
+ }
142
+ if (!plans.length) {
143
+ throw new Error(
144
+ "some repositories could not be prepared; see scan errors",
145
+ );
146
+ }
147
+ }
148
+ const secretRepositories = {};
149
+ for (const plan of plans) {
150
+ const provider = TOKEN_PROVIDERS[plan.registry];
151
+ if (provider) {
152
+ const config = JSON.parse(
153
+ await readFile(
154
+ path.join(plan.repository.root, ".package-registry-manager.json"),
155
+ "utf8",
156
+ ).catch((error) => {
157
+ if (error.code === "ENOENT") {
158
+ return "{}";
159
+ }
160
+ throw error;
161
+ }),
162
+ );
163
+ if (config.tokens?.[plan.registry]?.level === "repo") {
164
+ continue;
165
+ }
166
+ const slug = `${plan.repository.github_owner}/${plan.repository.github_repository}`;
167
+ const name = secretName(
168
+ options.secretName ??
169
+ config.tokens?.[plan.registry]?.secret ??
170
+ plan.package.token_secrets?.[0] ??
171
+ provider.secret,
172
+ plan.registry,
173
+ slug,
174
+ );
175
+ const targets = (secretRepositories[`${plan.registry}:${name}`] ??= []);
176
+ if (!targets.includes(slug)) {
177
+ targets.push(slug);
178
+ }
179
+ }
180
+ }
181
+ const outcomes = await (options.executePlans ?? executePlans)(
182
+ plans,
183
+ {
184
+ ...options,
185
+ github,
186
+ accountScan: true,
187
+ browser: options.openWith ? options.browser : "automated",
188
+ quietBrowser: true,
189
+ secretRepositories,
190
+ repository: items[0]?.inspection.repository.root,
191
+ workflow: options.planOptions?.workflow,
192
+ publisherEnvironment: options.planOptions?.publisherEnvironment,
193
+ },
194
+ true,
195
+ );
196
+ if (scan.repositories.some((item) => item.error)) {
197
+ throw new Error(
198
+ "some repositories could not be scanned; see scan errors",
199
+ );
200
+ }
201
+ return outcomes;
202
+ });
203
+ }
@@ -0,0 +1,46 @@
1
+ /** Registry knowledge belongs to this consumer, never to gh-manager. */
2
+ export const AUTH_FAILURE_PATTERNS = Object.freeze({
3
+ npm: [
4
+ "\\bENEEDAUTH\\b",
5
+ "\\bE401\\b",
6
+ "\\bE403\\b",
7
+ "invalid-publisher",
8
+ "E404.*PUT|PUT.*E404",
9
+ "Access token expired or revoked",
10
+ ],
11
+ "crates-io": [
12
+ "No Trusted Publishing config found",
13
+ "invalid-publisher",
14
+ "token.*(?:invalid|expired|revoked)",
15
+ "authentication failed",
16
+ ],
17
+ pypi: [
18
+ "invalid-publisher",
19
+ "invalid.*(?:token|authentication)",
20
+ "403.*Forbidden",
21
+ ],
22
+ rubygems: ["API key.*(?:invalid|expired)", "invalid-publisher"],
23
+ nuget: ["API key.*(?:invalid|expired)", "invalid-publisher"],
24
+ jsr: ["invalid-publisher", "publishing.*(?:unauthorized|permission)"],
25
+ "docker-hub": [
26
+ "unauthorized: authentication required",
27
+ "denied: requested access",
28
+ "incorrect username or password",
29
+ ],
30
+ "maven-central": [
31
+ "401.*Unauthorized",
32
+ "403.*Forbidden",
33
+ "Invalid user token",
34
+ ],
35
+ "vscode-marketplace": ["Invalid.*(?:PAT|token)", "TF400813", "VSCE_PAT"],
36
+ "open-vsx": ["Invalid access token", "token.*(?:expired|invalid)"],
37
+ "chrome-web-store": [
38
+ "invalid_grant",
39
+ "invalid_client",
40
+ "invalid.*refresh.token",
41
+ ],
42
+ });
43
+
44
+ export const RELEASE_FAILURE_PATTERNS = Object.freeze([
45
+ ...new Set(Object.values(AUTH_FAILURE_PATTERNS).flat()),
46
+ ]);
@@ -23,6 +23,15 @@ const ENDPOINTS = {
23
23
  "PACKAGE_REGISTRY_MANAGER_DOCKER_HUB_API",
24
24
  "https://hub.docker.com/v2",
25
25
  ],
26
+ nuget: [
27
+ "PACKAGE_REGISTRY_MANAGER_NUGET_API",
28
+ "https://api.nuget.org/v3-flatcontainer",
29
+ ],
30
+ rubygems: [
31
+ "PACKAGE_REGISTRY_MANAGER_RUBYGEMS_API",
32
+ "https://rubygems.org/api/v1",
33
+ ],
34
+ jsr: ["PACKAGE_REGISTRY_MANAGER_JSR_API", "https://jsr.io"],
26
35
  };
27
36
 
28
37
  /** Returns the registry API base URL, honoring test overrides. */
@@ -54,6 +63,19 @@ export function registryStateUrl(packageInfo, env = process.env) {
54
63
  return `${base}/crates/${encodeURIComponent(name)}`;
55
64
  case "pypi":
56
65
  return `${base}/pypi/${encodeURIComponent(name)}/json`;
66
+ case "nuget":
67
+ return `${base}/${encodeURIComponent(name.toLowerCase())}/index.json`;
68
+ case "rubygems":
69
+ return `${base}/gems/${encodeURIComponent(name)}.json`;
70
+ case "jsr":
71
+ return `${base}/${name
72
+ .split("/")
73
+ .map((part) =>
74
+ part.startsWith("@")
75
+ ? `@${encodeURIComponent(part.slice(1))}`
76
+ : encodeURIComponent(part),
77
+ )
78
+ .join("/")}/meta.json`;
57
79
  default: {
58
80
  const [namespace, repository] = name.split("/");
59
81
  return `${base}/namespaces/${encodeURIComponent(namespace)}/repositories/${encodeURIComponent(repository)}`;
@@ -0,0 +1,117 @@
1
+ import { repositorySlug } from "./repository-identity.mjs";
2
+
3
+ export const RELEASE_RUN_FIELDS =
4
+ "databaseId,conclusion,status,url,headSha,headBranch,event,createdAt";
5
+ /** Registry rejection of the workflow's trusted-publishing identity. */
6
+ export const PUBLISH_REJECTED =
7
+ /\bE404\b|404 Not Found|invalid[-_ ]publisher|(?:npm|crates\.io|PyPI) refused the OIDC (?:package |token )?exchange\s*\((?:400|401|403|404)\)|trusted publisher[^\n]*(?:does not match|mismatch)|(?:invalid|unregistered)[-_ ](?:publishing[-_ ])?publisher/i;
8
+ const PUBLISH_JOB = /(?:^|[^a-z])(?:publish(?:ing)?|preflight)(?:[^a-z]|$)/i;
9
+
10
+ /** Only failed publish/preflight jobs (or failed publishing steps) qualify. */
11
+ export function failedPublishingJob(jobs) {
12
+ return jobs.some(
13
+ (job) =>
14
+ job.conclusion === "failure" &&
15
+ (PUBLISH_JOB.test(job.name ?? "") ||
16
+ job.steps?.some(
17
+ (step) =>
18
+ step.conclusion === "failure" && PUBLISH_JOB.test(step.name ?? ""),
19
+ )),
20
+ );
21
+ }
22
+
23
+ /** Keep PRs, tags and other branches out even if a tool ignores its filters. */
24
+ export function eligibleReleaseRuns(runs, branch) {
25
+ const unique = new Map();
26
+ for (const run of runs) {
27
+ if (
28
+ run.headBranch === branch &&
29
+ ["push", "workflow_dispatch"].includes(run.event)
30
+ ) {
31
+ unique.set(run.databaseId, run);
32
+ }
33
+ }
34
+ return [...unique.values()].sort(
35
+ (left, right) =>
36
+ String(right.createdAt).localeCompare(String(left.createdAt)) ||
37
+ Number(right.databaseId) - Number(left.databaseId),
38
+ );
39
+ }
40
+
41
+ /** Cache one selection so inspection and repair retry the same release. */
42
+ export async function selectReleaseRun(session) {
43
+ if (session.releaseSelection) {
44
+ return session.releaseSelection;
45
+ }
46
+ const slug = repositorySlug(session.plan.repository);
47
+ const capture = (args) =>
48
+ session.capture({ command: { program: "gh", args }, cwd: "." });
49
+ const result = await capture([
50
+ "api",
51
+ `repos/${slug}`,
52
+ "--jq",
53
+ ".default_branch",
54
+ ]);
55
+ const branch = String(result.stdout ?? "").trim();
56
+ if (
57
+ result.code !== 0 ||
58
+ !branch ||
59
+ branch.startsWith("-") ||
60
+ /[\s:]/.test(branch)
61
+ ) {
62
+ throw new Error(
63
+ "could not determine the release workflow's default branch",
64
+ );
65
+ }
66
+ const runs = [];
67
+ // Each event gets its own bounded history, so PR traffic cannot hide releases.
68
+ for (const event of ["push", "workflow_dispatch"]) {
69
+ const list = await capture([
70
+ "run",
71
+ "list",
72
+ "--repo",
73
+ slug,
74
+ "--workflow",
75
+ session.plan.trusted_publisher.workflow,
76
+ "--branch",
77
+ branch,
78
+ "--event",
79
+ event,
80
+ "--limit",
81
+ "100",
82
+ "--json",
83
+ RELEASE_RUN_FIELDS,
84
+ ]);
85
+ if (list.code !== 0) {
86
+ throw new Error("could not list the release workflow runs");
87
+ }
88
+ runs.push(...JSON.parse(String(list.stdout || "[]")));
89
+ }
90
+ const eligible = eligibleReleaseRuns(runs, branch);
91
+ for (const run of eligible.filter((item) => item.conclusion === "failure")) {
92
+ const details = await capture([
93
+ "run",
94
+ "view",
95
+ String(run.databaseId),
96
+ "--repo",
97
+ slug,
98
+ "--json",
99
+ "jobs",
100
+ ]);
101
+ if (details.code !== 0) {
102
+ throw new Error("could not read the failed release jobs");
103
+ }
104
+ if (
105
+ failedPublishingJob(JSON.parse(String(details.stdout || "{}")).jobs ?? [])
106
+ ) {
107
+ session.releaseSelection = { run, publishFailure: true, branch };
108
+ return session.releaseSelection;
109
+ }
110
+ }
111
+ session.releaseSelection = {
112
+ run: eligible[0],
113
+ publishFailure: false,
114
+ branch,
115
+ };
116
+ return session.releaseSelection;
117
+ }