@smoothbricks/cli 0.2.0 → 0.3.0

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 (64) hide show
  1. package/README.md +27 -23
  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.js +1 -1
  23. package/dist/release/core.d.ts +3 -1
  24. package/dist/release/core.d.ts.map +1 -1
  25. package/dist/release/core.js +76 -9
  26. package/dist/release/github-release.d.ts +1 -0
  27. package/dist/release/github-release.d.ts.map +1 -1
  28. package/dist/release/github-release.js +1 -1
  29. package/dist/release/index.d.ts +9 -3
  30. package/dist/release/index.d.ts.map +1 -1
  31. package/dist/release/index.js +265 -39
  32. package/dist/release/orchestration.d.ts +10 -2
  33. package/dist/release/orchestration.d.ts.map +1 -1
  34. package/dist/release/orchestration.js +33 -3
  35. package/managed/templates/github/workflows/ci.yml +3 -2
  36. package/package.json +7 -12
  37. package/src/cli.ts +21 -4
  38. package/src/generate/index.ts +92 -0
  39. package/src/lib/run.ts +25 -0
  40. package/src/lib/workspace.ts +72 -16
  41. package/src/monorepo/lockfile.ts +9 -0
  42. package/src/monorepo/package-policy.test.ts +250 -22
  43. package/src/monorepo/package-policy.ts +88 -1351
  44. package/src/monorepo/publish-workflow.ts +2 -3
  45. package/src/nx/index.test.ts +9 -0
  46. package/src/nx/index.ts +25 -5
  47. package/src/release/__tests__/candidates.test.ts +53 -8
  48. package/src/release/__tests__/core-properties.test.ts +2 -2
  49. package/src/release/__tests__/core-scenarios.test.ts +12 -15
  50. package/src/release/__tests__/core.test.ts +50 -3
  51. package/src/release/__tests__/fixture-repo.test.ts +68 -18
  52. package/src/release/__tests__/github-release.test.ts +9 -16
  53. package/src/release/__tests__/orchestration.test.ts +91 -5
  54. package/src/release/__tests__/retag-unpublished.test.ts +9 -9
  55. package/src/release/__tests__/trust-publisher.test.ts +65 -33
  56. package/src/release/candidates.ts +1 -1
  57. package/src/release/core.ts +95 -10
  58. package/src/release/github-release.ts +1 -1
  59. package/src/release/index.ts +343 -49
  60. package/src/release/orchestration.ts +50 -5
  61. package/dist/nx-version-actions.cjs +0 -25
  62. package/dist/nx-version-actions.d.cts +0 -6
  63. package/dist/nx-version-actions.d.cts.map +0 -1
  64. 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>;
@@ -470,7 +542,16 @@ async function releaseVersionPackages(
470
542
  );
471
543
  }
472
544
 
