@smoothbricks/cli 0.1.1 → 0.2.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 (117) hide show
  1. package/README.md +155 -30
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +91 -7
  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/lib/cli-package.d.ts +4 -0
  8. package/dist/lib/cli-package.d.ts.map +1 -0
  9. package/dist/lib/cli-package.js +15 -0
  10. package/dist/lib/run.d.ts +5 -0
  11. package/dist/lib/run.d.ts.map +1 -1
  12. package/dist/lib/run.js +13 -0
  13. package/dist/lib/workspace.d.ts +15 -1
  14. package/dist/lib/workspace.d.ts.map +1 -1
  15. package/dist/lib/workspace.js +25 -2
  16. package/dist/monorepo/commit-msg.d.ts +8 -1
  17. package/dist/monorepo/commit-msg.d.ts.map +1 -1
  18. package/dist/monorepo/commit-msg.js +85 -7
  19. package/dist/monorepo/index.d.ts +19 -2
  20. package/dist/monorepo/index.d.ts.map +1 -1
  21. package/dist/monorepo/index.js +104 -16
  22. package/dist/monorepo/managed-files.js +1 -1
  23. package/dist/monorepo/nx-sync.d.ts +2 -1
  24. package/dist/monorepo/nx-sync.d.ts.map +1 -1
  25. package/dist/monorepo/nx-sync.js +8 -2
  26. package/dist/monorepo/package-hygiene.d.ts +6 -2
  27. package/dist/monorepo/package-hygiene.d.ts.map +1 -1
  28. package/dist/monorepo/package-hygiene.js +12 -4
  29. package/dist/monorepo/package-policy.d.ts +22 -2
  30. package/dist/monorepo/package-policy.d.ts.map +1 -1
  31. package/dist/monorepo/package-policy.js +1167 -8
  32. package/dist/monorepo/packed-package.d.ts +3 -0
  33. package/dist/monorepo/packed-package.d.ts.map +1 -1
  34. package/dist/monorepo/packed-package.js +68 -36
  35. package/dist/monorepo/packs/index.d.ts +20 -1
  36. package/dist/monorepo/packs/index.d.ts.map +1 -1
  37. package/dist/monorepo/packs/index.js +238 -19
  38. package/dist/monorepo/publish-workflow.d.ts +0 -4
  39. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  40. package/dist/monorepo/publish-workflow.js +5 -12
  41. package/dist/monorepo/tool-validation.d.ts +22 -0
  42. package/dist/monorepo/tool-validation.d.ts.map +1 -0
  43. package/dist/monorepo/tool-validation.js +399 -0
  44. package/dist/nx/index.d.ts +24 -0
  45. package/dist/nx/index.d.ts.map +1 -0
  46. package/dist/nx/index.js +94 -0
  47. package/dist/release/bootstrap-npm-packages.d.ts +19 -0
  48. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -0
  49. package/dist/release/bootstrap-npm-packages.js +58 -0
  50. package/dist/release/candidates.d.ts +11 -0
  51. package/dist/release/candidates.d.ts.map +1 -0
  52. package/dist/release/candidates.js +181 -0
  53. package/dist/release/core.d.ts +1 -0
  54. package/dist/release/core.d.ts.map +1 -1
  55. package/dist/release/github-release.js +2 -2
  56. package/dist/release/index.d.ts +25 -0
  57. package/dist/release/index.d.ts.map +1 -1
  58. package/dist/release/index.js +247 -49
  59. package/dist/release/npm-auth.d.ts +0 -1
  60. package/dist/release/npm-auth.d.ts.map +1 -1
  61. package/dist/release/npm-auth.js +4 -16
  62. package/dist/release/orchestration.d.ts +2 -1
  63. package/dist/release/orchestration.d.ts.map +1 -1
  64. package/dist/release/orchestration.js +9 -1
  65. package/dist/release/publish-plan.js +1 -1
  66. package/managed/raw/git-format-staged.yml +54 -0
  67. package/managed/raw/tooling/git-hooks/commit-msg.sh +1 -1
  68. package/managed/templates/github/actions/setup-devenv/action.yml +1 -7
  69. package/managed/templates/github/workflows/ci.yml +1 -0
  70. package/package.json +15 -3
  71. package/src/cli.ts +134 -20
  72. package/src/devenv/index.test.ts +49 -0
  73. package/src/devenv/index.ts +38 -0
  74. package/src/lib/cli-package.ts +18 -0
  75. package/src/lib/run.ts +19 -0
  76. package/src/lib/workspace.ts +41 -2
  77. package/src/monorepo/__tests__/publish-workflow.test.ts +16 -9
  78. package/src/monorepo/commit-msg.test.ts +45 -0
  79. package/src/monorepo/commit-msg.ts +107 -7
  80. package/src/monorepo/index.ts +142 -16
  81. package/src/monorepo/managed-files.ts +1 -1
  82. package/src/monorepo/nx-sync.ts +9 -2
  83. package/src/monorepo/package-hygiene.test.ts +34 -0
  84. package/src/monorepo/package-hygiene.ts +26 -4
  85. package/src/monorepo/package-policy.test.ts +902 -0
  86. package/src/monorepo/package-policy.ts +1393 -7
  87. package/src/monorepo/packed-package.ts +89 -37
  88. package/src/monorepo/packs/index.test.ts +189 -0
  89. package/src/monorepo/packs/index.ts +311 -20
  90. package/src/monorepo/publish-workflow.ts +8 -16
  91. package/src/monorepo/setup-test-tracing.test.ts +120 -0
  92. package/src/monorepo/tool-validation.test.ts +261 -0
  93. package/src/monorepo/tool-validation.ts +462 -0
  94. package/src/nx/index.test.ts +64 -0
  95. package/src/nx/index.ts +119 -0
  96. package/src/release/__tests__/bootstrap-npm-packages.test.ts +127 -0
  97. package/src/release/__tests__/candidates.test.ts +217 -0
  98. package/src/release/__tests__/core-properties.test.ts +4 -4
  99. package/src/release/__tests__/core-scenarios.test.ts +15 -5
  100. package/src/release/__tests__/core.test.ts +2 -2
  101. package/src/release/__tests__/fixture-repo.test.ts +22 -13
  102. package/src/release/__tests__/github-release.test.ts +13 -8
  103. package/src/release/__tests__/helpers/fixture-repo.ts +1 -0
  104. package/src/release/__tests__/npm-auth.test.ts +12 -21
  105. package/src/release/__tests__/orchestration.test.ts +51 -7
  106. package/src/release/__tests__/publish-plan.test.ts +9 -3
  107. package/src/release/__tests__/retag-unpublished.test.ts +1 -1
  108. package/src/release/__tests__/trust-publisher.test.ts +133 -0
  109. package/src/release/bootstrap-npm-packages.ts +88 -0
  110. package/src/release/candidates.ts +225 -0
  111. package/src/release/core.ts +1 -0
  112. package/src/release/github-release.ts +2 -2
  113. package/src/release/index.ts +346 -56
  114. package/src/release/npm-auth.ts +4 -33
  115. package/src/release/orchestration.ts +12 -2
  116. package/src/release/publish-plan.ts +1 -1
  117. package/managed/raw/.git-format-staged.yml +0 -47
