@smoothbricks/cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +29 -24
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +19 -3
  4. package/dist/generate/index.d.ts +20 -0
  5. package/dist/generate/index.d.ts.map +1 -0
  6. package/dist/generate/index.js +47 -0
  7. package/dist/lib/run.d.ts +1 -0
  8. package/dist/lib/run.d.ts.map +1 -1
  9. package/dist/lib/run.js +19 -0
  10. package/dist/lib/workspace.d.ts +10 -0
  11. package/dist/lib/workspace.d.ts.map +1 -1
  12. package/dist/lib/workspace.js +55 -16
  13. package/dist/monorepo/lockfile.d.ts.map +1 -1
  14. package/dist/monorepo/lockfile.js +9 -0
  15. package/dist/monorepo/package-policy.d.ts +6 -17
  16. package/dist/monorepo/package-policy.d.ts.map +1 -1
  17. package/dist/monorepo/package-policy.js +63 -1127
  18. package/dist/monorepo/publish-workflow.js +2 -3
  19. package/dist/nx/index.d.ts +1 -0
  20. package/dist/nx/index.d.ts.map +1 -1
  21. package/dist/nx/index.js +23 -5
  22. package/dist/release/candidates.d.ts +1 -0
  23. package/dist/release/candidates.d.ts.map +1 -1
  24. package/dist/release/candidates.js +12 -0
  25. package/dist/release/core.d.ts +3 -1
  26. package/dist/release/core.d.ts.map +1 -1
  27. package/dist/release/core.js +76 -9
  28. package/dist/release/github-release.d.ts +1 -0
  29. package/dist/release/github-release.d.ts.map +1 -1
  30. package/dist/release/github-release.js +1 -1
  31. package/dist/release/index.d.ts +9 -3
  32. package/dist/release/index.d.ts.map +1 -1
  33. package/dist/release/index.js +287 -39
  34. package/dist/release/orchestration.d.ts +10 -2
  35. package/dist/release/orchestration.d.ts.map +1 -1
  36. package/dist/release/orchestration.js +33 -3
  37. package/managed/raw/tooling/git-hooks/commit-msg.sh +7 -1
  38. package/managed/raw/tooling/git-hooks/pre-commit.sh +7 -1
  39. package/managed/templates/github/workflows/ci.yml +3 -2
  40. package/package.json +7 -12
  41. package/src/cli.ts +21 -4
  42. package/src/generate/index.ts +92 -0
  43. package/src/lib/run.ts +25 -0
  44. package/src/lib/workspace.ts +72 -16
  45. package/src/monorepo/lockfile.ts +9 -0
  46. package/src/monorepo/package-policy.test.ts +254 -26
  47. package/src/monorepo/package-policy.ts +88 -1351
  48. package/src/monorepo/publish-workflow.ts +2 -3
  49. package/src/nx/index.test.ts +9 -0
  50. package/src/nx/index.ts +25 -5
  51. package/src/release/__tests__/candidates.test.ts +109 -8
  52. package/src/release/__tests__/core-properties.test.ts +2 -2
  53. package/src/release/__tests__/core-scenarios.test.ts +12 -15
  54. package/src/release/__tests__/core.test.ts +50 -3
  55. package/src/release/__tests__/fixture-repo.test.ts +68 -18
  56. package/src/release/__tests__/github-release.test.ts +9 -16
  57. package/src/release/__tests__/orchestration.test.ts +91 -5
  58. package/src/release/__tests__/retag-unpublished.test.ts +9 -9
  59. package/src/release/__tests__/trust-publisher.test.ts +65 -33
  60. package/src/release/candidates.ts +13 -0
  61. package/src/release/core.ts +95 -10
  62. package/src/release/github-release.ts +1 -1
  63. package/src/release/index.ts +366 -49
  64. package/src/release/orchestration.ts +50 -5
  65. package/dist/nx-version-actions.cjs +0 -25
  66. package/dist/nx-version-actions.d.cts +0 -6
  67. package/dist/nx-version-actions.d.cts.map +0 -1
  68. package/src/nx-version-actions.cts +0 -36
