@smoothbricks/cli 0.1.1 → 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 (131) hide show
  1. package/README.md +161 -32
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +110 -10
  4. package/dist/devenv/index.d.ts +10 -0
  5. package/dist/devenv/index.d.ts.map +1 -0
  6. package/dist/devenv/index.js +27 -0
  7. package/dist/generate/index.d.ts +20 -0
  8. package/dist/generate/index.d.ts.map +1 -0
  9. package/dist/generate/index.js +47 -0
  10. package/dist/lib/cli-package.d.ts +4 -0
  11. package/dist/lib/cli-package.d.ts.map +1 -0
  12. package/dist/lib/cli-package.js +15 -0
  13. package/dist/lib/run.d.ts +6 -0
  14. package/dist/lib/run.d.ts.map +1 -1
  15. package/dist/lib/run.js +32 -0
  16. package/dist/lib/workspace.d.ts +25 -1
  17. package/dist/lib/workspace.d.ts.map +1 -1
  18. package/dist/lib/workspace.js +79 -17
  19. package/dist/monorepo/commit-msg.d.ts +8 -1
  20. package/dist/monorepo/commit-msg.d.ts.map +1 -1
  21. package/dist/monorepo/commit-msg.js +85 -7
  22. package/dist/monorepo/index.d.ts +19 -2
  23. package/dist/monorepo/index.d.ts.map +1 -1
  24. package/dist/monorepo/index.js +104 -16
  25. package/dist/monorepo/lockfile.d.ts.map +1 -1
  26. package/dist/monorepo/lockfile.js +9 -0
  27. package/dist/monorepo/managed-files.js +1 -1
  28. package/dist/monorepo/nx-sync.d.ts +2 -1
  29. package/dist/monorepo/nx-sync.d.ts.map +1 -1
  30. package/dist/monorepo/nx-sync.js +8 -2
  31. package/dist/monorepo/package-hygiene.d.ts +6 -2
  32. package/dist/monorepo/package-hygiene.d.ts.map +1 -1
  33. package/dist/monorepo/package-hygiene.js +12 -4
  34. package/dist/monorepo/package-policy.d.ts +13 -4
  35. package/dist/monorepo/package-policy.d.ts.map +1 -1
  36. package/dist/monorepo/package-policy.js +208 -113
  37. package/dist/monorepo/packed-package.d.ts +3 -0
  38. package/dist/monorepo/packed-package.d.ts.map +1 -1
  39. package/dist/monorepo/packed-package.js +68 -36
  40. package/dist/monorepo/packs/index.d.ts +20 -1
  41. package/dist/monorepo/packs/index.d.ts.map +1 -1
  42. package/dist/monorepo/packs/index.js +238 -19
  43. package/dist/monorepo/publish-workflow.d.ts +0 -4
  44. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  45. package/dist/monorepo/publish-workflow.js +7 -15
  46. package/dist/monorepo/tool-validation.d.ts +22 -0
  47. package/dist/monorepo/tool-validation.d.ts.map +1 -0
  48. package/dist/monorepo/tool-validation.js +399 -0
  49. package/dist/nx/index.d.ts +25 -0
  50. package/dist/nx/index.d.ts.map +1 -0
  51. package/dist/nx/index.js +112 -0
  52. package/dist/release/bootstrap-npm-packages.d.ts +19 -0
  53. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -0
  54. package/dist/release/bootstrap-npm-packages.js +58 -0
  55. package/dist/release/candidates.d.ts +11 -0
  56. package/dist/release/candidates.d.ts.map +1 -0
  57. package/dist/release/candidates.js +181 -0
  58. package/dist/release/core.d.ts +4 -1
  59. package/dist/release/core.d.ts.map +1 -1
  60. package/dist/release/core.js +76 -9
  61. package/dist/release/github-release.d.ts +1 -0
  62. package/dist/release/github-release.d.ts.map +1 -1
  63. package/dist/release/github-release.js +3 -3
  64. package/dist/release/index.d.ts +32 -1
  65. package/dist/release/index.d.ts.map +1 -1
  66. package/dist/release/index.js +488 -64
  67. package/dist/release/npm-auth.d.ts +0 -1
  68. package/dist/release/npm-auth.d.ts.map +1 -1
  69. package/dist/release/npm-auth.js +4 -16
  70. package/dist/release/orchestration.d.ts +11 -2
  71. package/dist/release/orchestration.d.ts.map +1 -1
  72. package/dist/release/orchestration.js +41 -3
  73. package/dist/release/publish-plan.js +1 -1
  74. package/managed/raw/git-format-staged.yml +54 -0
  75. package/managed/raw/tooling/git-hooks/commit-msg.sh +1 -1
  76. package/managed/templates/github/actions/setup-devenv/action.yml +1 -7
  77. package/managed/templates/github/workflows/ci.yml +4 -2
  78. package/package.json +15 -8
  79. package/src/cli.ts +153 -22
  80. package/src/devenv/index.test.ts +49 -0
  81. package/src/devenv/index.ts +38 -0
  82. package/src/generate/index.ts +92 -0
  83. package/src/lib/cli-package.ts +18 -0
  84. package/src/lib/run.ts +44 -0
  85. package/src/lib/workspace.ts +112 -17
  86. package/src/monorepo/__tests__/publish-workflow.test.ts +16 -9
  87. package/src/monorepo/commit-msg.test.ts +45 -0
  88. package/src/monorepo/commit-msg.ts +107 -7
  89. package/src/monorepo/index.ts +142 -16
  90. package/src/monorepo/lockfile.ts +9 -0
  91. package/src/monorepo/managed-files.ts +1 -1
  92. package/src/monorepo/nx-sync.ts +9 -2
  93. package/src/monorepo/package-hygiene.test.ts +34 -0
  94. package/src/monorepo/package-hygiene.ts +26 -4
  95. package/src/monorepo/package-policy.test.ts +1130 -0
  96. package/src/monorepo/package-policy.ts +237 -114
  97. package/src/monorepo/packed-package.ts +89 -37
  98. package/src/monorepo/packs/index.test.ts +189 -0
  99. package/src/monorepo/packs/index.ts +311 -20
  100. package/src/monorepo/publish-workflow.ts +10 -19
  101. package/src/monorepo/setup-test-tracing.test.ts +120 -0
  102. package/src/monorepo/tool-validation.test.ts +261 -0
  103. package/src/monorepo/tool-validation.ts +462 -0
  104. package/src/nx/index.test.ts +73 -0
  105. package/src/nx/index.ts +139 -0
  106. package/src/release/__tests__/bootstrap-npm-packages.test.ts +127 -0
  107. package/src/release/__tests__/candidates.test.ts +262 -0
  108. package/src/release/__tests__/core-properties.test.ts +6 -6
  109. package/src/release/__tests__/core-scenarios.test.ts +27 -20
  110. package/src/release/__tests__/core.test.ts +52 -5
  111. package/src/release/__tests__/fixture-repo.test.ts +90 -31
  112. package/src/release/__tests__/github-release.test.ts +22 -24
  113. package/src/release/__tests__/helpers/fixture-repo.ts +1 -0
  114. package/src/release/__tests__/npm-auth.test.ts +12 -21
  115. package/src/release/__tests__/orchestration.test.ts +142 -12
  116. package/src/release/__tests__/publish-plan.test.ts +9 -3
  117. package/src/release/__tests__/retag-unpublished.test.ts +10 -10
  118. package/src/release/__tests__/trust-publisher.test.ts +165 -0
  119. package/src/release/bootstrap-npm-packages.ts +88 -0
  120. package/src/release/candidates.ts +225 -0
  121. package/src/release/core.ts +96 -10
  122. package/src/release/github-release.ts +3 -3
  123. package/src/release/index.ts +658 -74
  124. package/src/release/npm-auth.ts +4 -33
  125. package/src/release/orchestration.ts +60 -5
  126. package/src/release/publish-plan.ts +1 -1
  127. package/dist/nx-version-actions.cjs +0 -25
  128. package/dist/nx-version-actions.d.cts +0 -6
  129. package/dist/nx-version-actions.d.cts.map +0 -1
  130. package/managed/raw/.git-format-staged.yml +0 -47
  131. package/src/nx-version-actions.cts +0 -36