473
- async function runNxReleaseVersion(root: string, projects: string, bump: string, dryRun: boolean): Promise<void> {
545
+ async function runNxReleaseVersion(
546
+ root: string,
547
+ projects: string,
548
+ bump: string,
549
+ dryRun: boolean,
550
+ ): Promise<ReleasePackage[]> {
551
+ if (dryRun) {
552
+ return runNxReleaseVersionPreview(root, projects, bump);
553
+ }
554
+
474
555
  // Nx owns local release mutation: package versions, bun.lock updates, the
475
556
  // release commit, and annotated tags. smoo owns remote publication after the
476
557
  // workflow validates the exact release commit Nx produced.
@@ -479,10 +560,78 @@ async function runNxReleaseVersion(root: string, projects: string, bump: string,
479
560
  nxArgs.push(bump);
480
561
  }
481
562
  nxArgs.push(`--projects=${projects}`, '--git-commit=true', '--git-tag=true', '--git-push=false');
482
- if (dryRun) {
483
- nxArgs.push('--dry-run');
484
- }
485
563
  await run('nx', nxArgs, root);
564
+ return [];
565
+ }
566
+
567
+ async function runNxReleaseVersionPreview(root: string, projects: string, bump: string): Promise<ReleasePackage[]> {
568
+ return withNxWorkspaceRoot(root, async () => {
569
+ const { createAPI } = await import('nx/src/command-line/release/version.js');
570
+ const { createAPI: createChangelogAPI } = await import('nx/src/command-line/release/changelog.js');
571
+ const projectNames = projects.split(',').filter(Boolean);
572
+ const result = await createAPI(
573
+ {},
574
+ false,
575
+ )({
576
+ specifier: bump === 'auto' ? undefined : bump,
577
+ projects: projectNames,
578
+ gitCommit: true,
579
+ gitTag: true,
580
+ gitPush: false,
581
+ dryRun: true,
582
+ });
583
+ const changelogResult = await createChangelogAPI(
584
+ {
585
+ changelog: {
586
+ workspaceChangelog: false,
587
+ projectChangelogs: { createRelease: false, file: false },
588
+ },
589
+ },
590
+ false,
591
+ )({
592
+ projects: projectNames,
593
+ versionData: result.projectsVersionData,
594
+ releaseGraph: result.releaseGraph,
595
+ gitCommit: false,
596
+ gitTag: false,
597
+ gitPush: false,
598
+ stageChanges: false,
599
+ createRelease: false,
600
+ dryRun: true,
601
+ });
602
+ releasePreviewChangelogs.clear();
603
+ for (const [projectName, changelog] of Object.entries(changelogResult.projectChangelogs ?? {})) {
604
+ releasePreviewChangelogs.set(projectName, changelog.contents);
605
+ }
606
+ const packagesByProject = new Map(releasePackages(root).map((pkg) => [pkg.projectName, pkg]));
607
+ return Object.entries(result.projectsVersionData)
608
+ .map(([projectName, data]) => {
609
+ const pkg = packagesByProject.get(projectName);
610
+ if (!pkg || !data.newVersion) {
611
+ return null;
612
+ }
613
+ return { ...pkg, version: data.newVersion };
614
+ })
615
+ .filter((pkg): pkg is ReleasePackage => pkg !== null);
616
+ });
617
+ }
618
+
619
+ async function runNxNextPrereleaseVersion(root: string, projects: string): Promise<void> {
620
+ await run(
621
+ 'nx',
622
+ [
623
+ 'release',
624
+ 'version',
625
+ 'prerelease',
626
+ `--projects=${projects}`,
627
+ '--preid=next',
628
+ '--git-commit=true',
629
+ '--git-tag=false',
630
+ '--git-push=false',
631
+ '--git-commit-message=chore(release): prepare next prerelease',
632
+ ],
633
+ root,
634
+ );
486
635
  }
487
636
 
488
637
  async function releasePackagesAtHead(root: string, packages: ReleasePackage[]): Promise<ReleasePackage[]> {
@@ -567,7 +716,7 @@ async function gitTagsAtRef(root: string, ref: string): Promise<string[]> {
567
716
  }
568
717
 
569
718
  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}`
719
+ const result = await $`git tag --list ${`${pkg.projectName}@*`} --sort=-v:refname --merged ${currentTag}`
571
720
  .cwd(root)
572
721
  .quiet()
573
722
  .nothrow();
@@ -620,6 +769,12 @@ async function pushReleaseRefs(root: string, packages: ReleasePackage[]): Promis
620
769
  return true;
621
770
  }
622
771
 
772
+ async function pushCurrentBranch(root: string): Promise<void> {
773
+ const branch = await releaseBranch(root);
774
+ const remote = await releaseRemote(root, branch);
775
+ await run('git', ['push', remote, `HEAD:refs/heads/${branch}`], root);
776
+ }
777
+
623
778
  function releaseCompletionShell(root: string): ReleaseCompletionShell<ReleasePackage> {
624
779
  return {
625
780
  gitHead: () => gitHead(root),
@@ -638,6 +793,15 @@ function releaseCompletionShell(root: string): ReleaseCompletionShell<ReleasePac
638
793
  };
639
794
  }
640
795
 
796
+ function releaseNextShell(root: string): { bumpStablePackagesToNext(packages: ReleasePackage[]): Promise<void> } {
797
+ return {
798
+ bumpStablePackagesToNext: async (packages) => {
799
+ await runNxNextPrereleaseVersion(root, releasePackageProjects(packages));
800
+ await pushCurrentBranch(root);
801
+ },
802
+ };
803
+ }
804
+
641
805
  function releaseRepairShell(root: string): ReleaseCompletionShell<ReleasePackage> & {
642
806
  checkout(ref: string): Promise<void>;
643
807
  withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T>;
@@ -692,11 +856,12 @@ async function listOwnedReleaseTagRecords(root: string, ref: string): Promise<Re
692
856
 
693
857
  async function durableReleaseTagState(root: string, pkg: Pick<ReleasePackage, 'name' | 'version'>, tag: string) {
694
858
  const packageVersion = `${pkg.name}@${pkg.version}`;
859
+ const githubReleaseTags = releaseTagAliases({ ...pkg, projectName: projectNameFromReleaseTag(tag) });
695
860
  const start = Date.now();
696
- console.log(`${packageVersion}: checking durable state (npm + GitHub Release ${tag}).`);
861
+ console.log(`${packageVersion}: checking durable state (npm + GitHub Release ${githubReleaseTags.join(' or ')}).`);
697
862
  const [npmPublished, githubReleasePresent] = await Promise.all([
698
863
  npmVersionExists(root, pkg.name, pkg.version),
699
- githubReleaseExists(root, tag),
864
+ anyGithubReleaseExists(root, githubReleaseTags),
700
865
  ]);
701
866
  console.log(
702
867
  `${packageVersion}: durable state npm=${yesNo(npmPublished)} github=${yesNo(githubReleasePresent)} (${Date.now() - start}ms).`,
@@ -704,6 +869,14 @@ async function durableReleaseTagState(root: string, pkg: Pick<ReleasePackage, 'n
704
869
  return { npmPublished, githubReleaseExists: githubReleasePresent };
705
870
  }
706
871
 
872
+ function projectNameFromReleaseTag(tag: string): string {
873
+ const versionSeparator = tag.lastIndexOf('@');
874
+ if (versionSeparator <= 0) {
875
+ return tag;
876
+ }
877
+ return tag.slice(0, versionSeparator);
878
+ }
879
+
707
880
  async function gitReleaseTagsByCreatorDate(root: string): Promise<GitReleaseTagInfo[]> {
708
881
  const result =
709
882
  await $`git for-each-ref --sort=-creatordate --format=${'%(refname:short)%09%(creatordate:unix)%09%(*objectname)%09%(objectname)'} refs/tags`
@@ -738,7 +911,7 @@ async function listMissingGithubReleasePackages(root: string, packages: ReleaseP
738
911
  ).filter((pkg): pkg is ReleasePackage => pkg !== null);
739
912
  }
740
913
 
741
- async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: boolean): Promise<void> {
914
+ async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: boolean): Promise<string | null> {
742
915
  const currentTag = releaseTag(pkg);
743
916
  console.log(`${pkg.name}@${pkg.version}: rendering GitHub Release notes for ${currentTag}.`);
744
917
  console.log(`GitHub release auth: ${envPresence('GH_TOKEN')}, ${envPresence('GITHUB_TOKEN')}.`);
@@ -748,7 +921,7 @@ async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: bo
748
921
  const previousTag = await previousReleaseTag(root, pkg, currentTag);
749
922
  if (dryRun) {
750
923
  await renderNxProjectChangelogContents({ root, pkg, previousTag, dryRun });
751
- return;
924
+ return null;
752
925
  }
753
926
  const contents = await renderNxProjectChangelogContents({ root, pkg, previousTag, dryRun });
754
927
  await createOrUpdateGithubRelease(pkg, contents, {
@@ -756,6 +929,7 @@ async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: bo
756
929
  runGhRelease: (args) => run('gh', args, root),
757
930
  log: (message) => console.log(message),
758
931
  });
932
+ return githubReleaseUrl(root, currentTag);
759
933
  }
760
934
 
761
935
  function envPresence(name: string): string {
@@ -793,7 +967,7 @@ async function writeReleaseSummary(summary: ReleaseSummary<ReleasePackage>): Pro
793
967
  lines.push(`- Git refs pushed: ${summary.pushed ? 'yes' : 'already current'}`);
794
968
  lines.push(`- npm published: ${packageSummary(summary.published)}`);
795
969
  lines.push(`- npm already published: ${packageSummary(summary.alreadyPublished)}`);
796
- lines.push(`- GitHub Releases created/updated: ${packageSummary(summary.githubReleases)}`);
970
+ lines.push(`- GitHub Releases created/updated: ${githubReleaseSummary(summary)}`);
797
971
  }
798
972
  if (summary.rerunRequired) {
799
973
  const message = 'A previous incomplete release was repaired; newer commits remain. Run Publish again.';
@@ -808,13 +982,45 @@ async function writeReleaseSummary(summary: ReleaseSummary<ReleasePackage>): Pro
808
982
  }
809
983
  }
810
984
 
985
+ async function writeReleasePreviewSummary(
986
+ root: string,
987
+ currentPackages: ReleasePackage[],
988
+ previewPackages: ReleasePackage[],
989
+ ): Promise<void> {
990
+ const lines = ['## Release Preview', '', `- Commit: \`${(await gitHead(root)).slice(0, 12)}\``, '- Mode: dry run'];
991
+ if (previewPackages.length === 0) {
992
+ lines.push('- Result: no release needed');
993
+ } else {
994
+ lines.push('- Packages:');
995
+ const currentByName = new Map(currentPackages.map((pkg) => [pkg.name, pkg]));
996
+ for (const pkg of previewPackages) {
997
+ lines.push(` - \`${pkg.name}\`: \`${currentByName.get(pkg.name)?.version ?? 'unknown'}\` -> \`${pkg.version}\``);
998
+ }
999
+ for (const pkg of previewPackages) {
1000
+ const contents = releasePreviewChangelogs.get(pkg.projectName);
1001
+ if (!contents) {
1002
+ throw new Error(`Nx did not generate a project changelog for ${pkg.projectName}.`);
1003
+ }
1004
+ lines.push('', `### ${pkg.name} ${pkg.version}`, '', contents.trim());
1005
+ }
1006
+ }
1007
+ const text = `${lines.join('\n')}\n`;
1008
+ console.log(text.trimEnd());
1009
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
1010
+ if (summaryPath) {
1011
+ await appendFile(summaryPath, `${text}\n`);
1012
+ }
1013
+ }
1014
+
811
1015
  async function writeRepairSummary(summaries: Array<ReleaseSummary<ReleasePackage>>, dryRun: boolean): Promise<void> {
812
1016
  const lines = ['## Pending Release Repair', '', `- Mode: ${dryRun ? 'dry run' : 'release'}`];
813
1017
  if (summaries.length === 0) {
814
1018
  lines.push('- Result: no pending releases needed repair');
815
1019
  } else {
816
1020
  for (const summary of summaries) {
817
- lines.push(`- Repaired \`${summary.sha.slice(0, 12)}\`: ${packageSummary(summary.packages)}`);
1021
+ lines.push(
1022
+ `- ${dryRun ? 'Would repair' : 'Repaired'} \`${summary.sha.slice(0, 12)}\`: ${packageSummary(summary.packages)}`,
1023
+ );
818
1024
  }
819
1025
  }
820
1026
  const text = `${lines.join('\n')}\n`;
@@ -832,6 +1038,30 @@ function packageSummary(packages: ReleasePackage[]): string {
832
1038
  return packages.map((pkg) => `${pkg.name}@${pkg.version}`).join(', ');
833
1039
  }
834
1040
 
1041
+ function githubReleaseSummary(summary: ReleaseSummary<ReleasePackage>): string {
1042
+ if (summary.githubReleaseLinks.length > 0) {
1043
+ return summary.githubReleaseLinks.map(({ pkg, url }) => `[${pkg.name}@${pkg.version}](${url})`).join(', ');
1044
+ }
1045
+ return packageSummary(summary.githubReleases);
1046
+ }
1047
+
1048
+ function repairTargetSummary(target: ReleaseTarget): string {
1049
+ return target.packages
1050
+ .map((pkg) => `${pkg.name}@${pkg.version} (${repairReasons(target, pkg).join(' + ')})`)
1051
+ .join(', ');
1052
+ }
1053
+
1054
+ function repairReasons(target: ReleaseTarget, pkg: ReleasePackage): string[] {
1055
+ const reasons: string[] = [];
1056
+ if (target.npmPackages.includes(pkg)) {
1057
+ reasons.push('npm missing');
1058
+ }
1059
+ if (target.githubPackages.includes(pkg)) {
1060
+ reasons.push('GitHub Release missing');
1061
+ }
1062
+ return reasons;
1063
+ }
1064
+
835
1065
  async function fetchReleaseRefs(root: string, remote: string, branch: string): Promise<void> {
836
1066
  await run('git', ['fetch', '--tags', remote, branch], root);
837
1067
  }
@@ -1054,6 +1284,19 @@ async function githubReleaseExists(root: string, tag: string): Promise<boolean>
1054
1284
  return (await runStatus('gh', ['release', 'view', tag, '--json', 'tagName'], root, true)) === 0;
1055
1285
  }