@@ -5,16 +5,24 @@ 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, 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,
19
27
  } from './core.js';
20
28
  import { createOrUpdateGithubRelease, renderNxProjectChangelogContents } from './github-release.js';
@@ -46,10 +54,19 @@ export interface ReleaseRepairPendingOptions {
46
54
 
47
55
  export interface ReleaseTrustPublisherOptions {
48
56
  dryRun?: boolean;
57
+ bootstrap?: boolean;
49
58
  otp?: string;
59
+ bootstrapOtp?: string;
50
60
  skipLogin?: boolean;
51
61
  }
52
62
 
63
+ export interface ReleaseBootstrapNpmPackagesOptions {
64
+ dryRun?: boolean;
65
+ skipLogin?: boolean;
66
+ otp?: string;
67
+ packages?: string[];
68
+ }
69
+
53
70
  export interface ReleaseRetagUnpublishedOptions {
54
71
  tags: string[];
55
72
  to?: string;
@@ -63,13 +80,14 @@ export interface ReleaseRetagUnpublishedOptions {
63
80
  export async function releaseVersion(root: string, options: ReleaseVersionOptions): Promise<void> {
64
81
  const bump = releaseBumpArg(options.bump);
65
82
  const packages = releasePackages(root);
66
- const projects = releasePackageProjects(packages);
67
83
  const result = await runReleaseVersion(
68
84
  {
69
85
  releasePackagesAtHead: () => releasePackagesAtHead(root, packages),
86
+ releaseVersionPackages: (releaseBump) => releaseVersionPackages(root, packages, releaseBump),
70
87
  ensureLocalReleaseTags: (releasePackages) => ensureLocalReleaseTags(root, releasePackages),
71
88
  gitHead: () => gitHead(root),
72
- runNxReleaseVersion: (releaseBump, dryRun) => runNxReleaseVersion(root, projects, releaseBump, dryRun),
89
+ runNxReleaseVersion: (releasePackages, releaseBump, dryRun) =>
90
+ runNxReleaseVersion(root, releasePackageProjects(releasePackages), releaseBump, dryRun),
73
91
  assertCleanGitTree: () => assertCleanGitTree(root),
74
92
  },
75
93
  { bump, dryRun: options.dryRun === true },
@@ -134,63 +152,152 @@ export async function releaseRepairPending(root: string, options: ReleaseRepairP
134
152
  export async function releaseTrustPublisher(root: string, options: ReleaseTrustPublisherOptions): Promise<void> {
135
153
  const repository = githubRepositoryFromRootPackage(root);
136
154
  const workflow = 'publish.yml';
137
- const packages = listReleasePackages(root);
155
+ await configureTrustedPublishers(
156
+ {
157
+ repository,
158
+ workflow,
159
+ listReleasePackages: () => listReleasePackages(root),
160
+ packageExists: (name) => npmPackageExists(root, name),
161
+ bootstrapNpmPackages: (bootstrapOptions) =>
162
+ bootstrapNpmPackages(
163
+ {
164
+ listReleasePackages: () => listReleasePackages(root),
165
+ packageExists: (name) => npmPackageExists(root, name),
166
+ login: () => runLatestNpm(root, ['login', '--auth-type=web']),
167
+ publishPlaceholder: (pkg, env) => publishPlaceholderPackage(root, pkg, env),
168
+ promptOtp: (packageName) => promptForNpmOtp(packageName),
169
+ log: (message) => console.log(message),
170
+ },
171
+ bootstrapOptions,
172
+ ),
173
+ login: () => runLatestNpm(root, ['login', '--auth-type=web']),
174
+ trustPublisher: (pkg, dryRun, env) => {
175
+ const args = ['trust', 'github', pkg.name, '--file', workflow, '--repo', repository, '--yes'];
176
+ if (dryRun) {
177
+ args.push('--dry-run');
178
+ }
179
+ return runLatestNpmTrust(root, args, env);
180
+ },
181
+ promptOtp: (packageName) => promptForNpmOtp(packageName),
182
+ log: (message) => console.log(message),
183
+ error: (message) => console.error(message),
184
+ },
185
+ options,
186
+ );
187
+ }
188
+
189
+ export interface TrustPublisherShell<Package extends ReleasePackageInfo = ReleasePackageInfo> {
190
+ repository: string;
191
+ workflow: string;
192
+ listReleasePackages(): Package[];
193
+ packageExists(name: string): Promise<boolean>;
194
+ bootstrapNpmPackages(options: BootstrapNpmPackagesOptions): Promise<Package[]>;
195
+ login(): Promise<void>;
196
+ trustPublisher(pkg: Package, dryRun: boolean, env?: Record<string, string>): Promise<TrustPublisherResult>;
197
+ promptOtp(packageName: string): Promise<string>;
198
+ log(message: string): void;
199
+ error(message: string): void;
200
+ }
201
+
202
+ export type TrustPublisherResult = 'configured' | 'already-configured';
203
+
204
+ export async function configureTrustedPublishers<Package extends ReleasePackageInfo>(
205
+ shell: TrustPublisherShell<Package>,
206
+ options: ReleaseTrustPublisherOptions,
207
+ ): Promise<void> {
208
+ const packages = shell.listReleasePackages();
138
209
  if (packages.length === 0) {
139
210
  throw new Error('No owned release packages found.');
140
211
  }
141
212
 
213
+ let bootstrapLoggedIn = false;
214
+ if (options.bootstrap) {
215
+ const bootstrapped = await shell.bootstrapNpmPackages({
216
+ dryRun: options.dryRun === true,
217
+ skipLogin: options.skipLogin === true,
218
+ otp: options.bootstrapOtp,
219
+ packages: [],
220
+ });
221
+ bootstrapLoggedIn = bootstrapped.length > 0 && !options.dryRun && !options.skipLogin;
222
+ }
223
+
142
224
  if (!options.dryRun) {
143
225
  const packageStates = await Promise.all(
144
- packages.map(async (pkg) => ((await npmPackageExists(root, pkg.name)) ? null : pkg.name)),
226
+ packages.map(async (pkg) => ((await shell.packageExists(pkg.name)) ? null : pkg.name)),
145
227
  );
146
228
  const missingPackages = packageStates.filter((name): name is string => name !== null);
147
229
  if (missingPackages.length > 0) {
148
230
  throw new Error(
149
231
  '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. ' +
232
+ 'Run smoo release trust-publisher --bootstrap locally. ' +
151
233
  `Missing packages: ${missingPackages.join(', ')}`,
152
234
  );
153
235
  }
154
236
  }
155
237
 
156
- if (!options.dryRun && !options.skipLogin) {
157
- await runLatestNpm(root, ['login', '--auth-type=web']);
238
+ if (!options.dryRun && !options.skipLogin && !bootstrapLoggedIn) {
239
+ await shell.login();
158
240
  }
159
241
 
160
242
  const failedPackages: string[] = [];
161
243
  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');
166
- }
244
+ shell.log(`${pkg.name}: trusting GitHub Actions ${shell.repository}/${shell.workflow}`);
167
245
  while (true) {
168
- const otp = options.dryRun ? undefined : (options.otp ?? (await promptForNpmOtp(pkg.name)));
246
+ const otp = options.dryRun ? undefined : (options.otp ?? (await shell.promptOtp(pkg.name)));
169
247
  try {
170
- await runLatestNpm(root, args, otp ? { NPM_CONFIG_OTP: otp } : undefined);
248
+ const result = await shell.trustPublisher(
249
+ pkg,
250
+ options.dryRun === true,
251
+ otp ? { NPM_CONFIG_OTP: otp } : undefined,
252
+ );
253
+ if (result === 'already-configured') {
254
+ shell.log(`${pkg.name}: npm trusted publisher is already configured; skipping.`);
255
+ }
171
256
  break;
172
257
  } catch (error) {
173
- console.error(`${pkg.name}: npm trusted publisher setup failed.`);
174
- console.error(error instanceof Error ? error.message : String(error));
258
+ shell.error(`${pkg.name}: npm trusted publisher setup failed.`);
259
+ shell.error(error instanceof Error ? error.message : String(error));
175
260
  if (!options.dryRun) {
176
- console.error('npm OTP codes are single-use. Generate a fresh OTP before retrying this package.');
261
+ shell.error('npm OTP codes are single-use. Generate a fresh OTP before retrying this package.');
177
262
  }
178
263
  if (options.otp || options.dryRun) {
179
264
  failedPackages.push(pkg.name);
180
265
  break;
181
266
  }
182
- console.error('Retrying this package. Press Ctrl-C to stop.');
267
+ shell.error('Retrying this package. Press Ctrl-C to stop.');
183
268
  }
184
269
  }
185
270
  }
186
271
  if (failedPackages.length > 0) {
187
- console.error(`Trusted publishing was not configured for: ${failedPackages.join(', ')}`);
272
+ shell.error(`Trusted publishing was not configured for: ${failedPackages.join(', ')}`);
188
273
  if (options.otp) {
189
- console.error('Rerun smoo release trust-publisher without --otp to enter a fresh OTP for each failed package.');
274
+ shell.error('Rerun smoo release trust-publisher without --otp to enter a fresh OTP for each failed package.');
190
275
  }
191
276
  }
192
277
  }
193
278
 
279
+ export async function releaseBootstrapNpmPackages(
280
+ root: string,
281
+ options: ReleaseBootstrapNpmPackagesOptions,
282
+ ): Promise<void> {
283
+ await bootstrapNpmPackages(
284
+ {
285
+ listReleasePackages: () => listReleasePackages(root),
286
+ packageExists: (name) => npmPackageExists(root, name),
287
+ login: () => runLatestNpm(root, ['login', '--auth-type=web']),
288
+ publishPlaceholder: (pkg, env) => publishPlaceholderPackage(root, pkg, env),
289
+ promptOtp: (packageName) => promptForNpmOtp(packageName),
290
+ log: (message) => console.log(message),
291
+ },
292
+ {
293
+ dryRun: options.dryRun === true,
294
+ skipLogin: options.skipLogin === true,
295
+ otp: options.otp,
296
+ packages: options.packages ?? [],
297
+ },
298
+ );
299
+ }
300
+
194
301
  export async function releaseRetagUnpublished(root: string, options: ReleaseRetagUnpublishedOptions): Promise<void> {
195
302
  const toRef = options.to ?? 'HEAD';
196
303
  const dispatch = options.dispatch === true;
@@ -243,21 +350,14 @@ async function listUnpublishedPackages(root: string, packages: ReleasePackage[])
243
350
  return states.filter((state) => !state.published).map((state) => state.pkg);
244
351
  }
245
352
 
246
- async function publishPackedPackage(
247
- root: string,
248
- pkg: ReleasePackage,
249
- tag: string,
250
- dryRun: boolean,
251
- useBootstrapToken: boolean,
252
- ): Promise<void> {
353
+ async function publishPackedPackage(root: string, pkg: ReleasePackage, tag: string, dryRun: boolean): Promise<void> {
253
354
  const tempDir = await mkdtemp(join(tmpdir(), 'smoo-publish-'));
254
355
  const tarball = join(tempDir, `${safeTarballPrefix(pkg.name)}-${pkg.version}.tgz`);
255
356
  try {
256
357
  console.log(`${pkg.name}@${pkg.version}: packing with bun pm pack`);
257
358
  await run('bun', ['pm', 'pack', '--filename', tarball, '--ignore-scripts', '--quiet'], join(root, pkg.path));
258
359
  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.
360
+ // npm CLI owns authentication here: trusted publishing OIDC when configured.
261
361
  // Bun still produces the tarball so workspace:* dependencies are resolved the
262
362
  // same way smoo validates packed packages before release.
263
363
  const args = ['publish', tarball, '--access', 'public', '--tag', tag, '--provenance'];
@@ -267,7 +367,7 @@ async function publishPackedPackage(
267
367
  await publishWithAuthDiagnostics(
268
368
  pkg,
269
369
  {
270
- publish: () => runLatestNpmPublish(root, args, useBootstrapToken),
370
+ publish: () => runLatestNpmPublish(root, args),
271
371
  versionExists: () => npmVersionExists(root, pkg.name, pkg.version),
272
372
  log: (message) => console.log(message),
273
373
  error: (message) => console.error(message),
@@ -279,7 +379,6 @@ async function publishPackedPackage(
279
379
  },
280
380
  },
281
381
  {
282
- useBootstrapToken,
283
382
  tokenPresent: npmAuthTokenPresent(),
284
383
  repository: githubRepositoryFromRootPackage(root),
285
384
  },
@@ -293,6 +392,38 @@ function npmAuthTokenPresent(): boolean {
293
392
  return Boolean(process.env.NODE_AUTH_TOKEN || process.env.NPM_TOKEN || process.env.NPM_CONFIG_USERCONFIG);
294
393
  }
295
394
 
395
+ async function publishPlaceholderPackage(
396
+ root: string,
397
+ pkg: ReleasePackage,
398
+ env?: Record<string, string>,
399
+ ): Promise<void> {
400
+ const tempDir = await mkdtemp(join(tmpdir(), 'smoo-npm-bootstrap-'));
401
+ try {
402
+ await writeFile(
403
+ join(tempDir, 'package.json'),
404
+ `${JSON.stringify(
405
+ {
406
+ name: pkg.name,
407
+ version: NPM_BOOTSTRAP_VERSION,
408
+ description: `Bootstrap placeholder for ${pkg.name}. Real releases are published by SmoothBricks CI.`,
409
+ license: stringProperty(pkg.json, 'license') ?? 'UNLICENSED',
410
+ repository: pkg.json.repository,
411
+ publishConfig: { access: 'public' },
412
+ },
413
+ null,
414
+ 2,
415
+ )}\n`,
416
+ );
417
+ await writeFile(
418
+ join(tempDir, 'README.md'),
419
+ `# ${pkg.name}\n\nThis is a bootstrap placeholder. Real package versions are published by SmoothBricks release automation.\n`,
420
+ );
421
+ await runLatestNpm(root, ['publish', tempDir, '--access', 'public', '--tag', NPM_BOOTSTRAP_DIST_TAG], env);
422
+ } finally {
423
+ await rm(tempDir, { recursive: true, force: true });
424
+ }
425
+ }
426
+
296
427
  function safeTarballPrefix(name: string): string {
297
428
  return name.replace(/^@/, '').replace(/[^a-zA-Z0-9._-]+/g, '-');
298
429
  }
@@ -314,7 +445,29 @@ function releasePackages(root: string): ReleasePackage[] {
314
445
  }
315
446
 
316
447
  function releasePackageProjects(packages: ReleasePackage[]): string {
317
- return packages.map((pkg) => pkg.name).join(',');
448
+ return packages.map((pkg) => pkg.projectName).join(',');
449
+ }
450
+
451
+ async function releaseVersionPackages(
452
+ root: string,
453
+ packages: ReleasePackage[],
454
+ bump: string,
455
+ ): Promise<ReleasePackage[]> {
456
+ if (bump !== 'auto') {
457
+ return packages;
458
+ }
459
+ return autoReleaseCandidatePackages(
460
+ {
461
+ gitRefExists: (ref) => gitRefExists(root, ref),
462
+ packageChangedFilesSince: (ref, packagePath) => packageChangedFilesSince(root, ref, packagePath),
463
+ packageJsonAtRef: (ref, packagePath) => packageJsonAtRef(root, ref, packagePath),
464
+ currentPackageJson: (packagePath) => currentPackageJson(root, packagePath),
465
+ packageBuildInputPatterns: (projectName, packagePath) =>
466
+ packageBuildInputPatterns(root, projectName, packagePath),
467
+ packageHasHistory: (packagePath) => packageHasHistory(root, packagePath),
468
+ },
469
+ packages,
470
+ );
318
471
  }
319
472
 
320
473
  async function runNxReleaseVersion(root: string, projects: string, bump: string, dryRun: boolean): Promise<void> {
@@ -475,7 +628,10 @@ function releaseCompletionShell(root: string): ReleaseCompletionShell<ReleasePac
475
628
  buildReleaseCandidate: (packages) => buildReleaseCandidate(root, packages),
476
629
  publishPackage: async (pkg, distTag, dryRun) => {
477
630
  const packageExists = dryRun ? true : await npmPackageExists(root, pkg.name);
478
- await publishPackedPackage(root, pkg, distTag, dryRun, !dryRun && !packageExists);
631
+ if (!packageExists) {
632
+ throw new Error(missingNpmPackagePublishGuidance(pkg));
633
+ }
634
+ await publishPackedPackage(root, pkg, distTag, dryRun);
479
635
  },
480
636
  listGithubMissingPackages: (packages) => listMissingGithubReleasePackages(root, packages),
481
637
  createGithubRelease: (pkg, dryRun) => createGithubRelease(root, pkg, dryRun),
@@ -685,6 +841,140 @@ async function gitRefExists(root: string, ref: string): Promise<boolean> {
685
841
  return result.exitCode === 0;
686
842
  }
687
843
 
844
+ async function packageChangedFilesSince(root: string, ref: string, packagePath: string): Promise<string[]> {
845
+ const result = await $`git diff --name-only ${`${ref}..HEAD`} -- ${packagePath}`.cwd(root).quiet().nothrow();
846
+ if (result.exitCode !== 0) {
847
+ throw new Error(`Unable to inspect package changes under ${packagePath}.`);
848
+ }
849
+ const packagePrefix = `${packagePath}/`;
850
+ return decode(result.stdout)
851
+ .split('\n')
852
+ .map((path) => path.trim())
853
+ .filter(Boolean)
854
+ .map((path) => (path.startsWith(packagePrefix) ? path.slice(packagePrefix.length) : path));
855
+ }
856
+
857
+ async function packageJsonAtRef(
858
+ root: string,
859
+ ref: string,
860
+ packagePath: string,
861
+ ): Promise<Record<string, unknown> | null> {
862
+ const result = await $`git show ${`${ref}:${packagePath}/package.json`}`.cwd(root).quiet().nothrow();
863
+ if (result.exitCode !== 0) {
864
+ return null;
865
+ }
866
+ try {
867
+ const parsed = JSON.parse(decode(result.stdout));
868
+ return isRecord(parsed) ? parsed : null;
869
+ } catch {
870
+ return null;
871
+ }
872
+ }
873
+
874
+ async function currentPackageJson(root: string, packagePath: string): Promise<Record<string, unknown> | null> {
875
+ return readPackageJson(join(root, packagePath, 'package.json'))?.json ?? null;
876
+ }
877
+
878
+ async function packageBuildInputPatterns(root: string, projectName: string, _packagePath: string): Promise<string[]> {
879
+ const project = await nxProjectJson(root, projectName);
880
+ const nxJson = readJsonObject(join(root, 'nx.json')) ?? {};
881
+ return resolveBuildInputPatterns(project, nxJson);
882
+ }
883
+
884
+ async function nxProjectJson(root: string, projectName: string): Promise<Record<string, unknown>> {
885
+ const result = await $`nx show project ${projectName} --json`.cwd(root).quiet();
886
+ const parsed = JSON.parse(decode(result.stdout));
887
+ if (!isRecord(parsed)) {
888
+ throw new Error(`Unable to inspect Nx project ${projectName}.`);
889
+ }
890
+ return parsed;
891
+ }
892
+
893
+ function resolveBuildInputPatterns(project: Record<string, unknown>, nxJson: Record<string, unknown>): string[] {
894
+ const targets = recordProperty(project, 'targets');
895
+ if (!targets) {
896
+ return [];
897
+ }
898
+ return normalizeInputPatterns(collectBuildInputs(targets), nxJson);
899
+ }
900
+
901
+ function collectBuildInputs(targets: Record<string, unknown>): string[] {
902
+ const build = recordProperty(targets, 'build');
903
+ if (!build) {
904
+ return ['production'];
905
+ }
906
+ const directInputs = stringArrayProperty(build, 'inputs');
907
+ if (directInputs.length > 0) {
908
+ return directInputs;
909
+ }
910
+ const inputs: string[] = [];
911
+ for (const dependency of stringArrayProperty(build, 'dependsOn')) {
912
+ if (dependency.startsWith('^')) {
913
+ continue;
914
+ }
915
+ const targetName = dependency.includes(':') ? dependency.split(':')[1] : dependency;
916
+ if (!targetName) {
917
+ continue;
918
+ }
919
+ inputs.push(...stringArrayProperty(recordProperty(targets, targetName), 'inputs'));
920
+ }
921
+ return inputs.length > 0 ? inputs : ['production'];
922
+ }
923
+
924
+ function normalizeInputPatterns(inputs: string[], nxJson: Record<string, unknown>): string[] {
925
+ const patterns: string[] = [];
926
+ const seen = new Set<string>();
927
+ for (const input of inputs) {
928
+ for (const pattern of expandInputPattern(input, nxJson, seen)) {
929
+ patterns.push(pattern);
930
+ }
931
+ }
932
+ return patterns;
933
+ }
934
+
935
+ function expandInputPattern(input: string, nxJson: Record<string, unknown>, seen: Set<string>): string[] {
936
+ if (seen.has(input)) {
937
+ return [];
938
+ }
939
+ seen.add(input);
940
+ if (!input.includes('{')) {
941
+ const namedInputs = recordProperty(nxJson, 'namedInputs');
942
+ const namedInput = namedInputs?.[input];
943
+ if (Array.isArray(namedInput)) {
944
+ return namedInput.flatMap((entry) => (typeof entry === 'string' ? expandInputPattern(entry, nxJson, seen) : []));
945
+ }
946
+ return [];
947
+ }
948
+ const excluded = input.startsWith('!');
949
+ const rawInput = excluded ? input.slice(1) : input;
950
+ if (!rawInput.startsWith('{projectRoot}/')) {
951
+ return [];
952
+ }
953
+ const packageRelative = rawInput.slice('{projectRoot}/'.length);
954
+ return [`${excluded ? '!' : ''}${packageRelative}`];
955
+ }
956
+
957
+ function recordProperty(record: Record<string, unknown> | null, key: string): Record<string, unknown> | null {
958
+ if (!record) {
959
+ return null;
960
+ }
961
+ const value = record[key];
962
+ return isRecord(value) ? value : null;
963
+ }
964
+
965
+ function stringArrayProperty(record: Record<string, unknown> | null, key: string): string[] {
966
+ const value = record?.[key];
967
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
968
+ }
969
+
970
+ async function packageHasHistory(root: string, packagePath: string): Promise<boolean> {
971
+ const result = await $`git log --format=%H -- ${packagePath}`.cwd(root).quiet().nothrow();
972
+ if (result.exitCode !== 0) {
973
+ throw new Error(`Unable to inspect package history under ${packagePath}.`);
974
+ }
975
+ return decode(result.stdout).trim().length > 0;
976
+ }
977
+
688
978
  async function gitIsAncestor(root: string, ancestor: string, descendant: string): Promise<boolean> {
689
979
  const result = await $`git merge-base --is-ancestor ${ancestor} ${descendant}`.cwd(root).quiet().nothrow();
690
980
  return result.exitCode === 0;
@@ -887,30 +1177,30 @@ async function runLatestNpm(root: string, npmArgs: string[], env?: Record<string
887
1177
  await run('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...npmArgs], root, env);
888
1178
  }
889
1179
 
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;
1180
+ async function runLatestNpmTrust(
1181
+ root: string,
1182
+ npmArgs: string[],
1183
+ env?: Record<string, string>,
1184
+ ): Promise<TrustPublisherResult> {
1185
+ const result = await runResult('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...npmArgs], root, env);
1186
+ if (result.exitCode === 0) {
1187
+ return 'configured';
894
1188
  }
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
- );
901
- }
902
- await runLatestNpm(root, npmArgs);
903
- return;
1189
+ const output = `${result.stdout}\n${result.stderr}`;
1190
+ if (/\bE409\b|\b409 Conflict\b/.test(output)) {
1191
+ return 'already-configured';
904
1192
  }
1193
+ throw new Error(
1194
+ `nix shell nixpkgs#nodejs_latest -c npm ${npmArgs.join(' ')} failed with exit code ${result.exitCode}`,
1195
+ );
1196
+ }
905
1197
 
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 });
913
- }
1198
+ async function runLatestNpmPublish(root: string, npmArgs: string[]): Promise<void> {
1199
+ await runLatestNpm(root, npmArgs, { NODE_AUTH_TOKEN: '', NPM_TOKEN: '' });
1200
+ }
1201
+
1202
+ function missingNpmPackagePublishGuidance(pkg: Pick<ReleasePackage, 'name'>): string {
1203
+ return `${pkg.name} does not exist on npm yet. Run smoo release trust-publisher --bootstrap locally before rerunning the Publish workflow.`;
914
1204
  }
915
1205
 
916
1206
  async function promptForNpmOtp(packageName: string): Promise<string> {
@@ -1,7 +1,6 @@
1
1
  import type { ReleasePackageInfo } from './core.js';
2
2
 
3
3
  export interface NpmPublishAuthFailureOptions {
4
- useBootstrapToken: boolean;
5
4
  tokenPresent: boolean;
6
5
  repository?: string;
7
6
  }
@@ -30,7 +29,7 @@ export async function publishWithAuthDiagnostics(
30
29
  shell.error(npmPublishAuthFailureMessage(pkg, options));
31
30
  await shell.appendSummary(npmPublishAuthFailureMarkdown(pkg, options));
32
31
  throw new Error(
33
- `${packageVersion}: npm publish authentication failed. Run smoo release trust-publisher after the package exists on npm; see the warning banner above for details.`,
32
+ `${packageVersion}: npm publish authentication failed. Run smoo release trust-publisher; see the warning banner above for details.`,
34
33
  { cause: error },
35
34
  );
36
35
  }
@@ -42,26 +41,11 @@ export function npmPublishAuthFailureMessage(
42
41
  ): string {
43
42
  const packageVersion = `${pkg.name}@${pkg.version}`;
44
43
  const lines = [
45
- `::error title=npm publish authentication failed::${packageVersion} could not be published. This usually means npm trusted publishing is not configured for this package/workflow/repo, or the bootstrap NPM_TOKEN is missing/invalid.`,
44
+ `::error title=npm publish authentication failed::${packageVersion} could not be published. This usually means npm trusted publishing is not configured for this package/workflow/repo.`,
46
45
  '',
47
46
  `🚨 npm publish authentication failed for ${packageVersion}`,
48
47
  '',
49
48
  ];
50
- if (options.useBootstrapToken) {
51
- lines.push(
52
- 'smoo expected a temporary npm automation token because this package does not exist on npm yet.',
53
- options.tokenPresent
54
- ? 'NODE_AUTH_TOKEN/NPM_TOKEN is set, but npm still rejected the bootstrap publish. Check that the token is valid, has publish rights for this scope, and is available to this workflow.'
55
- : 'NODE_AUTH_TOKEN/NPM_TOKEN is not set. Add a temporary NPM_TOKEN repository secret and rerun the Publish workflow.',
56
- '',
57
- 'After the first successful publish, run:',
58
- ' smoo release trust-publisher',
59
- '',
60
- 'Then remove the temporary bootstrap token path for future releases.',
61
- );
62
- return lines.join('\n');
63
- }
64
-
65
49
  lines.push(
66
50
  'smoo expected npm trusted publishing/OIDC because this package already exists on npm.',
67
51
  options.tokenPresent
@@ -75,7 +59,7 @@ export function npmPublishAuthFailureMessage(
75
59
  ' workflow: publish.yml',
76
60
  '3. Rerun the Publish workflow.',
77
61
  '',
78
- 'For first-ever package publishes, add a temporary NPM_TOKEN repository secret, publish once, then run smoo release trust-publisher.',
62
+ 'For first-ever package publishes, run locally: smoo release trust-publisher --bootstrap.',
79
63
  );
80
64
  return lines.join('\n');
81
65
  }
@@ -86,19 +70,6 @@ export function npmPublishAuthFailureMarkdown(
86
70
  ): string {
87
71
  const packageVersion = `${pkg.name}@${pkg.version}`;
88
72
  const lines = ['## 🚨 npm Publish Authentication Failed', '', `Package: \`${packageVersion}\``, ''];
89
- if (options.useBootstrapToken) {
90
- lines.push(
91
- 'smoo expected a temporary npm automation token because this package does not exist on npm yet.',
92
- '',
93
- options.tokenPresent
94
- ? '`NODE_AUTH_TOKEN`/`NPM_TOKEN` is set, but npm still rejected the bootstrap publish. Check that the token is valid, has publish rights for this scope, and is available to this workflow.'
95
- : '`NODE_AUTH_TOKEN`/`NPM_TOKEN` is not set. Add a temporary `NPM_TOKEN` repository secret and rerun the Publish workflow.',
96
- '',
97
- 'After the first successful publish, run `smoo release trust-publisher`, then remove the temporary bootstrap token path for future releases.',
98
- );
99
- return lines.join('\n');
100
- }
101
-
102
73
  lines.push(
103
74
  'smoo expected npm trusted publishing/OIDC because this package already exists on npm.',
104
75
  '',
@@ -112,7 +83,7 @@ export function npmPublishAuthFailureMarkdown(
112
83
  `2. Ensure the npm trusted publisher uses repository \`${trustedPublisherRepository(options)}\` and workflow \`publish.yml\``,
113
84
  '3. Rerun the Publish workflow.',
114
85
  '',
115
- 'For first-ever package publishes, add a temporary `NPM_TOKEN` repository secret, publish once, then run `smoo release trust-publisher`.',
86
+ 'For first-ever package publishes, run `smoo release trust-publisher --bootstrap` locally.',
116
87
  );
117
88
  return lines.join('\n');
118
89
  }
@@ -35,9 +35,10 @@ export interface ReleaseRepairShell<Package extends ReleasePackageInfo = Release
35
35
 
36
36
  export interface ReleaseVersionShell<Package extends ReleasePackageInfo = ReleasePackageInfo> {
37
37
  releasePackagesAtHead(): Promise<Package[]>;
38
+ releaseVersionPackages(bump: string): Promise<Package[]>;
38
39
  ensureLocalReleaseTags(packages: Package[]): Promise<void>;
39
40
  gitHead(): Promise<string>;
40
- runNxReleaseVersion(bump: string, dryRun: boolean): Promise<void>;
41
+ runNxReleaseVersion(packages: Package[], bump: string, dryRun: boolean): Promise<void>;
41
42
  assertCleanGitTree(): Promise<void>;
42
43
  }
43
44
 
@@ -59,8 +60,17 @@ export async function runReleaseVersion<Package extends ReleasePackageInfo>(
59
60
  }
60
61
  }
61
62
 
63
+ const versionPackages = await shell.releaseVersionPackages(options.bump);
64
+ if (versionPackages.length === 0) {
65
+ if (options.bump !== 'auto') {
66
+ // invariant throw: the CLI resolves forced bumps from the full owned release package set.
67
+ throw new Error(`No release packages were selected for forced --bump ${options.bump}.`);
68
+ }
69
+ return { mode: 'none', packages: [], status: 'no-release-needed' };
70
+ }
71
+
62
72
  const headBeforeVersioning = await shell.gitHead();
63
- await shell.runNxReleaseVersion(options.bump, options.dryRun);
73
+ await shell.runNxReleaseVersion(versionPackages, options.bump, options.dryRun);
64
74
  if (options.dryRun) {
65
75
  return { mode: 'none', packages: [], status: 'dry-run' };
66
76
  }
@@ -26,7 +26,7 @@ export function planPublishActions<Package extends ReleasePackageInfo>(
26
26
  const githubReleasePackages = input.releasePackages.filter((pkg) => githubMissingNames.has(pkg.name));
27
27
 
28
28
  return {
29
- buildProjects: npmPackages.map((pkg) => pkg.name),
29
+ buildProjects: npmPackages.map((pkg) => pkg.projectName),
30
30
  publishPackages: npmPackages.map((pkg) => ({ pkg, distTag: npmDistTagForVersion(pkg.version) })),
31
31
  githubReleasePackages,
32
32
  };