@smoothbricks/cli 0.10.1 → 0.10.3

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.
Files changed (77) hide show
  1. package/README.md +25 -13
  2. package/dist/cli.js +24 -0
  3. package/dist/github-ci/index.d.ts +17 -2
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +145 -14
  6. package/dist/github-ci/outputs.d.ts +22 -0
  7. package/dist/github-ci/outputs.d.ts.map +1 -0
  8. package/dist/github-ci/outputs.js +649 -0
  9. package/dist/lib/conflict-markers.d.ts.map +1 -1
  10. package/dist/lib/workspace.d.ts +1 -1
  11. package/dist/lib/workspace.d.ts.map +1 -1
  12. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  13. package/dist/monorepo/ci-workflow.js +2 -1
  14. package/dist/monorepo/managed-files.d.ts +2 -0
  15. package/dist/monorepo/managed-files.d.ts.map +1 -1
  16. package/dist/monorepo/managed-files.js +50 -5
  17. package/dist/monorepo/package-policy.d.ts.map +1 -1
  18. package/dist/monorepo/package-policy.js +26 -0
  19. package/dist/monorepo/packs/index.d.ts +3 -0
  20. package/dist/monorepo/packs/index.d.ts.map +1 -1
  21. package/dist/monorepo/packs/index.js +5 -2
  22. package/dist/monorepo/publish-workflow.d.ts +1 -0
  23. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  24. package/dist/monorepo/publish-workflow.js +240 -2
  25. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  26. package/dist/monorepo/tool-validation.js +20 -7
  27. package/dist/nx/index.d.ts +6 -0
  28. package/dist/nx/index.d.ts.map +1 -1
  29. package/dist/nx/index.js +55 -5
  30. package/dist/release/github-release.d.ts +5 -2
  31. package/dist/release/github-release.d.ts.map +1 -1
  32. package/dist/release/github-release.js +11 -0
  33. package/dist/release/index.d.ts +19 -1
  34. package/dist/release/index.d.ts.map +1 -1
  35. package/dist/release/index.js +140 -31
  36. package/dist/release/orchestration.d.ts +8 -1
  37. package/dist/release/orchestration.d.ts.map +1 -1
  38. package/dist/release/orchestration.js +38 -1
  39. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  40. package/dist/wrangler/prepare-env.js +1 -1
  41. package/dist/wrangler/scaffold.d.ts.map +1 -1
  42. package/dist/wrangler/scaffold.js +1 -1
  43. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +5 -0
  44. package/managed/raw/tooling/direnv/setup-environment.ts +32 -1
  45. package/managed/templates/github/actions/cache-nix-devenv/action.yml +12 -10
  46. package/managed/templates/github/actions/cache-node-modules/action.yml +3 -2
  47. package/managed/templates/github/actions/cache-nx/action.yml +2 -2
  48. package/managed/templates/github/actions/save-nix-devenv/action.yml +11 -3
  49. package/package.json +7 -14
  50. package/src/cli.ts +30 -4
  51. package/src/github-ci/index.test.ts +486 -0
  52. package/src/github-ci/index.ts +177 -16
  53. package/src/github-ci/outputs.ts +506 -0
  54. package/src/monorepo/__tests__/ci-workflow.test.ts +13 -0
  55. package/src/monorepo/__tests__/publish-workflow.test.ts +234 -5
  56. package/src/monorepo/ci-workflow.ts +3 -1
  57. package/src/monorepo/managed-files.test.ts +75 -0
  58. package/src/monorepo/managed-files.ts +61 -5
  59. package/src/monorepo/package-policy.test.ts +57 -38
  60. package/src/monorepo/package-policy.ts +28 -0
  61. package/src/monorepo/packs/index.test.ts +18 -1
  62. package/src/monorepo/packs/index.ts +7 -3
  63. package/src/monorepo/publish-workflow.ts +481 -2
  64. package/src/monorepo/tool-validation.test.ts +59 -24
  65. package/src/monorepo/tool-validation.ts +21 -7
  66. package/src/monorepo/wrangler.test.ts +9 -2
  67. package/src/nx/index.test.ts +39 -0
  68. package/src/nx/index.ts +65 -5
  69. package/src/release/__tests__/fixture-repo.test.ts +18 -16
  70. package/src/release/__tests__/github-release.test.ts +11 -0
  71. package/src/release/__tests__/orchestration.test.ts +57 -2
  72. package/src/release/__tests__/trust-publisher.test.ts +91 -2
  73. package/src/release/github-release.ts +12 -0
  74. package/src/release/index.ts +218 -40
  75. package/src/release/orchestration.ts +55 -3
  76. package/src/wrangler/prepare-env.ts +14 -4
  77. package/src/wrangler/scaffold.ts +8 -3