1056
1286
 
1287
+ function githubReleaseUrl(root: string, tag: string): string {
1288
+ return `https://github.com/${githubRepositoryFromRootPackage(root)}/releases/tag/${encodeURIComponent(tag)}`;
1289
+ }
1290
+
1291
+ async function anyGithubReleaseExists(root: string, tags: string[]): Promise<boolean> {
1292
+ for (const tag of tags) {
1293
+ if (await githubReleaseExists(root, tag)) {
1294
+ return true;
1295
+ }
1296
+ }
1297
+ return false;
1298
+ }
1299
+
1057
1300
  async function assertRemoteTagExists(root: string, tag: string): Promise<void> {
1058
1301
  const branch = await releaseBranch(root);
1059
1302
  const remote = await releaseRemote(root, branch);
@@ -1182,17 +1425,68 @@ async function runLatestNpmTrust(
1182
1425
  npmArgs: string[],
1183
1426
  env?: Record<string, string>,
1184
1427
  ): Promise<TrustPublisherResult> {
1185
- const result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...npmArgs], root, env);
1186
- if (result.exitCode === 0) {
1428
+ const status = await runInteractiveStatus(
1429
+ 'nix',
1430
+ ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...npmArgs],
1431
+ root,
1432
+ env,
1433
+ );
1434
+ if (status === 0) {
1187
1435
  return 'configured';
1188
1436
  }