@@ -18,7 +18,7 @@ const prerelease: ReleasePackageInfo = {
18
18
 
19
19
  describe('GitHub release helpers', () => {
20
20
  it('configures the Nx changelog API for render-only project changelogs', () => {
21
- expect(nxProjectChangelogArgs(stable, '@scope/pkg@1.2.2', false)).toEqual({
21
+ expect(nxProjectChangelogArgs(stable, 'pkg@1.2.2', false)).toEqual({
22
22
  version: '1.2.3',
23
23
  projects: ['pkg'],
24
24
  gitCommit: false,
@@ -28,7 +28,7 @@ describe('GitHub release helpers', () => {
28
28
  createRelease: false,
29
29
  deleteVersionPlans: false,
30
30
  dryRun: false,
31
- from: '@scope/pkg@1.2.2',
31
+ from: 'pkg@1.2.2',
32
32
  });
33
33
  });
34
34
 
@@ -71,18 +71,11 @@ describe('GitHub release helpers', () => {
71
71
 
72
72
  await createOrUpdateGithubRelease(stable, 'stable notes', shell);
73
73
 
74
- expect(shell.existsQueries).toEqual(['@scope/pkg@1.2.3']);
74
+ expect(shell.existsQueries).toEqual(['pkg@1.2.3']);
75
75
  expect(shell.notes).toEqual(['stable notes']);
76
- expect(shell.logs).toEqual(['@scope/pkg@1.2.3: creating GitHub Release for @scope/pkg@1.2.3.']);
76
+ expect(shell.logs).toEqual(['@scope/pkg@1.2.3: creating GitHub Release for pkg@1.2.3.']);
77
77
  const command = onlyCommand(shell.commands);
78
- expect(command.slice(0, 6)).toEqual([
79
- 'release',
80
- 'create',
81
- '@scope/pkg@1.2.3',
82
- '--title',
83
- '@scope/pkg@1.2.3',
84
- '--notes-file',
85
- ]);
78
+ expect(command.slice(0, 6)).toEqual(['release', 'create', 'pkg@1.2.3', '--title', 'pkg@1.2.3', '--notes-file']);
86
79
  expect(command).toContain('--verify-tag');
87
80
  expect(command).toContain('--latest=true');
88
81
  expect(command).not.toContain('--prerelease');
@@ -93,16 +86,16 @@ describe('GitHub release helpers', () => {
93
86
 
94
87
  await createOrUpdateGithubRelease(prerelease, 'prerelease notes', shell);
95
88
 
96
- expect(shell.existsQueries).toEqual(['@scope/pkg@2.0.0-beta.1']);
89
+ expect(shell.existsQueries).toEqual(['pkg@2.0.0-beta.1']);
97
90
  expect(shell.notes).toEqual(['prerelease notes']);
98
- expect(shell.logs).toEqual(['@scope/pkg@2.0.0-beta.1: updating GitHub Release for @scope/pkg@2.0.0-beta.1.']);
91
+ expect(shell.logs).toEqual(['@scope/pkg@2.0.0-beta.1: updating GitHub Release for pkg@2.0.0-beta.1.']);
99
92
  const command = onlyCommand(shell.commands);
100
93
  expect(command.slice(0, 6)).toEqual([
101
94
  'release',
102
95
  'edit',
103
- '@scope/pkg@2.0.0-beta.1',
96
+ 'pkg@2.0.0-beta.1',
104
97
  '--title',
105
- '@scope/pkg@2.0.0-beta.1',
98
+ 'pkg@2.0.0-beta.1',
106
99
  '--notes-file',
107
100
  ]);
108
101
  expect(command).toContain('--verify-tag');
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
- import type { ReleasePackageInfo, ReleaseTarget } from '../core.js';
2
+ import { type ReleasePackageInfo, type ReleaseTarget, releaseTag } from '../core.js';
3
3
  import {
4
+ bumpStableReleaseToNext,
4
5
  completeReleaseAtHead,
5
6
  type ReleaseRepairShell,
6
7
  type ReleaseVersionShell,
@@ -33,7 +34,7 @@ describe('release orchestration', () => {
33
34
  expect(shell.builds).toEqual([['@scope/stable']]);
34
35
  expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
35
36
  expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
36
- expect(shell.pushes).toEqual([['@scope/stable', '@scope/prerelease']]);
37
+ expect(shell.pushes).toEqual([['stable@1.0.0', 'prerelease@2.0.0-beta.1']]);
37
38
  expect(summaries).toHaveLength(1);
38
39
  expect(summaries[0]?.published.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
39
40
  expect(summaries[0]?.githubReleases.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
@@ -50,6 +51,33 @@ describe('release orchestration', () => {
50
51
  expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
51
52
  });
52
53
 
54
+ it('dry-run repair reports planned targets without checkout, devenv, build, publish, or GitHub calls', async () => {
55
+ const target = releaseTarget('github-only', [prerelease], [], [prerelease]);
56
+ const shell = new RecordingRepairShell();
57
+
58
+ const summaries = await repairPendingTargets(shell, [target], 'restore-ref', true);
59
+
60
+ expect(shell.checkouts).toEqual([]);
61
+ expect(shell.devenvLoads).toBe(0);
62
+ expect(shell.builds).toEqual([]);
63
+ expect(shell.publishes).toEqual([]);
64
+ expect(shell.githubCreates).toEqual([]);
65
+ expect(summaries).toEqual([
66
+ {
67
+ sha: 'github-only',
68
+ dryRun: true,
69
+ packages: [prerelease],
70
+ pushed: false,
71
+ published: [],
72
+ alreadyPublished: [prerelease],
73
+ githubReleases: [],
74
+ githubReleaseLinks: [],
75
+ rerunRequired: false,
76
+ noRelease: false,
77
+ },
78
+ ]);
79
+ });
80
+
53
81
  it('publishes a partial HEAD release by building npm-missing packages and creating missing GitHub Releases', async () => {
54
82
  const shell = new RecordingRepairShell({ npmMissing: ['@scope/stable'], githubMissing: ['@scope/prerelease'] });
55
83
 
@@ -60,9 +88,13 @@ describe('release orchestration', () => {
60
88
  expect(shell.builds).toEqual([['@scope/stable']]);
61
89
  expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
62
90
  expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
91
+ expect(shell.pushes).toEqual([['stable@1.0.0', 'prerelease@2.0.0-beta.1']]);
63
92
  expect(summary.published.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
64
93
  expect(summary.alreadyPublished.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
65
94
  expect(summary.githubReleases.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
95
+ expect(summary.githubReleaseLinks).toEqual([
96
+ { pkg: prerelease, url: 'https://github.test/prerelease@2.0.0-beta.1' },
97
+ ]);
66
98
  expect(summary.rerunRequired).toBe(true);
67
99
  });
68
100
 
@@ -77,6 +109,7 @@ describe('release orchestration', () => {
77
109
  expect(summary.published).toEqual([]);
78
110
  expect(summary.alreadyPublished.map((pkg) => pkg.name)).toEqual(['@scope/stable', '@scope/prerelease']);
79
111
  expect(summary.githubReleases).toEqual([]);
112
+ expect(summary.githubReleaseLinks).toEqual([]);
80
113
  });
81
114
 
82
115
  it('does not run Nx versioning when HEAD is already a release target', async () => {
@@ -127,6 +160,42 @@ describe('release orchestration', () => {
127
160
  expect(result).toEqual({ mode: 'new', packages: [stable], status: 'new-release' });
128
161
  expect(shell.nxRuns).toEqual([{ packages: ['@scope/stable'], bump: 'auto', dryRun: false }]);
129
162
  });
163
+
164
+ it('reports dry-run version packages resolved by Nx without creating a release mode', async () => {
165
+ const preview = { ...stable, version: '1.1.0' };
166
+ const shell = new RecordingVersionShell({ releasePackagesAtHead: [[]], releaseVersionPackages: [stable], preview });
167
+
168
+ const result = await runReleaseVersion(shell, { bump: 'auto', dryRun: true });
169
+
170
+ expect(result).toEqual({ mode: 'none', packages: [preview], status: 'dry-run' });
171
+ expect(shell.nxRuns).toEqual([{ packages: ['@scope/stable'], bump: 'auto', dryRun: true }]);
172
+ });
173
+
174
+ it('bumps stable release packages to next after publish completion', async () => {
175
+ const shell = new RecordingNextShell();
176
+
177
+ const bumped = await bumpStableReleaseToNext(shell, [stable, prerelease], false, false);
178
+
179
+ expect(bumped.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
180
+ expect(shell.bumped).toEqual([['@scope/stable']]);
181
+ });
182
+
183
+ it('does not bump prerelease-only or dry-run publishes to next', async () => {
184
+ const shell = new RecordingNextShell();
185
+
186
+ await expect(bumpStableReleaseToNext(shell, [prerelease], false, false)).resolves.toEqual([]);
187
+ await expect(bumpStableReleaseToNext(shell, [stable], true, false)).resolves.toEqual([]);
188
+
189
+ expect(shell.bumped).toEqual([]);
190
+ });
191
+
192
+ it('does not bump stable packages to next when newer branch commits remain', async () => {
193
+ const shell = new RecordingNextShell();
194
+
195
+ await expect(bumpStableReleaseToNext(shell, [stable], false, true)).resolves.toEqual([]);
196
+
197
+ expect(shell.bumped).toEqual([]);
198
+ });
130
199
  });
131
200
 
132
201
  function releaseTarget(
@@ -161,7 +230,7 @@ class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
161
230
  }
162
231
 
163
232
  async pushReleaseRefs(packages: ReleasePackageInfo[]): Promise<boolean> {
164
- this.pushes.push(packageNames(packages));
233
+ this.pushes.push(packages.map((pkg) => releaseTag(pkg)));
165
234
  return true;
166
235
  }
167
236
 
@@ -183,8 +252,9 @@ class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
183
252
  return packages.filter((pkg) => this.githubMissing.has(pkg.name));
184
253
  }
185
254
 
186
- async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<void> {
255
+ async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<string | null> {
187
256
  this.githubCreates.push({ name: pkg.name, dryRun });
257
+ return dryRun ? null : `https://github.test/${releaseTag(pkg)}`;
188
258
  }
189
259
 
190
260
  async checkout(ref: string): Promise<void> {
@@ -205,15 +275,18 @@ class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
205
275
  private readonly releaseBatches: ReleasePackageInfo[][];
206
276
  private readonly versionPackages: ReleasePackageInfo[];
207
277
  private readonly heads: string[];
278
+ private readonly preview: ReleasePackageInfo[];
208
279
 
209
280
  constructor(options: {
210
281
  releasePackagesAtHead: ReleasePackageInfo[][];
211
282
  releaseVersionPackages?: ReleasePackageInfo[];
212
283
  heads?: string[];
284
+ preview?: ReleasePackageInfo | ReleasePackageInfo[];
213
285
  }) {
214
286
  this.releaseBatches = [...options.releasePackagesAtHead];
215
287
  this.versionPackages = options.releaseVersionPackages ?? [stable, prerelease];
216
288
  this.heads = [...(options.heads ?? [])];
289
+ this.preview = Array.isArray(options.preview) ? options.preview : options.preview ? [options.preview] : [];
217
290
  }
218
291
 
219
292
  async releasePackagesAtHead(): Promise<ReleasePackageInfo[]> {
@@ -232,8 +305,13 @@ class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
232
305
  return this.heads.shift() ?? 'head';
233
306
  }
234
307
 
235
- async runNxReleaseVersion(packages: ReleasePackageInfo[], bump: string, dryRun: boolean): Promise<void> {
308
+ async runNxReleaseVersion(
309
+ packages: ReleasePackageInfo[],
310
+ bump: string,
311
+ dryRun: boolean,
312
+ ): Promise<ReleasePackageInfo[]> {
236
313
  this.nxRuns.push({ packages: packageNames(packages), bump, dryRun });
314
+ return this.preview;
237
315
  }
238
316
 
239
317
  async assertCleanGitTree(): Promise<void> {
@@ -241,6 +319,14 @@ class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
241
319
  }
242
320
  }
243
321
 
322
+ class RecordingNextShell {
323
+ readonly bumped: string[][] = [];
324
+
325
+ async bumpStablePackagesToNext(packages: ReleasePackageInfo[]): Promise<void> {
326
+ this.bumped.push(packageNames(packages));
327
+ }
328
+ }
329
+
244
330
  function packageNames(packages: ReleasePackageInfo[]): string[] {
245
331
  return packages.map((pkg) => pkg.name);
246
332
  }
@@ -13,25 +13,25 @@ describe('retag unpublished releases', () => {
13
13
  it('moves unpublished owned tags, pushes with a remote lease, and dispatches publish auto', async () => {
14
14
  const shell = new RecordingRetagShell({
15
15
  versionAtRef: '1.2.3',
16
- remoteTagObjects: new Map([['@scope/pkg@1.2.3', 'old-tag-object']]),
16
+ remoteTagObjects: new Map([['pkg@1.2.3', 'old-tag-object']]),
17
17
  dispatchSha: 'target-sha',
18
18
  });
19
19
 
20
20
  const updates = await retagUnpublished(shell, retagOptions({ push: true, dispatch: true }));
21
21
 
22
- expect(updates).toEqual([{ tag: '@scope/pkg@1.2.3', pkg, expectedRemoteObject: 'old-tag-object' }]);
23
- expect(shell.movedTags).toEqual([{ tag: '@scope/pkg@1.2.3', ref: 'HEAD' }]);
24
- expect(shell.pushed).toEqual([[{ tag: '@scope/pkg@1.2.3', expectedRemoteObject: 'old-tag-object' }]]);
22
+ expect(updates).toEqual([{ tag: 'pkg@1.2.3', pkg, expectedRemoteObject: 'old-tag-object' }]);
23
+ expect(shell.movedTags).toEqual([{ tag: 'pkg@1.2.3', ref: 'HEAD' }]);
24
+ expect(shell.pushed).toEqual([[{ tag: 'pkg@1.2.3', expectedRemoteObject: 'old-tag-object' }]]);
25
25
  expect(shell.dispatched).toEqual([{ workflow: 'publish.yml', branch: 'main' }]);
26
26
  expect(shell.dispatchRefLookups).toEqual(['main']);
27
- expect(shell.remoteTagLookups).toEqual(['@scope/pkg@1.2.3']);
27
+ expect(shell.remoteTagLookups).toEqual(['pkg@1.2.3']);
28
28
  });
29
29
 
30
30
  it('rejects tags whose package version already exists on npm', async () => {
31
31
  const shell = new RecordingRetagShell({ versionAtRef: '1.2.3', npmPublished: true });
32
32
 
33
33
  await expect(retagUnpublished(shell, retagOptions())).rejects.toThrow(
34
- 'Cannot retag @scope/pkg@1.2.3: @scope/pkg@1.2.3 already exists on npm.',
34
+ 'Cannot retag pkg@1.2.3: @scope/pkg@1.2.3 already exists on npm.',
35
35
  );
36
36
 
37
37
  expect(shell.movedTags).toEqual([]);
@@ -42,7 +42,7 @@ describe('retag unpublished releases', () => {
42
42
  const shell = new RecordingRetagShell({ versionAtRef: '1.2.4' });
43
43
 
44
44
  await expect(retagUnpublished(shell, retagOptions())).rejects.toThrow(
45
- 'Release tag @scope/pkg@1.2.3 cannot move to HEAD: packages/pkg/package.json has version 1.2.4, expected 1.2.3.',
45
+ 'Release tag pkg@1.2.3 cannot move to HEAD: packages/pkg/package.json has version 1.2.4, expected 1.2.3.',
46
46
  );
47
47
  });
48
48
 
@@ -66,7 +66,7 @@ describe('retag unpublished releases', () => {
66
66
  expect(shell.movedTags).toEqual([]);
67
67
  expect(shell.pushed).toEqual([]);
68
68
  expect(shell.dispatched).toEqual([]);
69
- expect(shell.logs).toContain('Would move @scope/pkg@1.2.3 to HEAD (target-sha).');
69
+ expect(shell.logs).toContain('Would move pkg@1.2.3 to HEAD (target-sha).');
70
70
  expect(shell.logs).toContain('Would push 1 retagged release tag.');
71
71
  expect(shell.logs).toContain('Would dispatch publish.yml on main with bump=auto.');
72
72
  });
@@ -74,7 +74,7 @@ describe('retag unpublished releases', () => {
74
74
 
75
75
  function retagOptions(overrides: Partial<RetagUnpublishedOptions> = {}): RetagUnpublishedOptions {
76
76
  return {
77
- tags: ['@scope/pkg@1.2.3'],
77
+ tags: ['pkg@1.2.3'],
78
78
  toRef: 'HEAD',
79
79
  push: false,
80
80
  dispatch: false,
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
  import type { BootstrapNpmPackagesOptions } from '../bootstrap-npm-packages.js';
3
3
  import type { ReleasePackageInfo } from '../core.js';
4
- import { configureTrustedPublishers, type TrustPublisherShell } from '../index.js';
4
+ import {
5
+ configureTrustedPublishers,
6
+ parseTrustedPublishers,
7
+ type TrustedPublisher,
8
+ type TrustPublisherShell,
9
+ } from '../index.js';
5
10
 
6
11
  const stable: ReleasePackageInfo = {
7
12
  name: '@scope/stable',
@@ -20,52 +25,86 @@ describe('trusted publisher setup', () => {
20
25
  it('bootstraps missing npm packages before configuring trust', async () => {
21
26
  const shell = new RecordingTrustPublisherShell({ packages: [stable, missing], existing: [stable.name] });
22
27
 
23
- await configureTrustedPublishers(shell, { bootstrap: true, skipLogin: false, otp: '123456' });
28
+ await configureTrustedPublishers(shell, { bootstrap: true, skipLogin: false });
24
29
 
25
30
  expect(shell.events).toEqual([
26
31
  'bootstrap:false:false',
27
32
  `exists:${stable.name}`,
28
33
  `exists:${missing.name}`,
29
- `trust:${stable.name}:false:123456`,
30
- `trust:${missing.name}:false:123456`,
34
+ `list:${stable.name}`,
35
+ `trust:${stable.name}:false`,
36
+ `list:${missing.name}`,
37
+ `trust:${missing.name}:false`,
31
38
  ]);
32
- expect(shell.logins).toBe(0);
33
39
  });
34
40
 
35
41
  it('directs missing packages to trust-publisher --bootstrap', async () => {
36
42
  const shell = new RecordingTrustPublisherShell({ packages: [missing], existing: [] });
37
43
 
38
- await expect(configureTrustedPublishers(shell, { otp: '123456' })).rejects.toThrow(
44
+ await expect(configureTrustedPublishers(shell, {})).rejects.toThrow(
39
45
  'Run smoo release trust-publisher --bootstrap locally',
40
46
  );
41
47
  expect(shell.events).toEqual([`exists:${missing.name}`]);
42
48
  });
43
49
 
44
- it('still logs in for trust when bootstrap finds no missing packages', async () => {
50
+ it('does not pre-login before trust operations', async () => {
45
51
  const shell = new RecordingTrustPublisherShell({ packages: [stable], existing: [stable.name] });
46
52
 
47
- await configureTrustedPublishers(shell, { bootstrap: true, otp: '123456' });
53
+ await configureTrustedPublishers(shell, { bootstrap: true });
48
54
 
49
55
  expect(shell.events).toEqual([
50
56
  'bootstrap:false:false',
51
57
  `exists:${stable.name}`,
52
- `trust:${stable.name}:false:123456`,
58
+ `list:${stable.name}`,
59
+ `trust:${stable.name}:false`,
53
60
  ]);
54
- expect(shell.logins).toBe(1);
55
61
  });
56
62
 
57
- it('treats npm trust conflicts as already configured', async () => {
63
+ it('skips packages with matching trusted publishers', async () => {
58
64
  const shell = new RecordingTrustPublisherShell({
59
65
  packages: [stable],
60
66
  existing: [stable.name],
61
- alreadyTrusted: [stable.name],
67
+ trustedPublishers: {
68
+ [stable.name]: [{ id: 'trusted-1', type: 'github', file: 'publish.yml', repository: 'scope/repo' }],
69
+ },
62
70
  });
63
71
 
64
- await configureTrustedPublishers(shell, { skipLogin: true, otp: '123456' });
72
+ await configureTrustedPublishers(shell, { skipLogin: true });
65
73
 
66
- expect(shell.events).toEqual([`exists:${stable.name}`, `trust:${stable.name}:false:123456`]);
74
+ expect(shell.events).toEqual([`exists:${stable.name}`, `list:${stable.name}`]);
67
75
  expect(shell.logs).toContain(`${stable.name}: npm trusted publisher is already configured; skipping.`);
68
76
  });
77
+
78
+ it('rejects mismatched trusted publishers', async () => {
79
+ const shell = new RecordingTrustPublisherShell({
80
+ packages: [stable],
81
+ existing: [stable.name],
82
+ trustedPublishers: {
83
+ [stable.name]: [{ id: 'trusted-1', type: 'github', file: 'other.yml', repository: 'scope/repo' }],
84
+ },
85
+ });
86
+
87
+ await expect(configureTrustedPublishers(shell, { skipLogin: true })).rejects.toThrow(
88
+ 'npm trusted publisher exists but does not match',
89
+ );
90
+ expect(shell.events).toEqual([`exists:${stable.name}`, `list:${stable.name}`]);
91
+ });
92
+
93
+ it('configures only selected packages', async () => {
94
+ const shell = new RecordingTrustPublisherShell({
95
+ packages: [stable, missing],
96
+ existing: [stable.name, missing.name],
97
+ });
98
+
99
+ await configureTrustedPublishers(shell, { skipLogin: true, packages: [missing.name] });
100
+
101
+ expect(shell.events).toEqual([`exists:${missing.name}`, `list:${missing.name}`, `trust:${missing.name}:false`]);
102
+ });
103
+
104
+ it('parses empty npm trust list output as no trusted publishers', () => {
105
+ expect(parseTrustedPublishers('', stable.name)).toEqual([]);
106
+ expect(parseTrustedPublishers('null', stable.name)).toEqual([]);
107
+ });
69
108
  });
70
109
 
71
110
  class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackageInfo> {
@@ -74,15 +113,18 @@ class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackage
74
113
  readonly events: string[] = [];
75
114
  readonly logs: string[] = [];
76
115
  readonly errors: string[] = [];
77
- logins = 0;
78
116
  private readonly packages: ReleasePackageInfo[];
79
117
  private readonly existing: Set<string>;
80
- private readonly alreadyTrusted: Set<string>;
118
+ private readonly trustedPublisherByPackage: Record<string, TrustedPublisher[]>;
81
119
 
82
- constructor(options: { packages: ReleasePackageInfo[]; existing: string[]; alreadyTrusted?: string[] }) {
120
+ constructor(options: {
121
+ packages: ReleasePackageInfo[];
122
+ existing: string[];
123
+ trustedPublishers?: Record<string, TrustedPublisher[]>;
124
+ }) {
83
125
  this.packages = options.packages;
84
126
  this.existing = new Set(options.existing);
85
- this.alreadyTrusted = new Set(options.alreadyTrusted ?? []);
127
+ this.trustedPublisherByPackage = options.trustedPublishers ?? {};
86
128
  }
87
129
 
88
130
  listReleasePackages(): ReleasePackageInfo[] {
@@ -103,24 +145,14 @@ class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackage
103
145
  return missingPackages;
104
146
  }
105
147
 
106
- async login(): Promise<void> {
107
- this.logins += 1;
108
- }
109
-
110
- async trustPublisher(
111
- pkg: ReleasePackageInfo,
112
- dryRun: boolean,
113
- env?: Record<string, string>,
114
- ): Promise<'configured' | 'already-configured'> {
115
- this.events.push(`trust:${pkg.name}:${dryRun}:${env?.NPM_CONFIG_OTP ?? ''}`);
116
- if (this.alreadyTrusted.has(pkg.name)) {
117
- return 'already-configured';
118
- }
148
+ async trustPublisher(pkg: ReleasePackageInfo, dryRun: boolean): Promise<'configured' | 'already-configured'> {
149
+ this.events.push(`trust:${pkg.name}:${dryRun}`);
119
150
  return 'configured';
120
151
  }
121
152
 
122
- async promptOtp(packageName: string): Promise<string> {
123
- throw new Error(`unexpected OTP prompt for ${packageName}`);
153
+ async trustedPublishers(pkg: ReleasePackageInfo): Promise<TrustedPublisher[]> {
154
+ this.events.push(`list:${pkg.name}`);
155
+ return this.trustedPublisherByPackage[pkg.name] ?? [];
124
156
  }
125
157
 
126
158
  log(message: string): void {
@@ -4,6 +4,7 @@ import { releaseTag } from './core.js';
4
4
 
5
5
  export interface AutoReleaseCandidateShell {
6
6
  gitRefExists(ref: string): Promise<boolean>;
7
+ latestStableReleaseRef(projectName: string): Promise<string | null>;
7
8
  packageChangedFilesSince(ref: string, packagePath: string): Promise<string[]>;
8
9
  packageJsonAtRef(ref: string, packagePath: string): Promise<Record<string, unknown> | null>;
9
10
  currentPackageJson(packagePath: string): Promise<Record<string, unknown> | null>;
@@ -62,6 +63,18 @@ async function isAutoReleaseCandidate<Package extends ReleasePackageInfo>(
62
63
  if (await shell.gitRefExists(tagRef)) {
63
64
  return packageHasReleasableChangesSince(shell, tagRef, pkg);
64
65
  }
66
+ if (pkg.version.includes('-')) {
67
+ // Prerelease version (e.g. 1.0.1-next.0) without its own tag: check for
68
+ // releasable changes since the last stable release. The prerelease bump is
69
+ // added after every release to make it obvious during development that the
70
+ // working version is unreleased — it should not suppress the next release.
71
+ const stableRef = await shell.latestStableReleaseRef(pkg.projectName);
72
+ if (stableRef) {
73
+ return packageHasReleasableChangesSince(shell, stableRef, pkg);
74
+ }
75
+ // No prior stable release — first release must be intentional, not auto.
76
+ return false;
77
+ }
65
78
  return shell.packageHasHistory(pkg.path);
66
79
  }
67
80
 
@@ -44,10 +44,18 @@ export interface ReleasePlanningShell {
44
44
  durableTagState(pkg: ReleasePackageInfo, tag: string): Promise<DurableTagState>;
45
45
  }
46
46
 
47
- export function releaseTag(pkg: Pick<ReleasePackageInfo, 'name' | 'version'>): string {
47
+ export function releaseTag(pkg: Pick<ReleasePackageInfo, 'projectName' | 'version'>): string {
48
+ return `${pkg.projectName}@${pkg.version}`;
49
+ }
50
+
51
+ export function legacyReleaseTag(pkg: Pick<ReleasePackageInfo, 'name' | 'version'>): string {
48
52
  return `${pkg.name}@${pkg.version}`;
49
53
  }
50
54
 
55
+ export function releaseTagAliases(pkg: Pick<ReleasePackageInfo, 'name' | 'projectName' | 'version'>): string[] {
56
+ return [...new Set([releaseTag(pkg), legacyReleaseTag(pkg)])];
57
+ }
58
+
51
59
  export function npmDistTagForVersion(version: string): string {
52
60
  return version.includes('-') ? 'next' : 'latest';
53
61
  }
@@ -57,7 +65,7 @@ export function releasePackageForTag<Package extends Omit<ReleasePackageInfo, 'v
57
65
  tag: string,
58
66
  ): { pkg: Package; version: string } | null {
59
67
  for (const pkg of packages) {
60
- const prefix = `${pkg.name}@`;
68
+ const prefix = `${pkg.projectName}@`;
61
69
  if (tag.startsWith(prefix)) {
62
70
  const version = tag.slice(prefix.length);
63
71
  if (!version) {
@@ -85,13 +93,14 @@ export async function collectOwnedReleaseTagRecords<Package extends Omit<Release
85
93
  ref: string,
86
94
  shell: ReleasePlanningShell,
87
95
  ): Promise<Array<ReleaseTagRecord<Package & { version: string }>>> {
88
- const records: Array<ReleaseTagRecord<Package & { version: string }>> = [];
89
- const completedPackages = new Set<string>();
96
+ const candidates: Array<{
97
+ tag: GitReleaseTagInfo;
98
+ pkg: Package & { version: string };
99
+ }> = [];
100
+ const durableStates = new BoundedPromiseRunner(8);
101
+
90
102
  for (const tag of await shell.listReleaseTagsByCreatorDate()) {
91
103
  const match = releasePackageForTag(packages, tag.name);
92
- if (match && completedPackages.has(match.pkg.name)) {
93
- continue;
94
- }
95
104
  if (!match || !(await shell.isAncestor(tag.sha, ref))) {
96
105
  continue;
97
106
  }
@@ -104,15 +113,91 @@ export async function collectOwnedReleaseTagRecords<Package extends Omit<Release
104
113
  );
105
114
  }
106
115
  const pkg = { ...match.pkg, version: match.version };
107
- const state = await shell.durableTagState(pkg, tag.name);
108
- records.push(classifyReleaseTag({ tag: tag.name, sha: tag.sha, timestamp: tag.timestamp, pkg }, state));
116
+ candidates.push({ tag, pkg });
117
+ }
118
+
119
+ const records: Array<ReleaseTagRecord<Package & { version: string }>> = [];
120
+ const completedPackages = new Set<string>();
121
+ const activePackages = new Set<string>();
122
+ const states = new Map<number, Promise<DurableTagState>>();
123
+
124
+ // Run durable-state checks concurrently across packages, but do not query an
125
+ // older tag for the same package until its newer tag proves incomplete.
126
+ const scheduleFrom = (startIndex: number) => {
127
+ for (let index = startIndex; index < candidates.length; index += 1) {
128
+ if (states.size >= 8) {
129
+ return;
130
+ }
131
+ const candidate = candidates[index];
132
+ if (!candidate || states.has(index) || completedPackages.has(candidate.pkg.name)) {
133
+ continue;
134
+ }
135
+ if (activePackages.has(candidate.pkg.name)) {
136
+ continue;
137
+ }
138
+ activePackages.add(candidate.pkg.name);
139
+ states.set(
140
+ index,
141
+ durableStates.run(() => shell.durableTagState(candidate.pkg, candidate.tag.name)),
142
+ );
143
+ }
144
+ };
145
+
146
+ for (const candidate of candidates) {
147
+ const index = candidates.indexOf(candidate);
148
+ scheduleFrom(index);
149
+ if (completedPackages.has(candidate.pkg.name)) {
150
+ continue;
151
+ }
152
+ const state = await states.get(index);
153
+ if (!state) {
154
+ throw new Error(`Release tag ${candidate.tag.name} lost durable state planning.`);
155
+ }
156
+ activePackages.delete(candidate.pkg.name);
157
+ states.delete(index);
158
+ records.push(
159
+ classifyReleaseTag(
160
+ { tag: candidate.tag.name, sha: candidate.tag.sha, timestamp: candidate.tag.timestamp, pkg: candidate.pkg },
161
+ state,
162
+ ),
163
+ );
109
164
  if (state.npmPublished && state.githubReleaseExists) {
110
- completedPackages.add(pkg.name);
165
+ completedPackages.add(candidate.pkg.name);
111
166
  }
112
167
  }
113
168
  return records;
114
169
  }
115
170
 
171
+ class BoundedPromiseRunner {
172
+ private active = 0;
173
+ private readonly waiting: Array<() => void> = [];
174
+
175
+ constructor(private readonly limit: number) {}
176
+
177
+ async run<T>(operation: () => Promise<T>): Promise<T> {
178
+ await this.acquire();
179
+ try {
180
+ return await operation();
181
+ } finally {
182
+ this.release();
183
+ }
184
+ }
185
+
186
+ private async acquire(): Promise<void> {
187
+ if (this.active < this.limit) {
188
+ this.active += 1;
189
+ return;
190
+ }
191
+ await new Promise<void>((resolve) => this.waiting.push(resolve));
192
+ this.active += 1;
193
+ }
194
+
195
+ private release(): void {
196
+ this.active -= 1;
197
+ this.waiting.shift()?.();
198
+ }
199
+ }
200
+
116
201
  export function groupReleaseTargets<Package extends ReleasePackageInfo>(
117
202
  records: ReleaseTagRecord<Package>[],
118
203
  ): ReleaseTarget<Package>[] {
@@ -112,7 +112,7 @@ function isPrereleaseVersion(version: string): boolean {
112
112
  return version.includes('-');
113
113
  }
114
114
 
115
- async function withNxWorkspaceRoot<T>(root: string, run: () => Promise<T>): Promise<T> {
115
+ export async function withNxWorkspaceRoot<T>(root: string, run: () => Promise<T>): Promise<T> {
116
116
  const workspaceRootModule = await import('nx/src/utils/workspace-root.js');
117
117
  const previousWorkspaceRoot = workspaceRootModule.workspaceRoot;
118
118
  const previousEnvWorkspaceRoot = process.env.NX_WORKSPACE_ROOT_PATH;