@smoothbricks/cli 0.2.0 → 0.3.1

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 (68) hide show
  1. package/README.md +29 -24
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +19 -3
  4. package/dist/generate/index.d.ts +20 -0
  5. package/dist/generate/index.d.ts.map +1 -0
  6. package/dist/generate/index.js +47 -0
  7. package/dist/lib/run.d.ts +1 -0
  8. package/dist/lib/run.d.ts.map +1 -1
  9. package/dist/lib/run.js +19 -0
  10. package/dist/lib/workspace.d.ts +10 -0
  11. package/dist/lib/workspace.d.ts.map +1 -1
  12. package/dist/lib/workspace.js +55 -16
  13. package/dist/monorepo/lockfile.d.ts.map +1 -1
  14. package/dist/monorepo/lockfile.js +9 -0
  15. package/dist/monorepo/package-policy.d.ts +6 -17
  16. package/dist/monorepo/package-policy.d.ts.map +1 -1
  17. package/dist/monorepo/package-policy.js +63 -1127
  18. package/dist/monorepo/publish-workflow.js +2 -3
  19. package/dist/nx/index.d.ts +1 -0
  20. package/dist/nx/index.d.ts.map +1 -1
  21. package/dist/nx/index.js +23 -5
  22. package/dist/release/candidates.d.ts +1 -0
  23. package/dist/release/candidates.d.ts.map +1 -1
  24. package/dist/release/candidates.js +12 -0
  25. package/dist/release/core.d.ts +3 -1
  26. package/dist/release/core.d.ts.map +1 -1
  27. package/dist/release/core.js +76 -9
  28. package/dist/release/github-release.d.ts +1 -0
  29. package/dist/release/github-release.d.ts.map +1 -1
  30. package/dist/release/github-release.js +1 -1
  31. package/dist/release/index.d.ts +9 -3
  32. package/dist/release/index.d.ts.map +1 -1
  33. package/dist/release/index.js +287 -39
  34. package/dist/release/orchestration.d.ts +10 -2
  35. package/dist/release/orchestration.d.ts.map +1 -1
  36. package/dist/release/orchestration.js +33 -3
  37. package/managed/raw/tooling/git-hooks/commit-msg.sh +7 -1
  38. package/managed/raw/tooling/git-hooks/pre-commit.sh +7 -1
  39. package/managed/templates/github/workflows/ci.yml +3 -2
  40. package/package.json +7 -12
  41. package/src/cli.ts +21 -4
  42. package/src/generate/index.ts +92 -0
  43. package/src/lib/run.ts +25 -0
  44. package/src/lib/workspace.ts +72 -16
  45. package/src/monorepo/lockfile.ts +9 -0
  46. package/src/monorepo/package-policy.test.ts +254 -26
  47. package/src/monorepo/package-policy.ts +88 -1351
  48. package/src/monorepo/publish-workflow.ts +2 -3
  49. package/src/nx/index.test.ts +9 -0
  50. package/src/nx/index.ts +25 -5
  51. package/src/release/__tests__/candidates.test.ts +109 -8
  52. package/src/release/__tests__/core-properties.test.ts +2 -2
  53. package/src/release/__tests__/core-scenarios.test.ts +12 -15
  54. package/src/release/__tests__/core.test.ts +50 -3
  55. package/src/release/__tests__/fixture-repo.test.ts +68 -18
  56. package/src/release/__tests__/github-release.test.ts +9 -16
  57. package/src/release/__tests__/orchestration.test.ts +91 -5
  58. package/src/release/__tests__/retag-unpublished.test.ts +9 -9
  59. package/src/release/__tests__/trust-publisher.test.ts +65 -33
  60. package/src/release/candidates.ts +13 -0
  61. package/src/release/core.ts +95 -10
  62. package/src/release/github-release.ts +1 -1
  63. package/src/release/index.ts +366 -49
  64. package/src/release/orchestration.ts +50 -5
  65. package/dist/nx-version-actions.cjs +0 -25
  66. package/dist/nx-version-actions.d.cts +0 -6
  67. package/dist/nx-version-actions.d.cts.map +0 -1
  68. package/src/nx-version-actions.cts +0 -36
@@ -6,7 +6,7 @@ import { Writable } from 'node:stream';
6
6
  import { $ } from 'bun';
7
7
  import { withDevenvEnv } from '../lib/devenv.js';
8
8
  import { isRecord, readJsonObject, stringProperty } from '../lib/json.js';
9
- import { decode, run, runResult, runStatus } from '../lib/run.js';
9
+ import { decode, run, runInteractiveStatus, runResult, runStatus } from '../lib/run.js';
10
10
  import { listReleasePackages, readPackageJson, repositoryInfo } from '../lib/workspace.js';
11
11
  import { readPackedPackageJson, validatePackedWorkspaceDependencies } from '../monorepo/packed-manifest.js';