@@ -3,8 +3,11 @@ import type { BootstrapNpmPackagesOptions } from '../bootstrap-npm-packages.js';
3
3
  import type { ReleasePackageInfo } from '../core.js';
4
4
  import {
5
5
  configureTrustedPublishers,
6
+ npmTrustGithubArgs,
7
+ npmTrustListAccessDenied,
6
8
  parseTrustedPublishers,
7
9
  type TrustedPublisher,
10
+ type TrustedPublisherLookup,
8
11
  type TrustPublisherShell,
9
12
  } from '../index.js';
10
13
 
@@ -101,6 +104,84 @@ describe('trusted publisher setup', () => {
101
104
  expect(shell.events).toEqual([`exists:${missing.name}`, `list:${missing.name}`, `trust:${missing.name}:false`]);
102
105
  });
103
106
 
107
+ it('reports package owners and opens npm login after trust access is denied', async () => {
108
+ const shell = new RecordingTrustPublisherShell({
109
+ packages: [stable],
110
+ existing: [stable.name],
111
+ trustedPublisherResponses: {
112
+ [stable.name]: [
113
+ {
114
+ status: 'access-denied',
115
+ identity: 'current-user',
116
+ owners: 'package-owner <owner@example.test>',
117
+ },
118
+ [],
119
+ ],
120
+ },
121
+ });
122
+
123
+ await configureTrustedPublishers(shell, {});
124
+
125
+ expect(shell.events).toEqual([
126
+ `exists:${stable.name}`,
127
+ `list:${stable.name}`,
128
+ 'login',
129
+ `list:${stable.name}`,
130
+ `trust:${stable.name}:false`,
131
+ ]);
132
+ expect(shell.errors.join('\n')).toContain('npm account "current-user"');
133
+ expect(shell.errors.join('\n')).toContain('package-owner <owner@example.test>');
134
+ expect(shell.logs).toContain(
135
+ `${stable.name}: opening npm browser login. Sign in as a listed package owner, then return here.`,
136
+ );
137
+ });
138
+
139
+ it('stops after one owner login when the selected account still lacks access', async () => {
140
+ const shell = new RecordingTrustPublisherShell({
141
+ packages: [stable],
142
+ existing: [stable.name],
143
+ trustedPublisherResponses: {
144
+ [stable.name]: [
145
+ {
146
+ status: 'access-denied',
147
+ identity: 'current-user',
148
+ owners: 'package-owner <owner@example.test>',
149
+ },
150
+ {
151
+ status: 'access-denied',
152
+ identity: 'wrong-user',
153
+ owners: 'package-owner <owner@example.test>',
154
+ },
155
+ ],
156
+ },
157
+ });
158
+
159
+ await expect(configureTrustedPublishers(shell, {})).rejects.toThrow(
160
+ 'npm browser login completed, but the selected account still cannot manage this package',
161
+ );
162
+ expect(shell.events).toEqual([`exists:${stable.name}`, `list:${stable.name}`, 'login', `list:${stable.name}`]);
163
+ });
164
+
165
+ it('grants the trusted workflow permission to run npm publish', () => {
166
+ expect(npmTrustGithubArgs(stable.name, 'scope/repo', 'publish.yml', false)).toEqual([
167
+ 'trust',
168
+ 'github',
169
+ stable.name,
170
+ '--file',
171
+ 'publish.yml',
172
+ '--repo',
173
+ 'scope/repo',
174
+ '--allow-publish',
175
+ '--yes',
176
+ ]);
177
+ expect(npmTrustGithubArgs(stable.name, 'scope/repo', 'publish.yml', true).at(-1)).toBe('--dry-run');
178
+ });
179
+
180
+ it('recognizes npm trust access-denied output', () => {
181
+ expect(npmTrustListAccessDenied('{"error":{"code":"E403"}}', '')).toBe(true);
182
+ expect(npmTrustListAccessDenied('', 'npm error code EOTP')).toBe(false);
183
+ });
184
+
104
185
  it('parses empty npm trust list output as no trusted publishers', () => {
105
186
  expect(parseTrustedPublishers('', stable.name)).toEqual([]);
106
187
  expect(parseTrustedPublishers('null', stable.name)).toEqual([]);
@@ -116,15 +197,18 @@ class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackage
116
197
  private readonly packages: ReleasePackageInfo[];
117
198
  private readonly existing: Set<string>;
118
199
  private readonly trustedPublisherByPackage: Record<string, TrustedPublisher[]>;
200
+ private readonly trustedPublisherResponses: Record<string, TrustedPublisherLookup[]>;
119
201
 
120
202
  constructor(options: {
121
203
  packages: ReleasePackageInfo[];
122
204
  existing: string[];
123
205
  trustedPublishers?: Record<string, TrustedPublisher[]>;
206
+ trustedPublisherResponses?: Record<string, TrustedPublisherLookup[]>;
124
207
  }) {
125
208
  this.packages = options.packages;
126
209
  this.existing = new Set(options.existing);
127
210
  this.trustedPublisherByPackage = options.trustedPublishers ?? {};
211
+ this.trustedPublisherResponses = options.trustedPublisherResponses ?? {};
128
212
  }
129
213
 
130
214
  listReleasePackages(): ReleasePackageInfo[] {
@@ -145,14 +229,19 @@ class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackage
145
229
  return missingPackages;
146
230
  }
147
231
 
232
+ async login(): Promise<void> {
233
+ this.events.push('login');
234
+ }
235
+
148
236
  async trustPublisher(pkg: ReleasePackageInfo, dryRun: boolean): Promise<'configured' | 'already-configured'> {
149
237
  this.events.push(`trust:${pkg.name}:${dryRun}`);
150
238
  return 'configured';
151
239
  }
152
240
 
153
- async trustedPublishers(pkg: ReleasePackageInfo): Promise<TrustedPublisher[]> {
241
+ async trustedPublishers(pkg: ReleasePackageInfo): Promise<TrustedPublisherLookup> {
154
242
  this.events.push(`list:${pkg.name}`);
155
- return this.trustedPublisherByPackage[pkg.name] ?? [];
243
+ const response = this.trustedPublisherResponses[pkg.name]?.shift();
244
+ return response ?? this.trustedPublisherByPackage[pkg.name] ?? [];
156
245
  }
157
246
 
158
247
  log(message: string): void {
@@ -71,6 +71,7 @@ export function nxProjectChangelogArgs(pkg: ReleasePackageInfo, previousTag: str
71
71
  gitPush: false,
72
72
  stageChanges: false,
73
73
  createRelease: false,
74
+ forceChangelogGeneration: true,
74
75
  deleteVersionPlans: false,
75
76
  dryRun,
76
77
  } satisfies ChangelogOptions;
@@ -85,6 +86,17 @@ export function projectChangelogContents(result: ProjectChangelogLookupResult, p
85
86
  return changelog.contents;
86
87
  }
87
88
 
89
+ export function githubReleaseLookupExists(tag: string, exitCode: number, stdout: string, stderr: string): boolean {
90
+ if (exitCode === 0) {
91
+ return true;
92
+ }
93
+ const details = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n');
94
+ if (/\bHTTP 404\b|release not found/i.test(details)) {
95
+ return false;
96
+ }
97
+ throw new Error(`Unable to inspect GitHub Release ${tag}.${details ? `\n${details}` : ''}`);
98
+ }
99
+
88
100
  export function githubReleaseCommandArgs(
89
101
  tag: string,
90
102
  notesFile: string,
@@ -1,13 +1,14 @@
1
1
  import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
3
+ import { join, resolve } from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { Writable } from 'node:stream';
6
6
  import { $ } from 'bun';
7
+ import { githubCiApplyOutputs, githubCiNxRunMany } from '../github-ci/index.js';
7
8
  import { assertNoConflictMarkers } from '../lib/conflict-markers.js';
8
9
  import { withDevenvEnv } from '../lib/devenv.js';
9
10
  import { isRecord, readJsonObject, stringProperty } from '../lib/json.js';
10
- import { decode, run, runInteractiveStatus, runResult, runStatus } from '../lib/run.js';
11
+ import { decode, run, runInteractiveStatus, runResult } from '../lib/run.js';
11
12
  import { listReleasePackages, readPackageJson, repositoryInfo } from '../lib/workspace.js';
12
13
  import { readPackedPackageJson, validatePackedWorkspaceDependencies } from '../monorepo/packed-manifest.js';
13
14
  import {
@@ -29,14 +30,18 @@ import {
29
30
  } from './core.js';
30
31
  import {
31
32
  createOrUpdateGithubRelease,
33
+ githubReleaseLookupExists,
32
34
  renderNxProjectChangelogContents,
33
35
  withNxWorkspaceRoot,
34
36
  } from './github-release.js';
35
37
  import { publishWithAuthDiagnostics } from './npm-auth.js';
36
38
  import {
37
39
  bumpStableReleaseToNext,
40
+ collectRepairPlatformOutputs,
38
41
  completeReleaseAtHead as completeReleaseAtHeadWithShell,
39
42
  type ReleaseCompletionShell,
43
+ type ReleaseRepairOutputsShell,
44
+ type ReleaseRepairShell,
40
45
  type ReleaseSummary,
41
46
  type ReleaseVersionMode,
42
47
  repairPendingTargets,
@@ -57,6 +62,15 @@ export interface ReleasePublishOptions {
57
62
 
58
63
  export interface ReleaseRepairPendingOptions {
59
64
  dryRun?: boolean;
65
+ platformOutputs?: string;
66
+ ref?: string;
67
+ }
68
+
69
+ export interface ReleasePlatformOutputsOptions {
70
+ bump: string;
71
+ output: string;
72
+ ref?: string;
73
+ targets: string;
60
74
  }
61
75
 
62
76
  export interface ReleaseTrustPublisherOptions {
@@ -162,26 +176,56 @@ export async function releasePublish(root: string, options: ReleasePublishOption
162
176
  }
163
177
 
164
178
  export async function releaseRepairPending(root: string, options: ReleaseRepairPendingOptions): Promise<void> {
165
- const branch = await releaseBranch(root);
166
- const remote = await releaseRemote(root, branch);
167
- console.log(`Repair pending releases: fetching ${remote}/${branch} and tags.`);
168
- await fetchReleaseRefs(root, remote, branch);
169
- const remoteRef = `${remote}/${branch}`;
170
- const restoreRef = (await gitRefExists(root, remoteRef)) ? remoteRef : await gitHead(root);
171
- console.log(`Repair pending releases: planning from ${restoreRef}.`);
172
- const targets = await listPendingReleaseTargets(root, restoreRef);
173
- console.log(
174
- targets.length === 0
175
- ? 'Repair pending releases: no pending durable state repairs found.'
176
- : `Repair pending releases: ${targets.length} release target${targets.length === 1 ? '' : 's'} need repair.`,
179
+ const repairRef = await fetchReleaseRepairRef(root, options.ref);
180
+ const restoreRef = options.ref ? await gitHead(root) : repairRef;
181
+ console.log(`Repair pending releases: planning from ${repairRef}.`);
182
+ const targets = await listPendingReleaseTargets(root, repairRef);
183
+ logPendingReleaseTargets(targets);
184
+ const platformOutputs = options.platformOutputs ? resolve(root, options.platformOutputs) : undefined;
185
+ const summaries = await repairPendingTargets(
186
+ releaseRepairShell(root, platformOutputs),
187
+ targets,
188
+ restoreRef,
189
+ options.dryRun === true,
177
190
  );
178
- for (const target of targets) {
179
- console.log(`Repair pending releases: target ${target.sha.slice(0, 12)} needs ${repairTargetSummary(target)}.`);
180
- }
181
- const summaries = await repairPendingTargets(releaseRepairShell(root), targets, restoreRef, options.dryRun === true);
182
191
  await writeRepairSummary(summaries, options.dryRun === true);
183
192
  }
184
193
 
194
+ export async function releaseCollectPlatformOutputs(
195
+ root: string,
196
+ options: ReleasePlatformOutputsOptions,
197
+ ): Promise<void> {
198
+ const restoreRef = await gitHead(root);
199
+ const releaseRef = await fetchReleaseRepairRef(root, options.ref);
200
+ const packages = releasePackages(root);
201
+ const packagesAtHead = await releasePackagesAtHead(root, packages);
202
+ const currentPackages =
203
+ packagesAtHead.length > 0
204
+ ? packagesAtHead
205
+ : await releasePlatformPackages(root, packages, releaseBumpArg(options.bump));
206
+ console.log(
207
+ currentPackages.length === 0
208
+ ? 'Release platform outputs: no current release packages selected.'
209
+ : `Release platform outputs: building current outputs for ${packageSummary(currentPackages)}.`,
210
+ );
211
+ const outputRoot = resolve(root, options.output);
212
+ await githubCiNxRunMany(root, {
213
+ targets: options.targets,
214
+ projects: releasePackageProjects(currentPackages),
215
+ collectOutputs: join(outputRoot, 'current'),
216
+ allowEmptyProjects: true,
217
+ });
218
+
219
+ console.log(`Repair platform outputs: planning from ${releaseRef}.`);
220
+ const targets = await listPendingReleaseTargets(root, releaseRef);
221
+ logPendingReleaseTargets(targets);
222
+ await collectRepairPlatformOutputs(
223
+ releaseRepairOutputsShell(root, options.targets, join(outputRoot, 'repairs')),
224
+ targets,
225
+ restoreRef,
226
+ );
227
+ }
228
+
185
229
  export async function releaseTrustPublisher(root: string, options: ReleaseTrustPublisherOptions): Promise<void> {
186
230
  const repository = githubRepositoryFromRootPackage(root);
187
231
  const workflow = 'publish.yml';
@@ -203,14 +247,10 @@ export async function releaseTrustPublisher(root: string, options: ReleaseTrustP
203
247
  },
204
248
  bootstrapOptions,
205
249
  ),
206
- trustPublisher: (pkg, dryRun, env) => {
207
- const args = ['trust', 'github', pkg.name, '--file', workflow, '--repo', repository, '--yes'];
208
- if (dryRun) {
209
- args.push('--dry-run');
210
- }
211
- return runLatestNpmTrust(root, args, env);
212
- },
250
+ trustPublisher: (pkg, dryRun, env) =>
251
+ runLatestNpmTrust(root, npmTrustGithubArgs(pkg.name, repository, workflow, dryRun), env),
213
252
  trustedPublishers: (pkg) => listTrustedPublishers(root, pkg),
253
+ login: () => runLatestNpm(root, ['login', '--auth-type=web']),
214
254
  log: (message) => console.log(message),
215
255
  error: (message) => console.error(message),
216
256
  },
@@ -218,6 +258,19 @@ export async function releaseTrustPublisher(root: string, options: ReleaseTrustP
218
258
  );
219
259
  }
220
260
 
261
+ export function npmTrustGithubArgs(
262
+ packageName: string,
263
+ repository: string,
264
+ workflow: string,
265
+ dryRun: boolean,
266
+ ): string[] {
267
+ const args = ['trust', 'github', packageName, '--file', workflow, '--repo', repository, '--allow-publish', '--yes'];
268
+ if (dryRun) {
269
+ args.push('--dry-run');
270
+ }
271
+ return args;
272
+ }
273
+
221
274
  export interface TrustPublisherShell<Package extends ReleasePackageInfo = ReleasePackageInfo> {
222
275
  repository: string;
223
276
  workflow: string;
@@ -225,7 +278,8 @@ export interface TrustPublisherShell<Package extends ReleasePackageInfo = Releas
225
278
  packageExists(name: string): Promise<boolean>;
226
279
  bootstrapNpmPackages(options: BootstrapNpmPackagesOptions): Promise<Package[]>;
227
280
  trustPublisher(pkg: Package, dryRun: boolean, env?: Record<string, string>): Promise<TrustPublisherResult>;
228
- trustedPublishers(pkg: Package): Promise<TrustedPublisher[]>;
281
+ trustedPublishers(pkg: Package): Promise<TrustedPublisherLookup>;
282
+ login(): Promise<void>;
229
283
  log(message: string): void;
230
284
  error(message: string): void;
231
285
  }
@@ -239,6 +293,14 @@ export interface TrustedPublisher {
239
293
  repository?: string;
240
294
  }
241
295
 
296
+ export interface NpmTrustAccessDenied {
297
+ readonly status: 'access-denied';
298
+ readonly identity: string;
299
+ readonly owners: string;
300
+ }
301
+
302
+ export type TrustedPublisherLookup = TrustedPublisher[] | NpmTrustAccessDenied;
303
+
242
304
  export async function configureTrustedPublishers<Package extends ReleasePackageInfo>(
243
305
  shell: TrustPublisherShell<Package>,
244
306
  options: ReleaseTrustPublisherOptions,
@@ -275,7 +337,7 @@ export async function configureTrustedPublishers<Package extends ReleasePackageI
275
337
  const failedPackages: string[] = [];
276
338
  for (const pkg of selectedPackages) {
277
339
  if (!options.dryRun) {
278
- const trustedPublishers = await shell.trustedPublishers(pkg);
340
+ const trustedPublishers = await trustedPublishersWithOwnerLogin(shell, pkg);
279
341
  if (hasMatchingGithubTrustedPublisher(trustedPublishers, shell.repository, shell.workflow)) {
280
342
  shell.log(`${pkg.name}: npm trusted publisher is already configured; skipping.`);
281
343
  continue;
@@ -315,6 +377,33 @@ export async function configureTrustedPublishers<Package extends ReleasePackageI
315
377
  }
316
378
  }
317
379
 
380
+ async function trustedPublishersWithOwnerLogin<Package extends ReleasePackageInfo>(
381
+ shell: TrustPublisherShell<Package>,
382
+ pkg: Package,
383
+ ): Promise<TrustedPublisher[]> {
384
+ let result = await shell.trustedPublishers(pkg);
385
+ if (Array.isArray(result)) {
386
+ return result;
387
+ }
388
+
389
+ shell.error(
390
+ `${pkg.name}: npm trust access denied for npm account "${result.identity}". ` +
391
+ `npm trust requires package write access. Package owners:\n${result.owners}`,
392
+ );
393
+ shell.log(`${pkg.name}: opening npm browser login. Sign in as a listed package owner, then return here.`);
394
+ await shell.login();
395
+
396
+ result = await shell.trustedPublishers(pkg);
397
+ if (Array.isArray(result)) {
398
+ return result;
399
+ }
400
+ throw new Error(
401
+ `${pkg.name}: npm trust access denied for npm account "${result.identity}". ` +
402
+ `npm trust requires package write access. Package owners:\n${result.owners}\n` +
403
+ 'npm browser login completed, but the selected account still cannot manage this package.',
404
+ );
405
+ }
406
+
318
407
  function selectedTrustPublisherPackages<Package extends ReleasePackageInfo>(
319
408
  packages: Package[],
320
409
  selections: string[],
@@ -548,6 +637,19 @@ async function releaseVersionPackages(
548
637
  );
549
638
  }
550
639
 
640
+ async function releasePlatformPackages(
641
+ root: string,
642
+ packages: ReleasePackage[],
643
+ bump: string,
644
+ ): Promise<ReleasePackage[]> {
645
+ const candidates = await releaseVersionPackages(root, packages, bump);
646
+ if (candidates.length === 0) {
647
+ return [];
648
+ }
649
+ // Nx may version additional reverse dependents, so use its dry-run result rather than only the direct candidates.
650
+ return runNxReleaseVersionPreview(root, releasePackageProjects(candidates), bump);
651
+ }
652
+
551
653
  async function runNxReleaseVersion(
552
654
  root: string,
553
655
  projects: string,
@@ -603,6 +705,7 @@ async function runNxReleaseVersionPreview(root: string, projects: string, bump:
603
705
  gitPush: false,
604
706
  stageChanges: false,
605
707
  createRelease: false,
708
+ forceChangelogGeneration: true,
606
709
  dryRun: true,
607
710
  });
608
711
  releasePreviewChangelogs.clear();
@@ -808,17 +911,11 @@ function releaseNextShell(root: string): { bumpStablePackagesToNext(packages: Re
808
911
  };
809
912
  }
810
913
 
811
- function releaseRepairShell(root: string): ReleaseCompletionShell<ReleasePackage> & {
812
- checkout(ref: string): Promise<void>;
813
- withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T>;
814
- beforeRepairTarget(target: ReleaseTarget): void;
815
- afterRepairTarget(target: ReleaseTarget): void;
816
- } {
914
+ function releaseTargetCheckoutShell(root: string) {
817
915
  return {
818
- ...releaseCompletionShell(root),
819
- checkout: (ref) => run('git', ['switch', '--detach', ref], root),
820
- withDevenvEnv: (runWithEnv) => withDevenvEnv(root, runWithEnv),
821
- beforeRepairTarget: (target) => {
916
+ checkout: (ref: string) => run('git', ['switch', '--detach', ref], root),
917
+ withDevenvEnv: <T>(runWithEnv: () => Promise<T>) => withDevenvEnv(root, runWithEnv),
918
+ beforeRepairTarget: (target: ReleaseTarget) => {
822
919
  console.log(`::group::Repair pending release ${target.sha.slice(0, 12)} (${packageSummary(target.packages)})`);
823
920
  },
824
921
  afterRepairTarget: () => {
@@ -827,6 +924,37 @@ function releaseRepairShell(root: string): ReleaseCompletionShell<ReleasePackage
827
924
  };
828
925
  }
829
926
 
927
+ function releaseRepairShell(root: string, platformOutputs?: string): ReleaseRepairShell<ReleasePackage> {
928
+ return {
929
+ ...releaseCompletionShell(root),
930
+ ...releaseTargetCheckoutShell(root),
931
+ prepareRepairTarget: async (target) => {
932
+ if (!platformOutputs || target.npmPackages.length === 0) {
933
+ return;
934
+ }
935
+ const output = join(platformOutputs, target.sha);
936
+ console.log(`Repair pending releases: applying cross-platform outputs from ${output}.`);
937
+ await githubCiApplyOutputs(root, [output], target.sha);
938
+ },
939
+ };
940
+ }
941
+
942
+ function releaseRepairOutputsShell(
943
+ root: string,
944
+ targetGlobs: string,
945
+ outputRoot: string,
946
+ ): ReleaseRepairOutputsShell<ReleasePackage> {
947
+ return {
948
+ ...releaseTargetCheckoutShell(root),
949
+ collectRepairTargetOutputs: (target) =>
950
+ githubCiNxRunMany(root, {
951
+ targets: targetGlobs,
952
+ projects: releasePackageProjects(target.npmPackages),
953
+ collectOutputs: join(outputRoot, target.sha),
954
+ }),
955
+ };
956
+ }
957
+
830
958
  function releaseRetagShell(root: string, remote: string) {
831
959
  return {
832
960
  listReleasePackages: () => releasePackages(root),
@@ -844,6 +972,29 @@ function releaseRetagShell(root: string, remote: string) {
844
972
  };
845
973
  }
846
974
 
975
+ async function fetchReleaseRepairRef(root: string, requestedRef?: string): Promise<string> {
976
+ const branch = await releaseBranch(root);
977
+ const remote = await releaseRemote(root, branch);
978
+ console.log(`Repair pending releases: fetching ${remote}/${branch} and tags.`);
979
+ await fetchReleaseRefs(root, remote, branch);
980
+ if (requestedRef) {
981
+ return requestedRef;
982
+ }
983
+ const remoteRef = `${remote}/${branch}`;
984
+ return (await gitRefExists(root, remoteRef)) ? remoteRef : gitHead(root);
985
+ }
986
+
987
+ function logPendingReleaseTargets(targets: ReleaseTarget[]): void {
988
+ console.log(
989
+ targets.length === 0
990
+ ? 'Repair pending releases: no pending durable state repairs found.'
991
+ : `Repair pending releases: ${targets.length} release target${targets.length === 1 ? '' : 's'} need repair.`,
992
+ );
993
+ for (const target of targets) {
994
+ console.log(`Repair pending releases: target ${target.sha.slice(0, 12)} needs ${repairTargetSummary(target)}.`);
995
+ }
996
+ }
997
+
847
998
  async function listPendingReleaseTargets(root: string, ref: string): Promise<ReleaseTarget[]> {
848
999
  const head = await gitHead(root);
849
1000
  return pendingReleaseTargets(await listOwnedReleaseTagRecords(root, ref), head);
@@ -1069,7 +1220,8 @@ function repairReasons(target: ReleaseTarget, pkg: ReleasePackage): string[] {
1069
1220
  }
1070
1221
 
1071
1222
  async function fetchReleaseRefs(root: string, remote: string, branch: string): Promise<void> {
1072
- await run('git', ['fetch', '--tags', remote, branch], root);
1223
+ // `retag-unpublished` intentionally moves repairable tags, so the remote tag is authoritative.
1224
+ await run('git', ['fetch', '--force', '--tags', remote, branch], root);
1073
1225
  }
1074
1226
 
1075
1227
  async function gitRefExists(root: string, ref: string): Promise<boolean> {
@@ -1309,7 +1461,8 @@ async function pushRetaggedReleaseTags(
1309
1461
  }
1310
1462
 
1311
1463
  async function githubReleaseExists(root: string, tag: string): Promise<boolean> {
1312
- return (await runStatus('gh', ['release', 'view', tag, '--json', 'tagName'], root, true)) === 0;
1464
+ const result = await $`gh release view ${tag} --json tagName`.cwd(root).quiet().nothrow();
1465
+ return githubReleaseLookupExists(tag, result.exitCode, decode(result.stdout), decode(result.stderr));
1313
1466
  }
1314
1467
 
1315
1468
  function githubReleaseUrl(root: string, tag: string): string {
@@ -1465,7 +1618,7 @@ async function runLatestNpmTrust(
1465
1618
  throw new Error(`nix shell nixpkgs#nodejs_latest -c npm ${npmArgs.join(' ')} failed with exit code ${status}`);
1466
1619
  }
1467
1620
 
1468
- async function listTrustedPublishers(root: string, pkg: Pick<ReleasePackage, 'name'>): Promise<TrustedPublisher[]> {
1621
+ async function listTrustedPublishers(root: string, pkg: Pick<ReleasePackage, 'name'>): Promise<TrustedPublisherLookup> {
1469
1622
  const args = ['trust', 'list', pkg.name, '--json'];
1470
1623
  let result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
1471
1624
  if (result.exitCode !== 0 && /\bEOTP\b/.test(`${result.stdout}\n${result.stderr}`)) {
@@ -1475,6 +1628,9 @@ async function listTrustedPublishers(root: string, pkg: Pick<ReleasePackage, 'na
1475
1628
  }
1476
1629
  result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
1477
1630
  }
1631
+ if (result.exitCode !== 0 && npmTrustListAccessDenied(result.stdout, result.stderr)) {
1632
+ return npmTrustAccessDetails(root, pkg.name);
1633
+ }
1478
1634
  if (result.exitCode !== 0) {
1479
1635
  throw new Error(
1480
1636
  `nix shell nixpkgs#nodejs_latest -c npm ${args.join(' ')} failed with exit code ${result.exitCode}`,
@@ -1483,6 +1639,28 @@ async function listTrustedPublishers(root: string, pkg: Pick<ReleasePackage, 'na
1483
1639
  return parseTrustedPublishers(result.stdout, pkg.name);
1484
1640
  }
1485
1641
 
1642
+ export function npmTrustListAccessDenied(stdout: string, stderr: string): boolean {
1643
+ return /\bE403\b/.test(`${stdout}\n${stderr}`);
1644
+ }
1645
+
1646
+ async function npmTrustAccessDetails(root: string, packageName: string): Promise<NpmTrustAccessDenied> {
1647
+ const [identityResult, ownersResult] = await Promise.all([
1648
+ runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', 'whoami'], root),
1649
+ runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', 'owner', 'ls', packageName], root),
1650
+ ]);
1651
+ return {
1652
+ status: 'access-denied',
1653
+ identity:
1654
+ identityResult.exitCode === 0 && identityResult.stdout.trim().length > 0
1655
+ ? identityResult.stdout.trim()
1656
+ : '(not authenticated)',
1657
+ owners:
1658
+ ownersResult.exitCode === 0 && ownersResult.stdout.trim().length > 0
1659
+ ? ownersResult.stdout.trim()
1660
+ : '(owner lookup unavailable)',
1661
+ };
1662
+ }
1663
+
1486
1664
  export function parseTrustedPublishers(stdout: string, packageName: string): TrustedPublisher[] {
1487
1665
  if (stdout.trim().length === 0) {
1488
1666
  return [];
@@ -30,14 +30,24 @@ export interface ReleaseNextShell<Package extends ReleasePackageInfo = ReleasePa
30
30
  bumpStablePackagesToNext(packages: Package[]): Promise<void>;
31
31
  }
32
32
 
33
- export interface ReleaseRepairShell<Package extends ReleasePackageInfo = ReleasePackageInfo>
34
- extends ReleaseCompletionShell<Package> {
33
+ export interface ReleaseTargetCheckoutShell<Package extends ReleasePackageInfo = ReleasePackageInfo> {
35
34
  checkout(ref: string): Promise<void>;
36
35
  withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T>;
37
36
  beforeRepairTarget?(target: ReleaseTarget<Package>): void;
38
37
  afterRepairTarget?(target: ReleaseTarget<Package>): void;
39
38
  }
40
39
 
40
+ export interface ReleaseRepairShell<Package extends ReleasePackageInfo = ReleasePackageInfo>
41
+ extends ReleaseCompletionShell<Package>,
42
+ ReleaseTargetCheckoutShell<Package> {
43
+ prepareRepairTarget?(target: ReleaseTarget<Package>): Promise<void>;
44
+ }
45
+
46
+ export interface ReleaseRepairOutputsShell<Package extends ReleasePackageInfo = ReleasePackageInfo>
47
+ extends ReleaseTargetCheckoutShell<Package> {
48
+ collectRepairTargetOutputs(target: ReleaseTarget<Package>): Promise<void>;
49
+ }
50
+
41
51
  export interface ReleaseVersionShell<Package extends ReleasePackageInfo = ReleasePackageInfo> {
42
52
  releasePackagesAtHead(): Promise<Package[]>;
43
53
  releaseVersionPackages(bump: string): Promise<Package[]>;
@@ -151,17 +161,59 @@ export async function repairPendingTargets<Package extends ReleasePackageInfo>(
151
161
  }
152
162
  shell.beforeRepairTarget?.(target);
153
163
  try {
154
- summaries.push(await shell.withDevenvEnv(() => completeRepairTargetAtHead(shell, target, dryRun)));
164
+ summaries.push(
165
+ await shell.withDevenvEnv(async () => {
166
+ await shell.prepareRepairTarget?.(target);
167
+ return completeRepairTargetAtHead(shell, target, dryRun);
168
+ }),
169
+ );
155
170
  } finally {
156
171
  shell.afterRepairTarget?.(target);
157
172
  }
158
173
  }
159
174
  } finally {
160
175
  await shell.checkout(restoreRef);
176
+ if (targets.length > 0) {
177
+ // Loading devenv at a historical release can rebuild ignored tool outputs
178
+ // from that checkout. Refresh after restoring HEAD so later release phases
179
+ // cannot combine the current CLI with an older Nx plugin build.
180
+ await shell.withDevenvEnv(async () => undefined);
181
+ }
161
182
  }
162
183
  return summaries;
163
184
  }
164
185
 
186
+ export async function collectRepairPlatformOutputs<Package extends ReleasePackageInfo>(
187
+ shell: ReleaseRepairOutputsShell<Package>,
188
+ targets: Array<ReleaseTarget<Package>>,
189
+ restoreRef: string,
190
+ ): Promise<void> {
191
+ const outputTargets = targets.filter((target) => target.npmPackages.length > 0);
192
+ if (outputTargets.length === 0) {
193
+ return;
194
+ }
195
+ try {
196
+ for (const target of outputTargets) {
197
+ await shell.checkout(target.sha);
198
+ if (target.packages.length === 0) {
199
+ // invariant throw: release target discovery must never yield a package-free target.
200
+ throw new Error(`Release commit ${target.sha} has no release packages after checkout.`);
201
+ }
202
+ shell.beforeRepairTarget?.(target);
203
+ try {
204
+ await shell.withDevenvEnv(() => shell.collectRepairTargetOutputs(target));
205
+ } finally {
206
+ shell.afterRepairTarget?.(target);
207
+ }
208
+ }
209
+ } finally {
210
+ await shell.checkout(restoreRef);
211
+ // Historical devenv activation may rebuild ignored tool outputs. Restore the
212
+ // dispatch environment before the macOS job performs any further work.
213
+ await shell.withDevenvEnv(async () => undefined);
214
+ }
215
+ }
216
+
165
217
  function repairDryRunSummary<Package extends ReleasePackageInfo>(
166
218
  target: ReleaseTarget<Package>,
167
219
  ): ReleaseSummary<Package> {