@@ -5,21 +5,35 @@ import { createInterface } from 'node:readline/promises';
5
5
  import { Writable } from 'node:stream';
6
6
  import { $ } from 'bun';
7
7
  import { withDevenvEnv } from '../lib/devenv.js';
8
- import { isRecord, stringProperty } from '../lib/json.js';
9
- import { decode, run, runStatus } from '../lib/run.js';
8
+ import { isRecord, readJsonObject, stringProperty } from '../lib/json.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
+ import {
13
+ type BootstrapNpmPackagesOptions,
14
+ bootstrapNpmPackages,
15
+ NPM_BOOTSTRAP_DIST_TAG,
16
+ NPM_BOOTSTRAP_VERSION,
17
+ } from './bootstrap-npm-packages.js';
18
+ import { autoReleaseCandidatePackages } from './candidates.js';
12
19
  import {
13
20
  type ReleaseTagRecord as CoreReleaseTagRecord,
14
21
  type ReleaseTarget as CoreReleaseTarget,
15
22
  collectOwnedReleaseTagRecords,
16
23
  type GitReleaseTagInfo,
17
24
  pendingReleaseTargets,
25
+ type ReleasePackageInfo,
18
26
  releaseTag,
27
+ releaseTagAliases,
19
28
  } from './core.js';
20
- import { createOrUpdateGithubRelease, renderNxProjectChangelogContents } from './github-release.js';
29
+ import {
30
+ createOrUpdateGithubRelease,
31
+ renderNxProjectChangelogContents,
32
+ withNxWorkspaceRoot,
33
+ } from './github-release.js';
21
34
  import { publishWithAuthDiagnostics } from './npm-auth.js';