1189
- const output = `${result.stdout}\n${result.stderr}`;
1190
- if (/\bE409\b|\b409 Conflict\b/.test(output)) {
1191
- return 'already-configured';
1437
+ throw new Error(`nix shell nixpkgs#nodejs_latest -c npm ${npmArgs.join(' ')} failed with exit code ${status}`);
1438
+ }
1439
+
1440
+ async function listTrustedPublishers(root: string, pkg: Pick<ReleasePackage, 'name'>): Promise<TrustedPublisher[]> {
1441
+ const args = ['trust', 'list', pkg.name, '--json'];
1442
+ let result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
1443
+ if (result.exitCode !== 0 && /\bEOTP\b/.test(`${result.stdout}\n${result.stderr}`)) {
1444
+ const status = await runInteractiveStatus('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
1445
+ if (status !== 0) {
1446
+ throw new Error(`nix shell nixpkgs#nodejs_latest -c npm ${args.join(' ')} failed with exit code ${status}`);
1447
+ }
1448
+ result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
1192
1449
  }
1193
- throw new Error(
1194
- `nix shell nixpkgs#nodejs_latest -c npm ${npmArgs.join(' ')} failed with exit code ${result.exitCode}`,
1195
- );
1450
+ if (result.exitCode !== 0) {
1451
+ throw new Error(
1452
+ `nix shell nixpkgs#nodejs_latest -c npm ${args.join(' ')} failed with exit code ${result.exitCode}`,
1453
+ );
1454
+ }
1455
+ return parseTrustedPublishers(result.stdout, pkg.name);
1456
+ }
1457
+
1458
+ export function parseTrustedPublishers(stdout: string, packageName: string): TrustedPublisher[] {
1459
+ if (stdout.trim().length === 0) {
1460
+ return [];
1461
+ }
1462
+ const parsed = JSON.parse(stdout) as unknown;
1463
+ if (parsed === null) {
1464
+ return [];
1465
+ }
1466
+ if (Array.isArray(parsed)) {
1467
+ return parsed.map((value) => parseTrustedPublisher(value, packageName));
1468
+ }
1469
+ if (isRecord(parsed)) {
1470
+ return [parseTrustedPublisher(parsed, packageName)];
1471
+ }
1472
+ throw new Error(`${packageName}: npm trust list returned invalid JSON.`);
1473
+ }
1474
+
1475
+ function parseTrustedPublisher(value: unknown, packageName: string): TrustedPublisher {
1476
+ if (!isRecord(value)) {
1477
+ throw new Error(`${packageName}: npm trust list returned invalid trusted publisher entry.`);
1478
+ }
1479
+ const id = stringProperty(value, 'id');
1480
+ const type = stringProperty(value, 'type');
1481
+ if (!id || !type) {
1482
+ throw new Error(`${packageName}: npm trust list returned a trusted publisher without id or type.`);
1483
+ }
1484
+ return {
1485
+ id,
1486
+ type,
1487
+ file: stringProperty(value, 'file') ?? undefined,
1488
+ repository: stringProperty(value, 'repository') ?? undefined,
1489
+ };
1196
1490
  }
1197
1491
 
1198
1492
  async function runLatestNpmPublish(root: string, npmArgs: string[]): Promise<void> {
@@ -1206,7 +1500,7 @@ function missingNpmPackagePublishGuidance(pkg: Pick<ReleasePackage, 'name'>): st
1206
1500
  async function promptForNpmOtp(packageName: string): Promise<string> {
1207
1501
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1208
1502
  throw new Error(
1209
- `npm trust requires a one-time password for ${packageName}. Pass --otp <code> in non-interactive shells.`,
1503
+ `npm requires a one-time password for ${packageName}. Pass --otp <code> in non-interactive shells.`,
1210
1504
  );
1211
1505
  }
1212
1506