12
12
  import {
@@ -24,10 +24,16 @@ import {
24
24
  pendingReleaseTargets,
25
25
  type ReleasePackageInfo,
26
26
  releaseTag,
27
+ releaseTagAliases,
27
28
  } from './core.js';
28
- import { createOrUpdateGithubRelease, renderNxProjectChangelogContents } from './github-release.js';
29
+ import {
30
+ createOrUpdateGithubRelease,
31
+ renderNxProjectChangelogContents,
32
+ withNxWorkspaceRoot,
33
+ } from './github-release.js';
29
34
  import { publishWithAuthDiagnostics } from './npm-auth.js';
30
35
  import {
36
+ bumpStableReleaseToNext,
31
37
  completeReleaseAtHead as completeReleaseAtHeadWithShell,
32
38
  type ReleaseCompletionShell,
33
39
  type ReleaseSummary,
@@ -55,9 +61,9 @@ export interface ReleaseRepairPendingOptions {
55
61
  export interface ReleaseTrustPublisherOptions {
56
62
  dryRun?: boolean;
57
63
  bootstrap?: boolean;
58
- otp?: string;
59
64
  bootstrapOtp?: string;
60
65
  skipLogin?: boolean;
66
+ packages?: string[];
61
67
  }
62
68
 
63
69
  export interface ReleaseBootstrapNpmPackagesOptions {
@@ -97,6 +103,9 @@ export async function releaseVersion(root: string, options: ReleaseVersionOption
97
103
  } else if (result.status === 'no-release-needed') {
98
104
  console.log('Nx did not create a release commit; no release needed.');
99
105
  }
106
+ if (result.status === 'dry-run') {
107
+ await writeReleasePreviewSummary(root, packages, result.packages);
108
+ }
100
109
  await writeReleaseGithubOutput(options.githubOutput, result.packages, result.mode);
101
110
  }
102
111
 
@@ -105,15 +114,22 @@ export async function releasePublish(root: string, options: ReleasePublishOption
105
114
  const packages = await releasePackagesAtHead(root, releasePackages(root));
106
115
  if (packages.length === 0) {
107
116
  if (bump === 'auto') {
117
+ if (options.dryRun === true) {
118
+ console.log(
119
+ 'No release tags found at HEAD because dry-run versioning does not create release commits or tags.',
120
+ );
121
+ return;
122
+ }
108
123
  console.log('No release tags found at HEAD; no release to publish.');
109
124
  const summary: ReleaseSummary<ReleasePackage> = {
110
125
  sha: await gitHead(root),
111
- dryRun: options.dryRun === true,
126
+ dryRun: false,
112
127
  packages,
113
128
  pushed: false,
114
129
  published: [],
115
130
  alreadyPublished: [],
116
131
  githubReleases: [],
132
+ githubReleaseLinks: [],
117
133
  rerunRequired: false,
118
134
  noRelease: true,
119
135
  };
@@ -129,6 +145,15 @@ export async function releasePublish(root: string, options: ReleasePublishOption
129
145
  await newerCommitsRemain(root),
130
146
  );
131
147
  await writeReleaseSummary(summary);
148
+ const bumpedPackages = await bumpStableReleaseToNext(
149
+ releaseNextShell(root),
150
+ packages,
151
+ options.dryRun === true,
152
+ summary.rerunRequired,
153
+ );
154
+ if (bumpedPackages.length > 0) {
155
+ console.log(`Prepared next prerelease for ${packageSummary(bumpedPackages)}.`);
156
+ }
132
157
  }
133
158
 
134
159
  export async function releaseRepairPending(root: string, options: ReleaseRepairPendingOptions): Promise<void> {
@@ -145,6 +170,9 @@ export async function releaseRepairPending(root: string, options: ReleaseRepairP
145
170
  ? 'Repair pending releases: no pending durable state repairs found.'
146
171
  : `Repair pending releases: ${targets.length} release target${targets.length === 1 ? '' : 's'} need repair.`,
147
172
  );
173
+ for (const target of targets) {
174
+ console.log(`Repair pending releases: target ${target.sha.slice(0, 12)} needs ${repairTargetSummary(target)}.`);
175
+ }
148
176
  const summaries = await repairPendingTargets(releaseRepairShell(root), targets, restoreRef, options.dryRun === true);
149
177
  await writeRepairSummary(summaries, options.dryRun === true);
150
178
  }
@@ -170,7 +198,6 @@ export async function releaseTrustPublisher(root: string, options: ReleaseTrustP
170
198
  },
171
199
  bootstrapOptions,
172
200
  ),
173
- login: () => runLatestNpm(root, ['login', '--auth-type=web']),
174
201
  trustPublisher: (pkg, dryRun, env) => {
175
202
  const args = ['trust', 'github', pkg.name, '--file', workflow, '--repo', repository, '--yes'];
176
203
  if (dryRun) {
@@ -178,7 +205,7 @@ export async function releaseTrustPublisher(root: string, options: ReleaseTrustP
178
205
  }
179
206
  return runLatestNpmTrust(root, args, env);
180
207
  },
181
- promptOtp: (packageName) => promptForNpmOtp(packageName),
208
+ trustedPublishers: (pkg) => listTrustedPublishers(root, pkg),
182
209
  log: (message) => console.log(message),
183
210
  error: (message) => console.error(message),
184
211
  },
@@ -192,38 +219,43 @@ export interface TrustPublisherShell<Package extends ReleasePackageInfo = Releas
192
219
  listReleasePackages(): Package[];
193
220
  packageExists(name: string): Promise<boolean>;
194
221
  bootstrapNpmPackages(options: BootstrapNpmPackagesOptions): Promise<Package[]>;
195
- login(): Promise<void>;
196
222
  trustPublisher(pkg: Package, dryRun: boolean, env?: Record<string, string>): Promise<TrustPublisherResult>;
197
- promptOtp(packageName: string): Promise<string>;
223
+ trustedPublishers(pkg: Package): Promise<TrustedPublisher[]>;
198
224
  log(message: string): void;
199
225
  error(message: string): void;
200
226
  }
201
227
 
202
228
  export type TrustPublisherResult = 'configured' | 'already-configured';
203
229
 
230
+ export interface TrustedPublisher {
231
+ id: string;
232
+ type: string;
233
+ file?: string;
234
+ repository?: string;
235
+ }
236
+
204
237
  export async function configureTrustedPublishers<Package extends ReleasePackageInfo>(
205
238
  shell: TrustPublisherShell<Package>,
206
239
  options: ReleaseTrustPublisherOptions,
207
240
  ): Promise<void> {
208
241
  const packages = shell.listReleasePackages();
209
- if (packages.length === 0) {
242
+ const selectedPackages = selectedTrustPublisherPackages(packages, options.packages ?? []);
243
+ if (selectedPackages.length === 0) {
210
244
  throw new Error('No owned release packages found.');
211
245
  }
212
246
 
213
- let bootstrapLoggedIn = false;
214
247
  if (options.bootstrap) {
215
- const bootstrapped = await shell.bootstrapNpmPackages({
248
+ await shell.bootstrapNpmPackages({
216
249
  dryRun: options.dryRun === true,
217
250
  skipLogin: options.skipLogin === true,
218
251
  otp: options.bootstrapOtp,
219
- packages: [],
252
+ packages: options.packages ?? [],
220
253
  });
221
- bootstrapLoggedIn = bootstrapped.length > 0 && !options.dryRun && !options.skipLogin;
222
254
  }
223
255
 
224
256
  if (!options.dryRun) {
225
257
  const packageStates = await Promise.all(
226
- packages.map(async (pkg) => ((await shell.packageExists(pkg.name)) ? null : pkg.name)),
258
+ selectedPackages.map(async (pkg) => ((await shell.packageExists(pkg.name)) ? null : pkg.name)),
227
259
  );
228
260
  const missingPackages = packageStates.filter((name): name is string => name !== null);
229
261
  if (missingPackages.length > 0) {
@@ -235,21 +267,26 @@ export async function configureTrustedPublishers<Package extends ReleasePackageI
235
267
  }
236
268
  }
237
269
 
238
- if (!options.dryRun && !options.skipLogin && !bootstrapLoggedIn) {
239
- await shell.login();
240
- }
241
-
242
270
  const failedPackages: string[] = [];
243
- for (const pkg of packages) {
271
+ for (const pkg of selectedPackages) {
272
+ if (!options.dryRun) {
273
+ const trustedPublishers = await shell.trustedPublishers(pkg);
274
+ if (hasMatchingGithubTrustedPublisher(trustedPublishers, shell.repository, shell.workflow)) {
275
+ shell.log(`${pkg.name}: npm trusted publisher is already configured; skipping.`);
276
+ continue;
277
+ }
278
+ if (trustedPublishers.length > 0) {
279
+ throw new Error(
280
+ `${pkg.name}: npm trusted publisher exists but does not match GitHub Actions ${shell.repository}/${shell.workflow}. ` +
281
+ 'Use npm trust list to inspect it and npm trust revoke --id <trust-id> before reconfiguring.',
282
+ );
283
+ }
284
+ }
285
+
244
286
  shell.log(`${pkg.name}: trusting GitHub Actions ${shell.repository}/${shell.workflow}`);
245
287
  while (true) {
246
- const otp = options.dryRun ? undefined : (options.otp ?? (await shell.promptOtp(pkg.name)));
247
288
  try {
248
- const result = await shell.trustPublisher(
249
- pkg,
250
- options.dryRun === true,
251
- otp ? { NPM_CONFIG_OTP: otp } : undefined,
252
- );
289
+ const result = await shell.trustPublisher(pkg, options.dryRun === true);
253
290
  if (result === 'already-configured') {
254
291
  shell.log(`${pkg.name}: npm trusted publisher is already configured; skipping.`);
255
292
  }
@@ -258,9 +295,9 @@ export async function configureTrustedPublishers<Package extends ReleasePackageI
258
295
  shell.error(`${pkg.name}: npm trusted publisher setup failed.`);
259
296
  shell.error(error instanceof Error ? error.message : String(error));
260
297
  if (!options.dryRun) {
261
- shell.error('npm OTP codes are single-use. Generate a fresh OTP before retrying this package.');
298
+ shell.error('npm owns trusted publishing authentication. Complete the npm browser challenge, then retry.');
262
299
  }
263
- if (options.otp || options.dryRun) {
300
+ if (options.dryRun) {
264
301
  failedPackages.push(pkg.name);
265
302
  break;
266
303
  }
@@ -270,10 +307,44 @@ export async function configureTrustedPublishers<Package extends ReleasePackageI
270
307
  }
271
308
  if (failedPackages.length > 0) {
272
309
  shell.error(`Trusted publishing was not configured for: ${failedPackages.join(', ')}`);
273
- if (options.otp) {
274
- shell.error('Rerun smoo release trust-publisher without --otp to enter a fresh OTP for each failed package.');
310
+ }
311
+ }
312
+
313
+ function selectedTrustPublisherPackages<Package extends ReleasePackageInfo>(
314
+ packages: Package[],
315
+ selections: string[],
316
+ ): Package[] {
317
+ if (selections.length === 0) {
318
+ return packages;
319
+ }
320
+ const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
321
+ const selected: Package[] = [];
322
+ const unknown: string[] = [];
323
+ for (const name of selections) {
324
+ const pkg = byName.get(name);
325
+ if (pkg) {
326
+ selected.push(pkg);
327
+ } else {
328
+ unknown.push(name);
275
329
  }
276
330
  }
331
+ if (unknown.length > 0) {
332
+ throw new Error(`Unknown owned release package selection: ${unknown.join(', ')}`);
333
+ }
334
+ return selected;
335
+ }
336
+
337
+ function hasMatchingGithubTrustedPublisher(
338
+ trustedPublishers: TrustedPublisher[],
339
+ repository: string,
340
+ workflow: string,
341
+ ): boolean {
342
+ return trustedPublishers.some(
343
+ (trustedPublisher) =>
344
+ trustedPublisher.type === 'github' &&
345
+ trustedPublisher.repository === repository &&
346
+ trustedPublisher.file === workflow,
347
+ );
277
348
  }
278
349
 
279
350
  export async function releaseBootstrapNpmPackages(
@@ -325,6 +396,7 @@ interface ReleaseState {
325
396
  }
326
397
 
327
398
  type ReleasePackage = ReturnType<typeof listReleasePackages>[number];
399
+ const releasePreviewChangelogs = new Map<string, string>();
328
400
 
329
401
  type ReleaseTarget = CoreReleaseTarget<ReleasePackage>;
330
402
  type ReleaseTagRecord = CoreReleaseTagRecord<ReleasePackage>;
@@ -459,6 +531,7 @@ async function releaseVersionPackages(
459
531
  return autoReleaseCandidatePackages(
460
532
  {
461
533
  gitRefExists: (ref) => gitRefExists(root, ref),
534
+ latestStableReleaseRef: (projectName) => latestStableReleaseRef(root, projectName),
462
535
  packageChangedFilesSince: (ref, packagePath) => packageChangedFilesSince(root, ref, packagePath),
463
536
  packageJsonAtRef: (ref, packagePath) => packageJsonAtRef(root, ref, packagePath),
464
537
  currentPackageJson: (packagePath) => currentPackageJson(root, packagePath),
@@ -470,7 +543,16 @@ async function releaseVersionPackages(
470
543
  );
471
544
  }
472
545
 
473
- async function runNxReleaseVersion(root: string, projects: string, bump: string, dryRun: boolean): Promise<void> {
546
+ async function runNxReleaseVersion(
547
+ root: string,
548
+ projects: string,
549
+ bump: string,
550
+ dryRun: boolean,
551
+ ): Promise<ReleasePackage[]> {
552
+ if (dryRun) {
553
+ return runNxReleaseVersionPreview(root, projects, bump);
554
+ }
555
+
474
556
  // Nx owns local release mutation: package versions, bun.lock updates, the
475
557
  // release commit, and annotated tags. smoo owns remote publication after the
476
558
  // workflow validates the exact release commit Nx produced.
@@ -479,10 +561,78 @@ async function runNxReleaseVersion(root: string, projects: string, bump: string,
479
561
  nxArgs.push(bump);
480
562
  }
481
563
  nxArgs.push(`--projects=${projects}`, '--git-commit=true', '--git-tag=true', '--git-push=false');
482
- if (dryRun) {
483
- nxArgs.push('--dry-run');
484
- }
485
564
  await run('nx', nxArgs, root);
565
+ return [];
566
+ }
567
+
568
+ async function runNxReleaseVersionPreview(root: string, projects: string, bump: string): Promise<ReleasePackage[]> {
569
+ return withNxWorkspaceRoot(root, async () => {
570
+ const { createAPI } = await import('nx/src/command-line/release/version.js');
571
+ const { createAPI: createChangelogAPI } = await import('nx/src/command-line/release/changelog.js');
572
+ const projectNames = projects.split(',').filter(Boolean);
573
+ const result = await createAPI(
574
+ {},
575
+ false,
576
+ )({
577
+ specifier: bump === 'auto' ? undefined : bump,
578
+ projects: projectNames,
579
+ gitCommit: true,
580
+ gitTag: true,
581
+ gitPush: false,
582
+ dryRun: true,
583
+ });
584
+ const changelogResult = await createChangelogAPI(
585
+ {
586
+ changelog: {
587
+ workspaceChangelog: false,
588
+ projectChangelogs: { createRelease: false, file: false },
589
+ },
590
+ },
591
+ false,
592
+ )({
593
+ projects: projectNames,
594
+ versionData: result.projectsVersionData,
595
+ releaseGraph: result.releaseGraph,
596
+ gitCommit: false,
597
+ gitTag: false,
598
+ gitPush: false,
599
+ stageChanges: false,
600
+ createRelease: false,
601
+ dryRun: true,
602
+ });
603
+ releasePreviewChangelogs.clear();
604
+ for (const [projectName, changelog] of Object.entries(changelogResult.projectChangelogs ?? {})) {
605
+ releasePreviewChangelogs.set(projectName, changelog.contents);
606
+ }
607
+ const packagesByProject = new Map(releasePackages(root).map((pkg) => [pkg.projectName, pkg]));
608
+ return Object.entries(result.projectsVersionData)
609
+ .map(([projectName, data]) => {
610
+ const pkg = packagesByProject.get(projectName);
611
+ if (!pkg || !data.newVersion) {
612
+ return null;
613
+ }
614
+ return { ...pkg, version: data.newVersion };
615
+ })
616
+ .filter((pkg): pkg is ReleasePackage => pkg !== null);
617
+ });
618
+ }
619
+
620
+ async function runNxNextPrereleaseVersion(root: string, projects: string): Promise<void> {
621
+ await run(
622
+ 'nx',
623
+ [
624
+ 'release',
625
+ 'version',
626
+ 'prerelease',
627
+ `--projects=${projects}`,
628
+ '--preid=next',
629
+ '--git-commit=true',
630
+ '--git-tag=false',
631
+ '--git-push=false',
632
+ '--git-commit-message=chore(release): prepare next prerelease',
633
+ ],
634
+ root,
635
+ );
486
636
  }
487
637
 
488
638
  async function releasePackagesAtHead(root: string, packages: ReleasePackage[]): Promise<ReleasePackage[]> {
@@ -567,7 +717,7 @@ async function gitTagsAtRef(root: string, ref: string): Promise<string[]> {
567
717
  }
568
718
 
569
719
  async function previousReleaseTag(root: string, pkg: ReleasePackage, currentTag: string): Promise<string | null> {
570
- const result = await $`git tag --list ${`${pkg.name}@*`} --sort=-v:refname --merged ${currentTag}`
720
+ const result = await $`git tag --list ${`${pkg.projectName}@*`} --sort=-v:refname --merged ${currentTag}`
571
721
  .cwd(root)
572
722
  .quiet()
573
723
  .nothrow();
@@ -620,6 +770,12 @@ async function pushReleaseRefs(root: string, packages: ReleasePackage[]): Promis
620
770
  return true;
621
771
  }
622
772
 
773
+ async function pushCurrentBranch(root: string): Promise<void> {
774
+ const branch = await releaseBranch(root);
775
+ const remote = await releaseRemote(root, branch);
776
+ await run('git', ['push', remote, `HEAD:refs/heads/${branch}`], root);
777
+ }
778
+
623
779
  function releaseCompletionShell(root: string): ReleaseCompletionShell<ReleasePackage> {
624
780
  return {
625
781
  gitHead: () => gitHead(root),
@@ -638,6 +794,15 @@ function releaseCompletionShell(root: string): ReleaseCompletionShell<ReleasePac
638
794
  };
639
795
  }
640
796
 
797
+ function releaseNextShell(root: string): { bumpStablePackagesToNext(packages: ReleasePackage[]): Promise<void> } {
798
+ return {
799
+ bumpStablePackagesToNext: async (packages) => {
800
+ await runNxNextPrereleaseVersion(root, releasePackageProjects(packages));
801
+ await pushCurrentBranch(root);
802
+ },
803
+ };
804
+ }
805
+
641
806
  function releaseRepairShell(root: string): ReleaseCompletionShell<ReleasePackage> & {
642
807
  checkout(ref: string): Promise<void>;
643
808
  withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T>;
@@ -692,11 +857,12 @@ async function listOwnedReleaseTagRecords(root: string, ref: string): Promise<Re
692
857
 
693
858
  async function durableReleaseTagState(root: string, pkg: Pick<ReleasePackage, 'name' | 'version'>, tag: string) {
694
859
  const packageVersion = `${pkg.name}@${pkg.version}`;
860
+ const githubReleaseTags = releaseTagAliases({ ...pkg, projectName: projectNameFromReleaseTag(tag) });
695
861
  const start = Date.now();
696
- console.log(`${packageVersion}: checking durable state (npm + GitHub Release ${tag}).`);
862
+ console.log(`${packageVersion}: checking durable state (npm + GitHub Release ${githubReleaseTags.join(' or ')}).`);
697
863
  const [npmPublished, githubReleasePresent] = await Promise.all([
698
864
  npmVersionExists(root, pkg.name, pkg.version),
699
- githubReleaseExists(root, tag),
865
+ anyGithubReleaseExists(root, githubReleaseTags),
700
866
  ]);
701
867
  console.log(
702
868
  `${packageVersion}: durable state npm=${yesNo(npmPublished)} github=${yesNo(githubReleasePresent)} (${Date.now() - start}ms).`,
@@ -704,6 +870,14 @@ async function durableReleaseTagState(root: string, pkg: Pick<ReleasePackage, 'n
704
870
  return { npmPublished, githubReleaseExists: githubReleasePresent };
705
871
  }
706
872
 
873
+ function projectNameFromReleaseTag(tag: string): string {
874
+ const versionSeparator = tag.lastIndexOf('@');
875
+ if (versionSeparator <= 0) {
876
+ return tag;
877
+ }
878
+ return tag.slice(0, versionSeparator);
879
+ }
880
+
707
881
  async function gitReleaseTagsByCreatorDate(root: string): Promise<GitReleaseTagInfo[]> {
708
882
  const result =
709
883
  await $`git for-each-ref --sort=-creatordate --format=${'%(refname:short)%09%(creatordate:unix)%09%(*objectname)%09%(objectname)'} refs/tags`
@@ -738,7 +912,7 @@ async function listMissingGithubReleasePackages(root: string, packages: ReleaseP
738
912
  ).filter((pkg): pkg is ReleasePackage => pkg !== null);
739
913
  }
740
914
 
741
- async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: boolean): Promise<void> {
915
+ async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: boolean): Promise<string | null> {
742
916
  const currentTag = releaseTag(pkg);
743
917
  console.log(`${pkg.name}@${pkg.version}: rendering GitHub Release notes for ${currentTag}.`);
744
918
  console.log(`GitHub release auth: ${envPresence('GH_TOKEN')}, ${envPresence('GITHUB_TOKEN')}.`);
@@ -748,7 +922,7 @@ async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: bo
748
922
  const previousTag = await previousReleaseTag(root, pkg, currentTag);
749
923
  if (dryRun) {
750
924
  await renderNxProjectChangelogContents({ root, pkg, previousTag, dryRun });
751
- return;
925
+ return null;
752
926
  }
753
927
  const contents = await renderNxProjectChangelogContents({ root, pkg, previousTag, dryRun });
754
928
  await createOrUpdateGithubRelease(pkg, contents, {
@@ -756,6 +930,7 @@ async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: bo
756
930
  runGhRelease: (args) => run('gh', args, root),
757
931
  log: (message) => console.log(message),
758
932
  });
933
+ return githubReleaseUrl(root, currentTag);
759
934
  }
760
935
 
761
936
  function envPresence(name: string): string {
@@ -793,7 +968,7 @@ async function writeReleaseSummary(summary: ReleaseSummary<ReleasePackage>): Pro
793
968
  lines.push(`- Git refs pushed: ${summary.pushed ? 'yes' : 'already current'}`);
794
969
  lines.push(`- npm published: ${packageSummary(summary.published)}`);
795
970
  lines.push(`- npm already published: ${packageSummary(summary.alreadyPublished)}`);
796
- lines.push(`- GitHub Releases created/updated: ${packageSummary(summary.githubReleases)}`);
971
+ lines.push(`- GitHub Releases created/updated: ${githubReleaseSummary(summary)}`);
797
972
  }
798
973
  if (summary.rerunRequired) {
799
974
  const message = 'A previous incomplete release was repaired; newer commits remain. Run Publish again.';
@@ -808,13 +983,45 @@ async function writeReleaseSummary(summary: ReleaseSummary<ReleasePackage>): Pro
808
983
  }
809
984
  }
810
985
 
986
+ async function writeReleasePreviewSummary(
987
+ root: string,
988
+ currentPackages: ReleasePackage[],
989
+ previewPackages: ReleasePackage[],
990
+ ): Promise<void> {
991
+ const lines = ['## Release Preview', '', `- Commit: \`${(await gitHead(root)).slice(0, 12)}\``, '- Mode: dry run'];
992
+ if (previewPackages.length === 0) {
993
+ lines.push('- Result: no release needed');
994
+ } else {
995
+ lines.push('- Packages:');
996
+ const currentByName = new Map(currentPackages.map((pkg) => [pkg.name, pkg]));
997
+ for (const pkg of previewPackages) {
998
+ lines.push(` - \`${pkg.name}\`: \`${currentByName.get(pkg.name)?.version ?? 'unknown'}\` -> \`${pkg.version}\``);
999
+ }
1000
+ for (const pkg of previewPackages) {
1001
+ const contents = releasePreviewChangelogs.get(pkg.projectName);
1002
+ if (!contents) {
1003
+ throw new Error(`Nx did not generate a project changelog for ${pkg.projectName}.`);
1004
+ }
1005
+ lines.push('', `### ${pkg.name} ${pkg.version}`, '', contents.trim());
1006
+ }
1007
+ }
1008
+ const text = `${lines.join('\n')}\n`;
1009
+ console.log(text.trimEnd());
1010
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
1011
+ if (summaryPath) {
1012
+ await appendFile(summaryPath, `${text}\n`);
1013
+ }
1014
+ }
1015
+
811
1016
  async function writeRepairSummary(summaries: Array<ReleaseSummary<ReleasePackage>>, dryRun: boolean): Promise<void> {
812
1017
  const lines = ['## Pending Release Repair', '', `- Mode: ${dryRun ? 'dry run' : 'release'}`];
813
1018
  if (summaries.length === 0) {
814
1019
  lines.push('- Result: no pending releases needed repair');
815
1020
  } else {
816
1021
  for (const summary of summaries) {
817
- lines.push(`- Repaired \`${summary.sha.slice(0, 12)}\`: ${packageSummary(summary.packages)}`);
1022
+ lines.push(
1023
+ `- ${dryRun ? 'Would repair' : 'Repaired'} \`${summary.sha.slice(0, 12)}\`: ${packageSummary(summary.packages)}`,
1024
+ );
818
1025
  }
819
1026
  }
820
1027
  const text = `${lines.join('\n')}\n`;
@@ -832,6 +1039,30 @@ function packageSummary(packages: ReleasePackage[]): string {
832
1039
  return packages.map((pkg) => `${pkg.name}@${pkg.version}`).join(', ');
833
1040
  }
834
1041
 
1042
+ function githubReleaseSummary(summary: ReleaseSummary<ReleasePackage>): string {
1043
+ if (summary.githubReleaseLinks.length > 0) {
1044
+ return summary.githubReleaseLinks.map(({ pkg, url }) => `[${pkg.name}@${pkg.version}](${url})`).join(', ');
1045
+ }
1046
+ return packageSummary(summary.githubReleases);
1047
+ }
1048
+
1049
+ function repairTargetSummary(target: ReleaseTarget): string {
1050
+ return target.packages
1051
+ .map((pkg) => `${pkg.name}@${pkg.version} (${repairReasons(target, pkg).join(' + ')})`)
1052
+ .join(', ');
1053
+ }
1054
+
1055
+ function repairReasons(target: ReleaseTarget, pkg: ReleasePackage): string[] {
1056
+ const reasons: string[] = [];
1057
+ if (target.npmPackages.includes(pkg)) {
1058
+ reasons.push('npm missing');
1059
+ }
1060
+ if (target.githubPackages.includes(pkg)) {
1061
+ reasons.push('GitHub Release missing');
1062
+ }
1063
+ return reasons;
1064
+ }
1065
+
835
1066
  async function fetchReleaseRefs(root: string, remote: string, branch: string): Promise<void> {
836
1067
  await run('git', ['fetch', '--tags', remote, branch], root);
837
1068
  }
@@ -841,6 +1072,28 @@ async function gitRefExists(root: string, ref: string): Promise<boolean> {
841
1072
  return result.exitCode === 0;
842
1073
  }
843
1074
 
1075
+ async function latestStableReleaseRef(root: string, projectName: string): Promise<string | null> {
1076
+ // List tags matching <projectName>@* sorted newest-first by version, then
1077
+ // return the first non-prerelease tag as a full ref.
1078
+ const pattern = `${projectName}@*`;
1079
+ const result = await $`git tag --list ${pattern} --sort=-v:refname`.cwd(root).quiet().nothrow();
1080
+ if (result.exitCode !== 0) {
1081
+ return null;
1082
+ }
1083
+ const tags = decode(result.stdout)
1084
+ .split('\n')
1085
+ .map((t) => t.trim())
1086
+ .filter(Boolean);
1087
+ const prefix = `${projectName}@`;
1088
+ for (const tagName of tags) {
1089
+ const version = tagName.slice(prefix.length);
1090
+ if (version && !version.includes('-')) {
1091
+ return `refs/tags/${tagName}`;
1092
+ }
1093
+ }
1094
+ return null;
1095
+ }
1096
+
844
1097
  async function packageChangedFilesSince(root: string, ref: string, packagePath: string): Promise<string[]> {
845
1098
  const result = await $`git diff --name-only ${`${ref}..HEAD`} -- ${packagePath}`.cwd(root).quiet().nothrow();
846
1099
  if (result.exitCode !== 0) {
@@ -1054,6 +1307,19 @@ async function githubReleaseExists(root: string, tag: string): Promise<boolean>
1054
1307
  return (await runStatus('gh', ['release', 'view', tag, '--json', 'tagName'], root, true)) === 0;
1055
1308
  }
1056
1309
 
1310
+ function githubReleaseUrl(root: string, tag: string): string {
1311
+ return `https://github.com/${githubRepositoryFromRootPackage(root)}/releases/tag/${encodeURIComponent(tag)}`;
1312
+ }
1313
+
1314
+ async function anyGithubReleaseExists(root: string, tags: string[]): Promise<boolean> {
1315
+ for (const tag of tags) {
1316
+ if (await githubReleaseExists(root, tag)) {
1317
+ return true;
1318
+ }
1319
+ }
1320
+ return false;
1321
+ }
1322
+
1057
1323
  async function assertRemoteTagExists(root: string, tag: string): Promise<void> {
1058
1324
  const branch = await releaseBranch(root);
1059
1325
  const remote = await releaseRemote(root, branch);
@@ -1182,17 +1448,68 @@ async function runLatestNpmTrust(
1182
1448
  npmArgs: string[],
1183
1449
  env?: Record<string, string>,
1184
1450
  ): Promise<TrustPublisherResult> {
1185
- const result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...npmArgs], root, env);
1186
- if (result.exitCode === 0) {
1451
+ const status = await runInteractiveStatus(
1452
+ 'nix',
1453
+ ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...npmArgs],
1454
+ root,
1455
+ env,
1456
+ );
1457
+ if (status === 0) {
1187
1458
  return 'configured';
1188
1459
  }
1189
- const output = `${result.stdout}\n${result.stderr}`;
1190
- if (/\bE409\b|\b409 Conflict\b/.test(output)) {
1191
- return 'already-configured';
1460
+ throw new Error(`nix shell nixpkgs#nodejs_latest -c npm ${npmArgs.join(' ')} failed with exit code ${status}`);
1461
+ }
1462
+
1463
+ async function listTrustedPublishers(root: string, pkg: Pick<ReleasePackage, 'name'>): Promise<TrustedPublisher[]> {
1464
+ const args = ['trust', 'list', pkg.name, '--json'];
1465
+ let result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
1466
+ if (result.exitCode !== 0 && /\bEOTP\b/.test(`${result.stdout}\n${result.stderr}`)) {
1467
+ const status = await runInteractiveStatus('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
1468
+ if (status !== 0) {
1469
+ throw new Error(`nix shell nixpkgs#nodejs_latest -c npm ${args.join(' ')} failed with exit code ${status}`);
1470
+ }
1471
+ result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
1192
1472
  }
1193
- throw new Error(
1194
- `nix shell nixpkgs#nodejs_latest -c npm ${npmArgs.join(' ')} failed with exit code ${result.exitCode}`,
1195
- );
1473
+ if (result.exitCode !== 0) {
1474
+ throw new Error(
1475
+ `nix shell nixpkgs#nodejs_latest -c npm ${args.join(' ')} failed with exit code ${result.exitCode}`,
1476
+ );
1477
+ }
1478
+ return parseTrustedPublishers(result.stdout, pkg.name);
1479
+ }
1480
+
1481
+ export function parseTrustedPublishers(stdout: string, packageName: string): TrustedPublisher[] {
1482
+ if (stdout.trim().length === 0) {
1483
+ return [];
1484
+ }
1485
+ const parsed = JSON.parse(stdout) as unknown;
1486
+ if (parsed === null) {
1487
+ return [];
1488
+ }
1489
+ if (Array.isArray(parsed)) {
1490
+ return parsed.map((value) => parseTrustedPublisher(value, packageName));
1491
+ }
1492
+ if (isRecord(parsed)) {
1493
+ return [parseTrustedPublisher(parsed, packageName)];
1494
+ }
1495
+ throw new Error(`${packageName}: npm trust list returned invalid JSON.`);
1496
+ }
1497
+
1498
+ function parseTrustedPublisher(value: unknown, packageName: string): TrustedPublisher {
1499
+ if (!isRecord(value)) {
1500
+ throw new Error(`${packageName}: npm trust list returned invalid trusted publisher entry.`);
1501
+ }
1502
+ const id = stringProperty(value, 'id');
1503
+ const type = stringProperty(value, 'type');
1504
+ if (!id || !type) {
1505
+ throw new Error(`${packageName}: npm trust list returned a trusted publisher without id or type.`);
1506
+ }
1507
+ return {
1508
+ id,
1509
+ type,
1510
+ file: stringProperty(value, 'file') ?? undefined,
1511
+ repository: stringProperty(value, 'repository') ?? undefined,
1512
+ };
1196
1513
  }
1197
1514
 
1198
1515
  async function runLatestNpmPublish(root: string, npmArgs: string[]): Promise<void> {
@@ -1206,7 +1523,7 @@ function missingNpmPackagePublishGuidance(pkg: Pick<ReleasePackage, 'name'>): st
1206
1523
  async function promptForNpmOtp(packageName: string): Promise<string> {
1207
1524
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1208
1525
  throw new Error(
1209
- `npm trust requires a one-time password for ${packageName}. Pass --otp <code> in non-interactive shells.`,
1526
+ `npm requires a one-time password for ${packageName}. Pass --otp <code> in non-interactive shells.`,
1210
1527
  );
1211
1528
  }
1212
1529