@smoothbricks/cli 0.10.2 → 0.10.3

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 (62) hide show
  1. package/README.md +25 -13
  2. package/dist/cli.js +24 -0
  3. package/dist/github-ci/index.d.ts +17 -2
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +141 -11
  6. package/dist/github-ci/outputs.d.ts +22 -0
  7. package/dist/github-ci/outputs.d.ts.map +1 -0
  8. package/dist/github-ci/outputs.js +649 -0
  9. package/dist/monorepo/ci-workflow.js +1 -1
  10. package/dist/monorepo/managed-files.d.ts +1 -0
  11. package/dist/monorepo/managed-files.d.ts.map +1 -1
  12. package/dist/monorepo/managed-files.js +38 -0
  13. package/dist/monorepo/package-policy.d.ts.map +1 -1
  14. package/dist/monorepo/package-policy.js +26 -0
  15. package/dist/monorepo/packs/index.d.ts +3 -0
  16. package/dist/monorepo/packs/index.d.ts.map +1 -1
  17. package/dist/monorepo/packs/index.js +5 -2
  18. package/dist/monorepo/publish-workflow.d.ts +1 -0
  19. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  20. package/dist/monorepo/publish-workflow.js +239 -2
  21. package/dist/nx/index.d.ts +6 -0
  22. package/dist/nx/index.d.ts.map +1 -1
  23. package/dist/nx/index.js +55 -5
  24. package/dist/release/github-release.d.ts +3 -0
  25. package/dist/release/github-release.d.ts.map +1 -1
  26. package/dist/release/github-release.js +11 -0
  27. package/dist/release/index.d.ts +19 -1
  28. package/dist/release/index.d.ts.map +1 -1
  29. package/dist/release/index.js +140 -31
  30. package/dist/release/orchestration.d.ts +8 -1
  31. package/dist/release/orchestration.d.ts.map +1 -1
  32. package/dist/release/orchestration.js +38 -1
  33. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +5 -0
  34. package/managed/raw/tooling/direnv/setup-environment.ts +40 -3
  35. package/managed/templates/github/actions/cache-nix-devenv/action.yml +12 -10
  36. package/managed/templates/github/actions/cache-node-modules/action.yml +3 -2
  37. package/managed/templates/github/actions/cache-nx/action.yml +2 -2
  38. package/managed/templates/github/actions/save-nix-devenv/action.yml +11 -3
  39. package/package.json +7 -4
  40. package/src/cli.ts +30 -4
  41. package/src/github-ci/index.test.ts +486 -0
  42. package/src/github-ci/index.ts +172 -13
  43. package/src/github-ci/outputs.ts +506 -0
  44. package/src/monorepo/__tests__/ci-workflow.test.ts +11 -0
  45. package/src/monorepo/__tests__/publish-workflow.test.ts +222 -1
  46. package/src/monorepo/ci-workflow.ts +1 -1
  47. package/src/monorepo/managed-files.test.ts +62 -0
  48. package/src/monorepo/managed-files.ts +41 -0
  49. package/src/monorepo/package-policy.test.ts +50 -1
  50. package/src/monorepo/package-policy.ts +28 -0
  51. package/src/monorepo/packs/index.test.ts +18 -1
  52. package/src/monorepo/packs/index.ts +7 -3
  53. package/src/monorepo/publish-workflow.ts +479 -2
  54. package/src/nx/index.test.ts +39 -0
  55. package/src/nx/index.ts +65 -5
  56. package/src/release/__tests__/fixture-repo.test.ts +18 -16
  57. package/src/release/__tests__/github-release.test.ts +11 -0
  58. package/src/release/__tests__/orchestration.test.ts +57 -2
  59. package/src/release/__tests__/trust-publisher.test.ts +91 -2
  60. package/src/release/github-release.ts +12 -0
  61. package/src/release/index.ts +218 -40
  62. package/src/release/orchestration.ts +55 -3