22
35
  import {
36
+ bumpStableReleaseToNext,
23
37
  completeReleaseAtHead as completeReleaseAtHeadWithShell,
24
38
  type ReleaseCompletionShell,
25
39
  type ReleaseSummary,
@@ -46,8 +60,17 @@ export interface ReleaseRepairPendingOptions {
46
60
 
47
61
  export interface ReleaseTrustPublisherOptions {
48
62
  dryRun?: boolean;
49
- otp?: string;
63
+ bootstrap?: boolean;
64
+ bootstrapOtp?: string;
65
+ skipLogin?: boolean;
66
+ packages?: string[];
67
+ }
68
+
69
+ export interface ReleaseBootstrapNpmPackagesOptions {
70
+ dryRun?: boolean;
50
71
  skipLogin?: boolean;
72
+ otp?: string;
73
+ packages?: string[];
51
74
  }
52
75
 
53
76
  export interface ReleaseRetagUnpublishedOptions {
@@ -63,13 +86,14 @@ export interface ReleaseRetagUnpublishedOptions {
63
86
  export async function releaseVersion(root: string, options: ReleaseVersionOptions): Promise<void> {
64
87
  const bump = releaseBumpArg(options.bump);
65
88
  const packages = releasePackages(root);
66
- const projects = releasePackageProjects(packages);
67
89
  const result = await runReleaseVersion(
68
90
  {
69
91
  releasePackagesAtHead: () => releasePackagesAtHead(root, packages),
92
+ releaseVersionPackages: (releaseBump) => releaseVersionPackages(root, packages, releaseBump),
70
93
  ensureLocalReleaseTags: (releasePackages) => ensureLocalReleaseTags(root, releasePackages),
71
94
  gitHead: () => gitHead(root),
72
- runNxReleaseVersion: (releaseBump, dryRun) => runNxReleaseVersion(root, projects, releaseBump, dryRun),
95
+ runNxReleaseVersion: (releasePackages, releaseBump, dryRun) =>
96
+ runNxReleaseVersion(root, releasePackageProjects(releasePackages), releaseBump, dryRun),
73
97
  assertCleanGitTree: () => assertCleanGitTree(root),
74
98
  },
75
99
  { bump, dryRun: options.dryRun === true },
@@ -79,6 +103,9 @@ export async function releaseVersion(root: string, options: ReleaseVersionOption
79
103
  } else if (result.status === 'no-release-needed') {
80
104
  console.log('Nx did not create a release commit; no release needed.');
81
105
  }
106
+ if (result.status === 'dry-run') {
107
+ await writeReleasePreviewSummary(root, packages, result.packages);
108
+ }
82
109
  await writeReleaseGithubOutput(options.githubOutput, result.packages, result.mode);
83
110
  }
84
111
 
@@ -87,15 +114,22 @@ export async function releasePublish(root: string, options: ReleasePublishOption
87
114
  const packages = await releasePackagesAtHead(root, releasePackages(root));
88
115
  if (packages.length === 0) {
89
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
+ }
90
123
  console.log('No release tags found at HEAD; no release to publish.');
91
124
  const summary: ReleaseSummary<ReleasePackage> = {
92
125
  sha: await gitHead(root),
93
- dryRun: options.dryRun === true,
126
+ dryRun: false,
94
127
  packages,
95
128
  pushed: false,
96
129
  published: [],
97
130
  alreadyPublished: [],
98
131
  githubReleases: [],
132
+ githubReleaseLinks: [],
99
133
  rerunRequired: false,
100
134
  noRelease: true,
101
135
  };
@@ -111,6 +145,15 @@ export async function releasePublish(root: string, options: ReleasePublishOption
111
145
  await newerCommitsRemain(root),
112
146
  );
113
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
+ }
114
157
  }
115
158
 
116
159
  export async function releaseRepairPending(root: string, options: ReleaseRepairPendingOptions): Promise<void> {
@@ -127,6 +170,9 @@ export async function releaseRepairPending(root: string, options: ReleaseRepairP
127
170
  ? 'Repair pending releases: no pending durable state repairs found.'
128
171
  : `Repair pending releases: ${targets.length} release target${targets.length === 1 ? '' : 's'} need repair.`,
129
172
  );
173
+ for (const target of targets) {
174
+ console.log(`Repair pending releases: target ${target.sha.slice(0, 12)} needs ${repairTargetSummary(target)}.`);
175
+ }
130
176
  const summaries = await repairPendingTargets(releaseRepairShell(root), targets, restoreRef, options.dryRun === true);
131
177
  await writeRepairSummary(summaries, options.dryRun === true);
132
178
  }
@@ -134,61 +180,193 @@ export async function releaseRepairPending(root: string, options: ReleaseRepairP
134
180
  export async function releaseTrustPublisher(root: string, options: ReleaseTrustPublisherOptions): Promise<void> {
135
181
  const repository = githubRepositoryFromRootPackage(root);
136
182
  const workflow = 'publish.yml';
137
- const packages = listReleasePackages(root);
138
- if (packages.length === 0) {
183
+ await configureTrustedPublishers(
184
+ {
185
+ repository,
186
+ workflow,
187
+ listReleasePackages: () => listReleasePackages(root),
188
+ packageExists: (name) => npmPackageExists(root, name),
189
+ bootstrapNpmPackages: (bootstrapOptions) =>
190
+ bootstrapNpmPackages(
191
+ {
192
+ listReleasePackages: () => listReleasePackages(root),
193
+ packageExists: (name) => npmPackageExists(root, name),
194
+ login: () => runLatestNpm(root, ['login', '--auth-type=web']),
195
+ publishPlaceholder: (pkg, env) => publishPlaceholderPackage(root, pkg, env),
196
+ promptOtp: (packageName) => promptForNpmOtp(packageName),
197
+ log: (message) => console.log(message),
198
+ },
199
+ bootstrapOptions,
200
+ ),
201
+ trustPublisher: (pkg, dryRun, env) => {
202
+ const args = ['trust', 'github', pkg.name, '--file', workflow, '--repo', repository, '--yes'];
203
+ if (dryRun) {
204
+ args.push('--dry-run');
205
+ }
206
+ return runLatestNpmTrust(root, args, env);
207
+ },
208
+ trustedPublishers: (pkg) => listTrustedPublishers(root, pkg),
209
+ log: (message) => console.log(message),
210
+ error: (message) => console.error(message),
211
+ },
212
+ options,
213
+ );
214
+ }
215
+
216
+ export interface TrustPublisherShell<Package extends ReleasePackageInfo = ReleasePackageInfo> {
217
+ repository: string;
218
+ workflow: string;
219
+ listReleasePackages(): Package[];
220
+ packageExists(name: string): Promise<boolean>;
221
+ bootstrapNpmPackages(options: BootstrapNpmPackagesOptions): Promise<Package[]>;
222
+ trustPublisher(pkg: Package, dryRun: boolean, env?: Record<string, string>): Promise<TrustPublisherResult>;
223
+ trustedPublishers(pkg: Package): Promise<TrustedPublisher[]>;
224
+ log(message: string): void;
225
+ error(message: string): void;
226
+ }
227
+
228
+ export type TrustPublisherResult = 'configured' | 'already-configured';
229
+
230
+ export interface TrustedPublisher {
231
+ id: string;
232
+ type: string;
233
+ file?: string;
234
+ repository?: string;
235
+ }
236
+
237
+ export async function configureTrustedPublishers<Package extends ReleasePackageInfo>(
238
+ shell: TrustPublisherShell<Package>,
239
+ options: ReleaseTrustPublisherOptions,
240
+ ): Promise<void> {
241
+ const packages = shell.listReleasePackages();
242
+ const selectedPackages = selectedTrustPublisherPackages(packages, options.packages ?? []);
243
+ if (selectedPackages.length === 0) {
139
244
  throw new Error('No owned release packages found.');
140
245
  }
141
246
 
247
+ if (options.bootstrap) {
248
+ await shell.bootstrapNpmPackages({
249
+ dryRun: options.dryRun === true,
250
+ skipLogin: options.skipLogin === true,
251
+ otp: options.bootstrapOtp,
252
+ packages: options.packages ?? [],
253
+ });
254
+ }
255
+
142
256
  if (!options.dryRun) {
143
257
  const packageStates = await Promise.all(
144
- packages.map(async (pkg) => ((await npmPackageExists(root, pkg.name)) ? null : pkg.name)),
258
+ selectedPackages.map(async (pkg) => ((await shell.packageExists(pkg.name)) ? null : pkg.name)),
145
259
  );
146
260
  const missingPackages = packageStates.filter((name): name is string => name !== null);
147
261
  if (missingPackages.length > 0) {
148
262
  throw new Error(
149
263
  'npm trusted publishing can only be configured after packages exist on the registry. ' +
150
- 'Bootstrap the first publish with a temporary NPM_TOKEN, then rerun trust-publisher. ' +
264
+ 'Run smoo release trust-publisher --bootstrap locally. ' +
151
265
  `Missing packages: ${missingPackages.join(', ')}`,
152
266
  );
153
267
  }
154
268
  }
155
269
 
156
- if (!options.dryRun && !options.skipLogin) {
157
- await runLatestNpm(root, ['login', '--auth-type=web']);
158
- }
159
-
160
270
  const failedPackages: string[] = [];
161
- for (const pkg of packages) {
162
- console.log(`${pkg.name}: trusting GitHub Actions ${repository}/${workflow}`);
163
- const args = ['trust', 'github', pkg.name, '--file', workflow, '--repo', repository, '--yes'];
164
- if (options.dryRun) {
165
- args.push('--dry-run');
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
+ }
166
284
  }
285
+
286
+ shell.log(`${pkg.name}: trusting GitHub Actions ${shell.repository}/${shell.workflow}`);
167
287
  while (true) {
168
- const otp = options.dryRun ? undefined : (options.otp ?? (await promptForNpmOtp(pkg.name)));
169
288
  try {
170
- await runLatestNpm(root, args, otp ? { NPM_CONFIG_OTP: otp } : undefined);
289
+ const result = await shell.trustPublisher(pkg, options.dryRun === true);
290
+ if (result === 'already-configured') {
291
+ shell.log(`${pkg.name}: npm trusted publisher is already configured; skipping.`);
292
+ }
171
293
  break;
172
294
  } catch (error) {
173
- console.error(`${pkg.name}: npm trusted publisher setup failed.`);
174
- console.error(error instanceof Error ? error.message : String(error));
295
+ shell.error(`${pkg.name}: npm trusted publisher setup failed.`);
296
+ shell.error(error instanceof Error ? error.message : String(error));
175
297
  if (!options.dryRun) {
176
- console.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.');
177
299
  }
178
- if (options.otp || options.dryRun) {
300
+ if (options.dryRun) {
179
301
  failedPackages.push(pkg.name);
180
302
  break;
181
303
  }
182
- console.error('Retrying this package. Press Ctrl-C to stop.');
304
+ shell.error('Retrying this package. Press Ctrl-C to stop.');
183
305
  }
184
306
  }
185
307
  }
186
308
  if (failedPackages.length > 0) {
187
- console.error(`Trusted publishing was not configured for: ${failedPackages.join(', ')}`);
188
- if (options.otp) {
189
- console.error('Rerun smoo release trust-publisher without --otp to enter a fresh OTP for each failed package.');
309
+ shell.error(`Trusted publishing was not configured for: ${failedPackages.join(', ')}`);
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);
190
329
  }
191
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
+ );
348
+ }
349
+
350
+ export async function releaseBootstrapNpmPackages(
351
+ root: string,
352
+ options: ReleaseBootstrapNpmPackagesOptions,
353
+ ): Promise<void> {
354
+ await bootstrapNpmPackages(
355
+ {
356
+ listReleasePackages: () => listReleasePackages(root),
357
+ packageExists: (name) => npmPackageExists(root, name),
358
+ login: () => runLatestNpm(root, ['login', '--auth-type=web']),
359
+ publishPlaceholder: (pkg, env) => publishPlaceholderPackage(root, pkg, env),
360
+ promptOtp: (packageName) => promptForNpmOtp(packageName),
361
+ log: (message) => console.log(message),
362
+ },
363
+ {
364
+ dryRun: options.dryRun === true,
365
+ skipLogin: options.skipLogin === true,
366
+ otp: options.otp,
367
+ packages: options.packages ?? [],
368
+ },
369
+ );
192
370
  }
193
371
 
194
372
  export async function releaseRetagUnpublished(root: string, options: ReleaseRetagUnpublishedOptions): Promise<void> {
@@ -218,6 +396,7 @@ interface ReleaseState {
218
396
  }
219
397
 
220
398
  type ReleasePackage = ReturnType<typeof listReleasePackages>[number];
399
+ const releasePreviewChangelogs = new Map<string, string>();
221
400
 
222
401
  type ReleaseTarget = CoreReleaseTarget<ReleasePackage>;
223
402
  type ReleaseTagRecord = CoreReleaseTagRecord<ReleasePackage>;
@@ -243,21 +422,14 @@ async function listUnpublishedPackages(root: string, packages: ReleasePackage[])
243
422
  return states.filter((state) => !state.published).map((state) => state.pkg);
244
423
  }
245
424
 
246
- async function publishPackedPackage(
247
- root: string,
248
- pkg: ReleasePackage,
249
- tag: string,
250
- dryRun: boolean,
251
- useBootstrapToken: boolean,
252
- ): Promise<void> {
425
+ async function publishPackedPackage(root: string, pkg: ReleasePackage, tag: string, dryRun: boolean): Promise<void> {
253
426
  const tempDir = await mkdtemp(join(tmpdir(), 'smoo-publish-'));
254
427
  const tarball = join(tempDir, `${safeTarballPrefix(pkg.name)}-${pkg.version}.tgz`);
255
428
  try {
256
429
  console.log(`${pkg.name}@${pkg.version}: packing with bun pm pack`);
257
430
  await run('bun', ['pm', 'pack', '--filename', tarball, '--ignore-scripts', '--quiet'], join(root, pkg.path));
258
431
  await assertPackedWorkspaceDependencies(root, tarball, pkg);
259
- // npm CLI owns authentication here: trusted publishing OIDC when configured,
260
- // or the temporary NODE_AUTH_TOKEN bootstrap path below before trust exists.
432
+ // npm CLI owns authentication here: trusted publishing OIDC when configured.
261
433
  // Bun still produces the tarball so workspace:* dependencies are resolved the
262
434
  // same way smoo validates packed packages before release.
263
435
  const args = ['publish', tarball, '--access', 'public', '--tag', tag, '--provenance'];
@@ -267,7 +439,7 @@ async function publishPackedPackage(
267
439
  await publishWithAuthDiagnostics(
268
440
  pkg,
269
441
  {
270
- publish: () => runLatestNpmPublish(root, args, useBootstrapToken),
442
+ publish: () => runLatestNpmPublish(root, args),
271
443
  versionExists: () => npmVersionExists(root, pkg.name, pkg.version),
272
444
  log: (message) => console.log(message),
273
445
  error: (message) => console.error(message),
@@ -279,7 +451,6 @@ async function publishPackedPackage(
279
451
  },
280
452
  },
281
453
  {
282
- useBootstrapToken,
283
454
  tokenPresent: npmAuthTokenPresent(),
284
455
  repository: githubRepositoryFromRootPackage(root),
285
456
  },
@@ -293,6 +464,38 @@ function npmAuthTokenPresent(): boolean {
293
464
  return Boolean(process.env.NODE_AUTH_TOKEN || process.env.NPM_TOKEN || process.env.NPM_CONFIG_USERCONFIG);
294
465
  }
295
466
 
467
+ async function publishPlaceholderPackage(
468
+ root: string,
469
+ pkg: ReleasePackage,
470
+ env?: Record<string, string>,
471
+ ): Promise<void> {
472
+ const tempDir = await mkdtemp(join(tmpdir(), 'smoo-npm-bootstrap-'));
473
+ try {
474
+ await writeFile(
475
+ join(tempDir, 'package.json'),
476
+ `${JSON.stringify(
477
+ {
478
+ name: pkg.name,
479
+ version: NPM_BOOTSTRAP_VERSION,
480
+ description: `Bootstrap placeholder for ${pkg.name}. Real releases are published by SmoothBricks CI.`,
481
+ license: stringProperty(pkg.json, 'license') ?? 'UNLICENSED',
482
+ repository: pkg.json.repository,
483
+ publishConfig: { access: 'public' },
484
+ },
485
+ null,
486
+ 2,
487
+ )}\n`,
488
+ );
489
+ await writeFile(
490
+ join(tempDir, 'README.md'),
491
+ `# ${pkg.name}\n\nThis is a bootstrap placeholder. Real package versions are published by SmoothBricks release automation.\n`,
492
+ );
493
+ await runLatestNpm(root, ['publish', tempDir, '--access', 'public', '--tag', NPM_BOOTSTRAP_DIST_TAG], env);
494
+ } finally {
495
+ await rm(tempDir, { recursive: true, force: true });
496
+ }
497
+ }
498
+
296
499
  function safeTarballPrefix(name: string): string {
297
500
  return name.replace(/^@/, '').replace(/[^a-zA-Z0-9._-]+/g, '-');
298
501
  }
@@ -314,10 +517,41 @@ function releasePackages(root: string): ReleasePackage[] {
314
517
  }
315
518
 
316
519
  function releasePackageProjects(packages: ReleasePackage[]): string {
317
- return packages.map((pkg) => pkg.name).join(',');
520
+ return packages.map((pkg) => pkg.projectName).join(',');
318
521
  }
319
522
 
320
- async function runNxReleaseVersion(root: string, projects: string, bump: string, dryRun: boolean): Promise<void> {
523
+ async function releaseVersionPackages(
524
+ root: string,
525
+ packages: ReleasePackage[],
526
+ bump: string,
527
+ ): Promise<ReleasePackage[]> {
528
+ if (bump !== 'auto') {
529
+ return packages;
530
+ }
531
+ return autoReleaseCandidatePackages(
532
+ {
533
+ gitRefExists: (ref) => gitRefExists(root, ref),
534
+ packageChangedFilesSince: (ref, packagePath) => packageChangedFilesSince(root, ref, packagePath),
535
+ packageJsonAtRef: (ref, packagePath) => packageJsonAtRef(root, ref, packagePath),
536
+ currentPackageJson: (packagePath) => currentPackageJson(root, packagePath),
537
+ packageBuildInputPatterns: (projectName, packagePath) =>
538
+ packageBuildInputPatterns(root, projectName, packagePath),
539
+ packageHasHistory: (packagePath) => packageHasHistory(root, packagePath),
540
+ },
541
+ packages,
542
+ );
543
+ }
544
+
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
+
321
555
  // Nx owns local release mutation: package versions, bun.lock updates, the
322
556
  // release commit, and annotated tags. smoo owns remote publication after the
323
557
  // workflow validates the exact release commit Nx produced.
@@ -326,10 +560,78 @@ async function runNxReleaseVersion(root: string, projects: string, bump: string,
326
560
  nxArgs.push(bump);
327
561
  }
328
562
  nxArgs.push(`--projects=${projects}`, '--git-commit=true', '--git-tag=true', '--git-push=false');
329
- if (dryRun) {
330
- nxArgs.push('--dry-run');
331
- }
332
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
+ );
333
635
  }
334
636
 
335
637
  async function releasePackagesAtHead(root: string, packages: ReleasePackage[]): Promise<ReleasePackage[]> {
@@ -414,7 +716,7 @@ async function gitTagsAtRef(root: string, ref: string): Promise<string[]> {
414
716
  }
415
717
 
416
718
  async function previousReleaseTag(root: string, pkg: ReleasePackage, currentTag: string): Promise<string | null> {
417
- 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}`
418
720
  .cwd(root)
419
721
  .quiet()
420
722
  .nothrow();
@@ -467,6 +769,12 @@ async function pushReleaseRefs(root: string, packages: ReleasePackage[]): Promis
467
769
  return true;
468
770
  }
469
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
+
470
778
  function releaseCompletionShell(root: string): ReleaseCompletionShell<ReleasePackage> {
471
779
  return {
472
780
  gitHead: () => gitHead(root),
@@ -475,13 +783,25 @@ function releaseCompletionShell(root: string): ReleaseCompletionShell<ReleasePac
475
783
  buildReleaseCandidate: (packages) => buildReleaseCandidate(root, packages),
476
784
  publishPackage: async (pkg, distTag, dryRun) => {
477
785
  const packageExists = dryRun ? true : await npmPackageExists(root, pkg.name);
478
- await publishPackedPackage(root, pkg, distTag, dryRun, !dryRun && !packageExists);
786
+ if (!packageExists) {
787
+ throw new Error(missingNpmPackagePublishGuidance(pkg));
788
+ }
789
+ await publishPackedPackage(root, pkg, distTag, dryRun);
479
790
  },
480
791
  listGithubMissingPackages: (packages) => listMissingGithubReleasePackages(root, packages),
481
792
  createGithubRelease: (pkg, dryRun) => createGithubRelease(root, pkg, dryRun),
482
793
  };
483
794
  }
484
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
+
485
805
  function releaseRepairShell(root: string): ReleaseCompletionShell<ReleasePackage> & {
486
806
  checkout(ref: string): Promise<void>;
487
807
  withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T>;
@@ -536,11 +856,12 @@ async function listOwnedReleaseTagRecords(root: string, ref: string): Promise<Re
536
856
 
537
857
  async function durableReleaseTagState(root: string, pkg: Pick<ReleasePackage, 'name' | 'version'>, tag: string) {
538
858
  const packageVersion = `${pkg.name}@${pkg.version}`;
859
+ const githubReleaseTags = releaseTagAliases({ ...pkg, projectName: projectNameFromReleaseTag(tag) });
539
860
  const start = Date.now();
540
- console.log(`${packageVersion}: checking durable state (npm + GitHub Release ${tag}).`);
861
+ console.log(`${packageVersion}: checking durable state (npm + GitHub Release ${githubReleaseTags.join(' or ')}).`);
541
862
  const [npmPublished, githubReleasePresent] = await Promise.all([
542
863
  npmVersionExists(root, pkg.name, pkg.version),
543
- githubReleaseExists(root, tag),
864
+ anyGithubReleaseExists(root, githubReleaseTags),
544
865
  ]);
545
866
  console.log(
546
867
  `${packageVersion}: durable state npm=${yesNo(npmPublished)} github=${yesNo(githubReleasePresent)} (${Date.now() - start}ms).`,
@@ -548,6 +869,14 @@ async function durableReleaseTagState(root: string, pkg: Pick<ReleasePackage, 'n
548
869
  return { npmPublished, githubReleaseExists: githubReleasePresent };
549
870
  }
550
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
+
551
880
  async function gitReleaseTagsByCreatorDate(root: string): Promise<GitReleaseTagInfo[]> {
552
881
  const result =
553
882
  await $`git for-each-ref --sort=-creatordate --format=${'%(refname:short)%09%(creatordate:unix)%09%(*objectname)%09%(objectname)'} refs/tags`
@@ -582,7 +911,7 @@ async function listMissingGithubReleasePackages(root: string, packages: ReleaseP
582
911
  ).filter((pkg): pkg is ReleasePackage => pkg !== null);
583
912
  }
584
913
 
585
- async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: boolean): Promise<void> {
914
+ async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: boolean): Promise<string | null> {
586
915
  const currentTag = releaseTag(pkg);
587
916
  console.log(`${pkg.name}@${pkg.version}: rendering GitHub Release notes for ${currentTag}.`);
588
917
  console.log(`GitHub release auth: ${envPresence('GH_TOKEN')}, ${envPresence('GITHUB_TOKEN')}.`);
@@ -592,7 +921,7 @@ async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: bo
592
921
  const previousTag = await previousReleaseTag(root, pkg, currentTag);
593
922
  if (dryRun) {
594
923
  await renderNxProjectChangelogContents({ root, pkg, previousTag, dryRun });
595
- return;
924
+ return null;
596
925
  }
597
926
  const contents = await renderNxProjectChangelogContents({ root, pkg, previousTag, dryRun });
598
927
  await createOrUpdateGithubRelease(pkg, contents, {
@@ -600,6 +929,7 @@ async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: bo
600
929
  runGhRelease: (args) => run('gh', args, root),
601
930
  log: (message) => console.log(message),
602
931
  });
932
+ return githubReleaseUrl(root, currentTag);
603
933
  }
604
934
 
605
935
  function envPresence(name: string): string {
@@ -637,7 +967,7 @@ async function writeReleaseSummary(summary: ReleaseSummary<ReleasePackage>): Pro
637
967
  lines.push(`- Git refs pushed: ${summary.pushed ? 'yes' : 'already current'}`);
638
968
  lines.push(`- npm published: ${packageSummary(summary.published)}`);
639
969
  lines.push(`- npm already published: ${packageSummary(summary.alreadyPublished)}`);
640
- lines.push(`- GitHub Releases created/updated: ${packageSummary(summary.githubReleases)}`);
970
+ lines.push(`- GitHub Releases created/updated: ${githubReleaseSummary(summary)}`);
641
971
  }
642
972
  if (summary.rerunRequired) {
643
973
  const message = 'A previous incomplete release was repaired; newer commits remain. Run Publish again.';
@@ -652,13 +982,45 @@ async function writeReleaseSummary(summary: ReleaseSummary<ReleasePackage>): Pro
652
982
  }
653
983
  }
654
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
+
655
1015
  async function writeRepairSummary(summaries: Array<ReleaseSummary<ReleasePackage>>, dryRun: boolean): Promise<void> {
656
1016
  const lines = ['## Pending Release Repair', '', `- Mode: ${dryRun ? 'dry run' : 'release'}`];
657
1017
  if (summaries.length === 0) {
658
1018
  lines.push('- Result: no pending releases needed repair');
659
1019
  } else {
660
1020
  for (const summary of summaries) {
661
- 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
+ );
662
1024
  }
663
1025
  }
664
1026
  const text = `${lines.join('\n')}\n`;
@@ -676,6 +1038,30 @@ function packageSummary(packages: ReleasePackage[]): string {
676
1038
  return packages.map((pkg) => `${pkg.name}@${pkg.version}`).join(', ');
677
1039
  }
678
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
+
679
1065
  async function fetchReleaseRefs(root: string, remote: string, branch: string): Promise<void> {
680
1066
  await run('git', ['fetch', '--tags', remote, branch], root);
681
1067
  }
@@ -685,6 +1071,140 @@ async function gitRefExists(root: string, ref: string): Promise<boolean> {
685
1071
  return result.exitCode === 0;
686
1072
  }
687
1073
 
1074
+ async function packageChangedFilesSince(root: string, ref: string, packagePath: string): Promise<string[]> {
1075
+ const result = await $`git diff --name-only ${`${ref}..HEAD`} -- ${packagePath}`.cwd(root).quiet().nothrow();
1076
+ if (result.exitCode !== 0) {
1077
+ throw new Error(`Unable to inspect package changes under ${packagePath}.`);
1078
+ }
1079
+ const packagePrefix = `${packagePath}/`;
1080
+ return decode(result.stdout)
1081
+ .split('\n')
1082
+ .map((path) => path.trim())
1083
+ .filter(Boolean)
1084
+ .map((path) => (path.startsWith(packagePrefix) ? path.slice(packagePrefix.length) : path));
1085
+ }
1086
+
1087
+ async function packageJsonAtRef(
1088
+ root: string,
1089
+ ref: string,
1090
+ packagePath: string,
1091
+ ): Promise<Record<string, unknown> | null> {
1092
+ const result = await $`git show ${`${ref}:${packagePath}/package.json`}`.cwd(root).quiet().nothrow();
1093
+ if (result.exitCode !== 0) {
1094
+ return null;
1095
+ }
1096
+ try {
1097
+ const parsed = JSON.parse(decode(result.stdout));
1098
+ return isRecord(parsed) ? parsed : null;
1099
+ } catch {
1100
+ return null;
1101
+ }
1102
+ }
1103
+
1104
+ async function currentPackageJson(root: string, packagePath: string): Promise<Record<string, unknown> | null> {
1105
+ return readPackageJson(join(root, packagePath, 'package.json'))?.json ?? null;
1106
+ }
1107
+
1108
+ async function packageBuildInputPatterns(root: string, projectName: string, _packagePath: string): Promise<string[]> {
1109
+ const project = await nxProjectJson(root, projectName);
1110
+ const nxJson = readJsonObject(join(root, 'nx.json')) ?? {};
1111
+ return resolveBuildInputPatterns(project, nxJson);
1112
+ }
1113
+
1114
+ async function nxProjectJson(root: string, projectName: string): Promise<Record<string, unknown>> {
1115
+ const result = await $`nx show project ${projectName} --json`.cwd(root).quiet();
1116
+ const parsed = JSON.parse(decode(result.stdout));
1117
+ if (!isRecord(parsed)) {
1118
+ throw new Error(`Unable to inspect Nx project ${projectName}.`);
1119
+ }
1120
+ return parsed;
1121
+ }
1122
+
1123
+ function resolveBuildInputPatterns(project: Record<string, unknown>, nxJson: Record<string, unknown>): string[] {
1124
+ const targets = recordProperty(project, 'targets');
1125
+ if (!targets) {
1126
+ return [];
1127
+ }
1128
+ return normalizeInputPatterns(collectBuildInputs(targets), nxJson);
1129
+ }
1130
+
1131
+ function collectBuildInputs(targets: Record<string, unknown>): string[] {
1132
+ const build = recordProperty(targets, 'build');
1133
+ if (!build) {
1134
+ return ['production'];
1135
+ }
1136
+ const directInputs = stringArrayProperty(build, 'inputs');
1137
+ if (directInputs.length > 0) {
1138
+ return directInputs;
1139
+ }
1140
+ const inputs: string[] = [];
1141
+ for (const dependency of stringArrayProperty(build, 'dependsOn')) {
1142
+ if (dependency.startsWith('^')) {
1143
+ continue;
1144
+ }
1145
+ const targetName = dependency.includes(':') ? dependency.split(':')[1] : dependency;
1146
+ if (!targetName) {
1147
+ continue;
1148
+ }
1149
+ inputs.push(...stringArrayProperty(recordProperty(targets, targetName), 'inputs'));
1150
+ }
1151
+ return inputs.length > 0 ? inputs : ['production'];
1152
+ }
1153
+
1154
+ function normalizeInputPatterns(inputs: string[], nxJson: Record<string, unknown>): string[] {
1155
+ const patterns: string[] = [];
1156
+ const seen = new Set<string>();
1157
+ for (const input of inputs) {
1158
+ for (const pattern of expandInputPattern(input, nxJson, seen)) {
1159
+ patterns.push(pattern);
1160
+ }
1161
+ }
1162
+ return patterns;
1163
+ }
1164
+
1165
+ function expandInputPattern(input: string, nxJson: Record<string, unknown>, seen: Set<string>): string[] {
1166
+ if (seen.has(input)) {
1167
+ return [];
1168
+ }
1169
+ seen.add(input);
1170
+ if (!input.includes('{')) {
1171
+ const namedInputs = recordProperty(nxJson, 'namedInputs');
1172
+ const namedInput = namedInputs?.[input];
1173
+ if (Array.isArray(namedInput)) {
1174
+ return namedInput.flatMap((entry) => (typeof entry === 'string' ? expandInputPattern(entry, nxJson, seen) : []));
1175
+ }
1176
+ return [];
1177
+ }
1178
+ const excluded = input.startsWith('!');
1179
+ const rawInput = excluded ? input.slice(1) : input;
1180
+ if (!rawInput.startsWith('{projectRoot}/')) {
1181
+ return [];
1182
+ }
1183
+ const packageRelative = rawInput.slice('{projectRoot}/'.length);
1184
+ return [`${excluded ? '!' : ''}${packageRelative}`];
1185
+ }
1186
+
1187
+ function recordProperty(record: Record<string, unknown> | null, key: string): Record<string, unknown> | null {
1188
+ if (!record) {
1189
+ return null;
1190
+ }
1191
+ const value = record[key];
1192
+ return isRecord(value) ? value : null;
1193
+ }
1194
+
1195
+ function stringArrayProperty(record: Record<string, unknown> | null, key: string): string[] {
1196
+ const value = record?.[key];
1197
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
1198
+ }
1199
+
1200
+ async function packageHasHistory(root: string, packagePath: string): Promise<boolean> {
1201
+ const result = await $`git log --format=%H -- ${packagePath}`.cwd(root).quiet().nothrow();
1202
+ if (result.exitCode !== 0) {
1203
+ throw new Error(`Unable to inspect package history under ${packagePath}.`);
1204
+ }
1205
+ return decode(result.stdout).trim().length > 0;
1206
+ }
1207
+
688
1208
  async function gitIsAncestor(root: string, ancestor: string, descendant: string): Promise<boolean> {
689
1209
  const result = await $`git merge-base --is-ancestor ${ancestor} ${descendant}`.cwd(root).quiet().nothrow();
690
1210
  return result.exitCode === 0;
@@ -764,6 +1284,19 @@ async function githubReleaseExists(root: string, tag: string): Promise<boolean>
764
1284
  return (await runStatus('gh', ['release', 'view', tag, '--json', 'tagName'], root, true)) === 0;
765
1285
  }
766
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
+
767
1300
  async function assertRemoteTagExists(root: string, tag: string): Promise<void> {
768
1301
  const branch = await releaseBranch(root);
769
1302
  const remote = await releaseRemote(root, branch);
@@ -887,36 +1420,87 @@ async function runLatestNpm(root: string, npmArgs: string[], env?: Record<string
887
1420
  await run('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...npmArgs], root, env);
888
1421
  }
889
1422
 
890
- async function runLatestNpmPublish(root: string, npmArgs: string[], useBootstrapToken: boolean): Promise<void> {
891
- if (!useBootstrapToken) {
892
- await runLatestNpm(root, npmArgs, { NODE_AUTH_TOKEN: '', NPM_TOKEN: '' });
893
- return;
1423
+ async function runLatestNpmTrust(
1424
+ root: string,
1425
+ npmArgs: string[],
1426
+ env?: Record<string, string>,
1427
+ ): Promise<TrustPublisherResult> {
1428
+ const status = await runInteractiveStatus(
1429
+ 'nix',
1430
+ ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...npmArgs],
1431
+ root,
1432
+ env,
1433
+ );
1434
+ if (status === 0) {
1435
+ return 'configured';
894
1436
  }
895
- const token = process.env.NODE_AUTH_TOKEN || process.env.NPM_TOKEN;
896
- if (!token || process.env.NPM_CONFIG_USERCONFIG) {
897
- if (!token && !process.env.NPM_CONFIG_USERCONFIG) {
898
- throw new Error(
899
- 'First publish for a package requires NODE_AUTH_TOKEN or NPM_TOKEN until trusted publishing exists.',
900
- );
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}`);
901
1447
  }
902
- await runLatestNpm(root, npmArgs);
903
- return;
1448
+ result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...args], root);
904
1449
  }
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
+ }
905
1457
 
906
- const tempDir = await mkdtemp(join(tmpdir(), 'smoo-npm-auth-'));
907
- const npmrc = join(tempDir, '.npmrc');
908
- try {
909
- await writeFile(npmrc, `registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=${token}\n`);
910
- await runLatestNpm(root, npmArgs, { NPM_CONFIG_USERCONFIG: npmrc });
911
- } finally {
912
- await rm(tempDir, { recursive: true, force: true });
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.`);
913
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
+ };
1490
+ }
1491
+
1492
+ async function runLatestNpmPublish(root: string, npmArgs: string[]): Promise<void> {
1493
+ await runLatestNpm(root, npmArgs, { NODE_AUTH_TOKEN: '', NPM_TOKEN: '' });
1494
+ }
1495
+
1496
+ function missingNpmPackagePublishGuidance(pkg: Pick<ReleasePackage, 'name'>): string {
1497
+ return `${pkg.name} does not exist on npm yet. Run smoo release trust-publisher --bootstrap locally before rerunning the Publish workflow.`;
914
1498
  }
915
1499
 
916
1500
  async function promptForNpmOtp(packageName: string): Promise<string> {
917
1501
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
918
1502
  throw new Error(
919
- `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.`,
920
1504
  );
921
1505
  }
922
1506