@@ -175,7 +175,9 @@ describe('release planning with fixture git repositories', () => {
175
175
  const summaries = await repairPendingTargets(shell, pending, restoreRef, false);
176
176
 
177
177
  expect(shell.checkouts).toEqual([githubOnlySha, npmAndGithubSha, restoreRef]);
178
- expect(shell.devenvLoads).toBe(2);
178
+ expect(shell.devenvLoads).toBe(3);
179
+ expect(shell.devenvRefs).toEqual([githubOnlySha, npmAndGithubSha, headSha]);
180
+ await expect(readFile(join(runner, '.generated-tool-ref'), 'utf8')).resolves.toBe(`${headSha}\n`);
179
181
  expect(shell.builds).toEqual([['@scope/b']]);
180
182
  expect(shell.publishes).toEqual([{ name: '@scope/b', version: '2.0.0-beta.1', distTag: 'next', dryRun: false }]);
181
183
  expect(shell.githubCreates).toEqual([
@@ -189,40 +191,36 @@ describe('release planning with fixture git repositories', () => {
189
191
  });
190
192
  });
191
193
 
192
- it('pushes current release refs to a local bare remote and another clone can fetch them', async () => {
194
+ it('pushes current release refs to a local bare remote', async () => {
193
195
  await withFixtureRepo(async (author) => {
194
196
  await writeWorkspace(author);
195
197
  await writeBuildablePackage(author, '@scope/pushed', 'packages/pushed', '1.0.0');
196
198
  await git(author, ['add', '.']);
197
199
  await git(author, ['commit', '-m', 'release pushed package']);
200
+ const releaseSha = await gitOutput(author, ['rev-parse', 'HEAD']);
198
201
  await git(author, ['init', '--bare', 'remote.git']);
199
202
  await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
200
203
  await git(author, ['push', 'origin', 'main']);
201
- await git(author, ['clone', '--branch', 'main', join(author, 'remote.git'), 'runner']);
202
- const runner = join(author, 'runner');
203
- await git(runner, ['config', 'user.name', 'Test User']);
204
- await git(runner, ['config', 'user.email', 'test@example.com']);
205
204
  const pkg: ReleasePackageInfo = {
206
205
  name: '@scope/pushed',
207
206
  projectName: 'pushed',
208
207
  path: 'packages/pushed',
209
208
  version: '1.0.0',
210
209
  };
211
- const shell = new LocalGitRepairShell(runner);
210
+ const shell = new LocalGitRepairShell(author);
212
211
 
213
212
  const summary = await completeReleaseAtHead(shell, [pkg], false, false);
214
213
 
215
214
  expect(summary.pushed).toBe(true);
216
215
  expect(shell.pushes).toEqual([['pushed@1.0.0']]);
217
- await git(author, ['clone', '--branch', 'main', join(author, 'remote.git'), 'auditor']);
218
- const auditor = join(author, 'auditor');
219
- await git(auditor, ['fetch', '--tags', 'origin', 'main']);
220
- await expect(gitOutput(auditor, ['rev-parse', 'refs/tags/pushed@1.0.0^{}'])).resolves.toBe(
221
- await gitOutput(runner, ['rev-parse', 'HEAD']),
222
- );
223
- await expect(gitSucceeds(auditor, ['rev-parse', '--verify', 'refs/tags/@scope/pushed@1.0.0'])).resolves.toBe(
224
- false,
225
- );
216
+ const remoteTags = await gitOutput(author, [
217
+ 'ls-remote',
218
+ '--tags',
219
+ 'origin',
220
+ 'refs/tags/pushed@1.0.0^{}',
221
+ 'refs/tags/@scope/pushed@1.0.0^{}',
222
+ ]);
223
+ expect(remoteTags).toBe(`${releaseSha}\trefs/tags/pushed@1.0.0^{}`);
226
224
  });
227
225
  });
228
226
 
@@ -295,6 +293,7 @@ class LocalGitRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
295
293
  readonly publishes: Array<{ name: string; version: string; distTag: string; dryRun: boolean }> = [];
296
294
  readonly githubCreates: Array<{ name: string; version: string; dryRun: boolean }> = [];
297
295
  devenvLoads = 0;
296
+ readonly devenvRefs: string[] = [];
298
297
 
299
298
  constructor(private readonly root: string) {}
300
299
 
@@ -360,6 +359,9 @@ class LocalGitRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
360
359
 
361
360
  async withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T> {
362
361
  this.devenvLoads += 1;
362
+ const currentRef = await this.gitHead();
363
+ this.devenvRefs.push(currentRef);
364
+ await writeFile(join(this.root, '.generated-tool-ref'), `${currentRef}\n`);
363
365
  return runWithEnv();
364
366
  }
365
367
 
@@ -4,6 +4,7 @@ import type { ReleasePackageInfo } from '../core.js';
4
4
  import {
5
5
  createOrUpdateGithubRelease,
6
6
  type GithubReleaseWriteShell,
7
+ githubReleaseLookupExists,
7
8
  nxProjectChangelogArgs,
8
9
  projectChangelogContents,
9
10
  } from '../github-release.js';
@@ -26,6 +27,7 @@ describe('GitHub release helpers', () => {
26
27
  gitPush: false,
27
28
  stageChanges: false,
28
29
  createRelease: false,
30
+ forceChangelogGeneration: true,
29
31
  deleteVersionPlans: false,
30
32
  dryRun: false,
31
33
  from: 'pkg@1.2.2',
@@ -41,6 +43,7 @@ describe('GitHub release helpers', () => {
41
43
  gitPush: false,
42
44
  stageChanges: false,
43
45
  createRelease: false,
46
+ forceChangelogGeneration: true,
44
47
  deleteVersionPlans: false,
45
48
  dryRun: true,
46
49
  firstRelease: true,
@@ -66,6 +69,14 @@ describe('GitHub release helpers', () => {
66
69
  );
67
70
  });
68
71
 
72
+ it('distinguishes missing releases from transient GitHub lookup failures', () => {
73
+ expect(githubReleaseLookupExists('pkg@1.2.3', 0, '{"tagName":"pkg@1.2.3"}', '')).toBe(true);
74
+ expect(githubReleaseLookupExists('pkg@1.2.3', 1, '', 'HTTP 404: release not found')).toBe(false);
75
+ expect(() => githubReleaseLookupExists('pkg@1.2.3', 1, '', 'HTTP 503: Service Unavailable')).toThrow(
76
+ 'Unable to inspect GitHub Release pkg@1.2.3.\nHTTP 503: Service Unavailable',
77
+ );
78
+ });
79
+
69
80
  it('creates a missing stable GitHub Release from the generated notes file', async () => {
70
81
  const shell = new RecordingGithubReleaseShell(false);
71
82
 
@@ -2,7 +2,9 @@ import { describe, expect, it } from 'bun:test';
2
2
  import { type ReleasePackageInfo, type ReleaseTarget, releaseTag } from '../core.js';
3
3
  import {
4
4
  bumpStableReleaseToNext,
5
+ collectRepairPlatformOutputs,
5
6
  completeReleaseAtHead,
7
+ type ReleaseRepairOutputsShell,
6
8
  type ReleaseRepairShell,
7
9
  type ReleaseVersionShell,
8
10
  repairPendingTargets,
@@ -23,14 +25,16 @@ const prerelease: ReleasePackageInfo = {
23
25
  };
24
26
 
25
27
  describe('release orchestration', () => {
26
- it('repairs an older target with one checkout, one devenv load, npm-only build, and GitHub create calls', async () => {
28
+ it('repairs an older target and refreshes devenv after restoring the caller ref', async () => {
27
29
  const target = releaseTarget('older-release', [stable, prerelease], [stable], [prerelease]);
28
30
  const shell = new RecordingRepairShell();
29
31
 
30
32
  const summaries = await repairPendingTargets(shell, [target], 'restore-ref', false);
31
33
 
32
34
  expect(shell.checkouts).toEqual(['older-release', 'restore-ref']);
33
- expect(shell.devenvLoads).toBe(1);
35
+ expect(shell.devenvLoads).toBe(2);
36
+ expect(shell.devenvRefs).toEqual(['older-release', 'restore-ref']);
37
+ expect(shell.prepares).toEqual(['older-release']);
34
38
  expect(shell.builds).toEqual([['@scope/stable']]);
35
39
  expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
36
40
  expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
@@ -78,6 +82,29 @@ describe('release orchestration', () => {
78
82
  ]);
79
83
  });
80
84
 
85
+ it('collects historical platform outputs only for npm-missing repair targets', async () => {
86
+ const githubOnly = releaseTarget('github-only', [prerelease], [], [prerelease]);
87
+ const npmMissing = releaseTarget('npm-missing', [stable], [stable], []);
88
+ const shell = new RecordingRepairOutputsShell();
89
+
90
+ await collectRepairPlatformOutputs(shell, [githubOnly, npmMissing], 'restore-ref');
91
+
92
+ expect(shell.checkouts).toEqual(['npm-missing', 'restore-ref']);
93
+ expect(shell.devenvRefs).toEqual(['npm-missing', 'restore-ref']);
94
+ expect(shell.collected).toEqual(['npm-missing']);
95
+ });
96
+
97
+ it('does nothing when no pending release needs npm platform outputs', async () => {
98
+ const shell = new RecordingRepairOutputsShell();
99
+ const githubOnly = releaseTarget('github-only', [prerelease], [], [prerelease]);
100
+
101
+ await collectRepairPlatformOutputs(shell, [githubOnly], 'restore-ref');
102
+
103
+ expect(shell.checkouts).toEqual([]);
104
+ expect(shell.devenvRefs).toEqual([]);
105
+ expect(shell.collected).toEqual([]);
106
+ });
107
+
81
108
  it('publishes a partial HEAD release by building npm-missing packages and creating missing GitHub Releases', async () => {
82
109
  const shell = new RecordingRepairShell({ npmMissing: ['@scope/stable'], githubMissing: ['@scope/prerelease'] });
83
110
 
@@ -216,15 +243,21 @@ class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
216
243
  readonly npmQueries: string[][] = [];
217
244
  readonly githubQueries: string[][] = [];
218
245
  devenvLoads = 0;
246
+ readonly devenvRefs: string[] = [];
219
247
  currentRef = 'head';
220
248
  private readonly npmMissing: Set<string>;
221
249
  private readonly githubMissing: Set<string>;
250
+ readonly prepares: string[] = [];
222
251
 
223
252
  constructor(options: { npmMissing?: string[]; githubMissing?: string[] } = {}) {
224
253
  this.npmMissing = new Set(options.npmMissing ?? []);
225
254
  this.githubMissing = new Set(options.githubMissing ?? []);
226
255
  }
227
256
 
257
+ async prepareRepairTarget(target: ReleaseTarget<ReleasePackageInfo>): Promise<void> {
258
+ this.prepares.push(target.sha);
259
+ }
260
+
228
261
  async gitHead(): Promise<string> {
229
262
  return this.currentRef;
230
263
  }
@@ -264,10 +297,32 @@ class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
264
297
 
265
298
  async withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T> {
266
299
  this.devenvLoads += 1;
300
+ this.devenvRefs.push(this.currentRef);
267
301
  return runWithEnv();
268
302
  }
269
303
  }
270
304
 
305
+ class RecordingRepairOutputsShell implements ReleaseRepairOutputsShell<ReleasePackageInfo> {
306
+ readonly checkouts: string[] = [];
307
+ readonly collected: string[] = [];
308
+ readonly devenvRefs: string[] = [];
309
+ private currentRef = 'head';
310
+
311
+ async checkout(ref: string): Promise<void> {
312
+ this.currentRef = ref;
313
+ this.checkouts.push(ref);
314
+ }
315
+
316
+ async withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T> {
317
+ this.devenvRefs.push(this.currentRef);
318
+ return runWithEnv();
319
+ }
320
+
321
+ async collectRepairTargetOutputs(target: ReleaseTarget<ReleasePackageInfo>): Promise<void> {
322
+ this.collected.push(target.sha);
323
+ }
324
+ }
325
+
271
326
  class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
272
327
  readonly ensureCalls: string[][] = [];
273
328
  readonly nxRuns: Array<{ packages: string[]; bump: string; dryRun: boolean }> = [];
@@ -3,8 +3,11 @@ import type { BootstrapNpmPackagesOptions } from '../bootstrap-npm-packages.js';
3
3
  import type { ReleasePackageInfo } from '../core.js';
4
4
  import {
5
5
  configureTrustedPublishers,
6
+ npmTrustGithubArgs,
7
+ npmTrustListAccessDenied,
6
8
  parseTrustedPublishers,
7
9
  type TrustedPublisher,
10
+ type TrustedPublisherLookup,
8
11
  type TrustPublisherShell,
9
12
  } from '../index.js';
10
13
 
@@ -101,6 +104,84 @@ describe('trusted publisher setup', () => {
101
104
  expect(shell.events).toEqual([`exists:${missing.name}`, `list:${missing.name}`, `trust:${missing.name}:false`]);
102
105
  });
103
106
 
107
+ it('reports package owners and opens npm login after trust access is denied', async () => {
108
+ const shell = new RecordingTrustPublisherShell({
109
+ packages: [stable],
110
+ existing: [stable.name],
111
+ trustedPublisherResponses: {
112
+ [stable.name]: [
113
+ {
114
+ status: 'access-denied',
115
+ identity: 'current-user',
116
+ owners: 'package-owner <owner@example.test>',
117
+ },
118
+ [],
119
+ ],
120
+ },
121
+ });
122
+
123
+ await configureTrustedPublishers(shell, {});
124
+
125
+ expect(shell.events).toEqual([
126
+ `exists:${stable.name}`,
127
+ `list:${stable.name}`,
128
+ 'login',
129
+ `list:${stable.name}`,
130
+ `trust:${stable.name}:false`,
131
+ ]);
132
+ expect(shell.errors.join('\n')).toContain('npm account "current-user"');
133
+ expect(shell.errors.join('\n')).toContain('package-owner <owner@example.test>');
134
+ expect(shell.logs).toContain(
135
+ `${stable.name}: opening npm browser login. Sign in as a listed package owner, then return here.`,
136
+ );
137
+ });
138
+
139
+ it('stops after one owner login when the selected account still lacks access', async () => {
140
+ const shell = new RecordingTrustPublisherShell({
141
+ packages: [stable],
142
+ existing: [stable.name],
143
+ trustedPublisherResponses: {
144
+ [stable.name]: [
145
+ {
146
+ status: 'access-denied',
147
+ identity: 'current-user',
148
+ owners: 'package-owner <owner@example.test>',
149
+ },
150
+ {
151
+ status: 'access-denied',
152
+ identity: 'wrong-user',
153
+ owners: 'package-owner <owner@example.test>',
154
+ },
155
+ ],
156
+ },
157
+ });
158
+
159
+ await expect(configureTrustedPublishers(shell, {})).rejects.toThrow(
160
+ 'npm browser login completed, but the selected account still cannot manage this package',
161
+ );
162
+ expect(shell.events).toEqual([`exists:${stable.name}`, `list:${stable.name}`, 'login', `list:${stable.name}`]);
163
+ });
164
+
165
+ it('grants the trusted workflow permission to run npm publish', () => {
166
+ expect(npmTrustGithubArgs(stable.name, 'scope/repo', 'publish.yml', false)).toEqual([
167
+ 'trust',
168
+ 'github',
169
+ stable.name,
170
+ '--file',
171
+ 'publish.yml',
172
+ '--repo',
173
+ 'scope/repo',
174
+ '--allow-publish',
175
+ '--yes',
176
+ ]);
177
+ expect(npmTrustGithubArgs(stable.name, 'scope/repo', 'publish.yml', true).at(-1)).toBe('--dry-run');
178
+ });
179
+
180
+ it('recognizes npm trust access-denied output', () => {
181
+ expect(npmTrustListAccessDenied('{"error":{"code":"E403"}}', '')).toBe(true);
182
+ expect(npmTrustListAccessDenied('', 'npm error code EOTP')).toBe(false);
183
+ });
184
+
104
185
  it('parses empty npm trust list output as no trusted publishers', () => {
105
186
  expect(parseTrustedPublishers('', stable.name)).toEqual([]);
106
187
  expect(parseTrustedPublishers('null', stable.name)).toEqual([]);
@@ -116,15 +197,18 @@ class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackage
116
197
  private readonly packages: ReleasePackageInfo[];
117
198
  private readonly existing: Set<string>;
118
199
  private readonly trustedPublisherByPackage: Record<string, TrustedPublisher[]>;
200
+ private readonly trustedPublisherResponses: Record<string, TrustedPublisherLookup[]>;
119
201
 
120
202
  constructor(options: {
121
203
  packages: ReleasePackageInfo[];
122
204
  existing: string[];
123
205
  trustedPublishers?: Record<string, TrustedPublisher[]>;
206
+ trustedPublisherResponses?: Record<string, TrustedPublisherLookup[]>;
124
207
  }) {
125
208
  this.packages = options.packages;
126
209
  this.existing = new Set(options.existing);
127
210
  this.trustedPublisherByPackage = options.trustedPublishers ?? {};
211
+ this.trustedPublisherResponses = options.trustedPublisherResponses ?? {};
128
212
  }
129
213
 
130
214
  listReleasePackages(): ReleasePackageInfo[] {
@@ -145,14 +229,19 @@ class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackage
145
229
  return missingPackages;
146
230
  }
147
231
 
232
+ async login(): Promise<void> {
233
+ this.events.push('login');
234
+ }
235
+
148
236
  async trustPublisher(pkg: ReleasePackageInfo, dryRun: boolean): Promise<'configured' | 'already-configured'> {
149
237
  this.events.push(`trust:${pkg.name}:${dryRun}`);
150
238
  return 'configured';
151
239
  }
152
240
 
153
- async trustedPublishers(pkg: ReleasePackageInfo): Promise<TrustedPublisher[]> {
241
+ async trustedPublishers(pkg: ReleasePackageInfo): Promise<TrustedPublisherLookup> {
154
242
  this.events.push(`list:${pkg.name}`);
155
- return this.trustedPublisherByPackage[pkg.name] ?? [];
243
+ const response = this.trustedPublisherResponses[pkg.name]?.shift();
244
+ return response ?? this.trustedPublisherByPackage[pkg.name] ?? [];
156
245
  }
157
246
 
158
247
  log(message: string): void {
@@ -71,6 +71,7 @@ export function nxProjectChangelogArgs(pkg: ReleasePackageInfo, previousTag: str
71
71
  gitPush: false,
72
72
  stageChanges: false,
73
73
  createRelease: false,
74
+ forceChangelogGeneration: true,
74
75
  deleteVersionPlans: false,
75
76
  dryRun,
76
77
  } satisfies ChangelogOptions;
@@ -85,6 +86,17 @@ export function projectChangelogContents(result: ProjectChangelogLookupResult, p
85
86
  return changelog.contents;
86
87
  }
87
88
 
89
+ export function githubReleaseLookupExists(tag: string, exitCode: number, stdout: string, stderr: string): boolean {
90
+ if (exitCode === 0) {
91
+ return true;
92
+ }
93
+ const details = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n');
94
+ if (/\bHTTP 404\b|release not found/i.test(details)) {
95
+ return false;
96
+ }
97
+ throw new Error(`Unable to inspect GitHub Release ${tag}.${details ? `\n${details}` : ''}`);
98
+ }
99
+
88
100
  export function githubReleaseCommandArgs(
89
101
  tag: string,
90
102
  notesFile: string,