@smoothbricks/cli 0.10.1 → 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 (77) 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 +145 -14
  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/lib/conflict-markers.d.ts.map +1 -1
  10. package/dist/lib/workspace.d.ts +1 -1
  11. package/dist/lib/workspace.d.ts.map +1 -1
  12. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  13. package/dist/monorepo/ci-workflow.js +2 -1
  14. package/dist/monorepo/managed-files.d.ts +2 -0
  15. package/dist/monorepo/managed-files.d.ts.map +1 -1
  16. package/dist/monorepo/managed-files.js +50 -5
  17. package/dist/monorepo/package-policy.d.ts.map +1 -1
  18. package/dist/monorepo/package-policy.js +26 -0
  19. package/dist/monorepo/packs/index.d.ts +3 -0
  20. package/dist/monorepo/packs/index.d.ts.map +1 -1
  21. package/dist/monorepo/packs/index.js +5 -2
  22. package/dist/monorepo/publish-workflow.d.ts +1 -0
  23. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  24. package/dist/monorepo/publish-workflow.js +240 -2
  25. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  26. package/dist/monorepo/tool-validation.js +20 -7
  27. package/dist/nx/index.d.ts +6 -0
  28. package/dist/nx/index.d.ts.map +1 -1
  29. package/dist/nx/index.js +55 -5
  30. package/dist/release/github-release.d.ts +5 -2
  31. package/dist/release/github-release.d.ts.map +1 -1
  32. package/dist/release/github-release.js +11 -0
  33. package/dist/release/index.d.ts +19 -1
  34. package/dist/release/index.d.ts.map +1 -1
  35. package/dist/release/index.js +140 -31
  36. package/dist/release/orchestration.d.ts +8 -1
  37. package/dist/release/orchestration.d.ts.map +1 -1
  38. package/dist/release/orchestration.js +38 -1
  39. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  40. package/dist/wrangler/prepare-env.js +1 -1
  41. package/dist/wrangler/scaffold.d.ts.map +1 -1
  42. package/dist/wrangler/scaffold.js +1 -1
  43. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +5 -0
  44. package/managed/raw/tooling/direnv/setup-environment.ts +32 -1
  45. package/managed/templates/github/actions/cache-nix-devenv/action.yml +12 -10
  46. package/managed/templates/github/actions/cache-node-modules/action.yml +3 -2
  47. package/managed/templates/github/actions/cache-nx/action.yml +2 -2
  48. package/managed/templates/github/actions/save-nix-devenv/action.yml +11 -3
  49. package/package.json +7 -14
  50. package/src/cli.ts +30 -4
  51. package/src/github-ci/index.test.ts +486 -0
  52. package/src/github-ci/index.ts +177 -16
  53. package/src/github-ci/outputs.ts +506 -0
  54. package/src/monorepo/__tests__/ci-workflow.test.ts +13 -0
  55. package/src/monorepo/__tests__/publish-workflow.test.ts +234 -5
  56. package/src/monorepo/ci-workflow.ts +3 -1
  57. package/src/monorepo/managed-files.test.ts +75 -0
  58. package/src/monorepo/managed-files.ts +61 -5
  59. package/src/monorepo/package-policy.test.ts +57 -38
  60. package/src/monorepo/package-policy.ts +28 -0
  61. package/src/monorepo/packs/index.test.ts +18 -1
  62. package/src/monorepo/packs/index.ts +7 -3
  63. package/src/monorepo/publish-workflow.ts +481 -2
  64. package/src/monorepo/tool-validation.test.ts +59 -24
  65. package/src/monorepo/tool-validation.ts +21 -7
  66. package/src/monorepo/wrangler.test.ts +9 -2
  67. package/src/nx/index.test.ts +39 -0
  68. package/src/nx/index.ts +65 -5
  69. package/src/release/__tests__/fixture-repo.test.ts +18 -16
  70. package/src/release/__tests__/github-release.test.ts +11 -0
  71. package/src/release/__tests__/orchestration.test.ts +57 -2
  72. package/src/release/__tests__/trust-publisher.test.ts +91 -2
  73. package/src/release/github-release.ts +12 -0
  74. package/src/release/index.ts +218 -40
  75. package/src/release/orchestration.ts +55 -3
  76. package/src/wrangler/prepare-env.ts +14 -4
  77. package/src/wrangler/scaffold.ts +8 -3
@@ -12,12 +12,13 @@ import {
12
12
 
13
13
  const registryVersions: Record<string, string[]> = {
14
14
  '@biomejs/biome': ['2.3.5'],
15
- '@nx/js': ['22.5.4'],
16
- '@smoothbricks/nx-plugin': ['0.0.1'],
15
+ '@nx/js': ['23.1.0'],
16
+ '@smoothbricks/nx-plugin': ['0.3.0'],
17
17
  eslint: ['9.39.1'],
18
18
  'eslint-stdout': ['1.1.1', '1.1.2'],
19
- nx: ['22.5.4'],
19
+ nx: ['23.1.0'],
20
20
  prettier: ['3.6.1'],
21
+ ttsc: ['0.18.4'],
21
22
  typescript: ['5.9.3'],
22
23
  };
23
24
 
@@ -25,7 +26,7 @@ const realFetch = globalThis.fetch;
25
26
 
26
27
  describe('tool configuration validation', () => {
27
28
  beforeEach(() => {
28
- globalThis.fetch = mockRegistryFetch as typeof fetch;
29
+ globalThis.fetch = mockRegistryFetch;
29
30
  });
30
31
 
31
32
  afterEach(() => {
@@ -67,7 +68,7 @@ describe('tool configuration validation', () => {
67
68
  expect(rootPackage.devDependencies['@smoothbricks/cli']).toBeUndefined();
68
69
  expect(rootPackage.devDependencies['@smoothbricks/nx-plugin']).toBe('workspace:*');
69
70
  expect(rootPackage.devDependencies['eslint-stdout']).toBe('workspace:*');
70
- expect(rootPackage.devDependencies.nx).toBe('22.5.4');
71
+ expect(rootPackage.devDependencies.nx).toBe('23.1.0');
71
72
  expect(rootPackage.workspaces).toContain('tooling');
72
73
  expect(toolingPackage.name).toBe('@smoothbricks/tooling');
73
74
  expect(toolingPackage.dependencies['@smoothbricks/cli']).toBe('workspace:*');
@@ -89,13 +90,14 @@ describe('tool configuration validation', () => {
89
90
  workspaces: ['packages/*', 'tooling'],
90
91
  devDependencies: {
91
92
  '@biomejs/biome': '^3.0.0',
92
- '@nx/js': '22.6.0',
93
- '@smoothbricks/nx-plugin': '^0.1.0',
93
+ '@nx/js': '23.2.0',
94
+ '@smoothbricks/nx-plugin': '^0.4.0',
94
95
  '@types/bun': '1.3.99',
95
96
  eslint: '^10.0.0',
96
97
  'eslint-stdout': await currentEslintStdoutRange(),
97
- nx: '23.0.0',
98
+ nx: '24.0.0',
98
99
  prettier: '^3.7.0',
100
+ ttsc: '^0.19.0',
99
101
  typescript: '^6.0.0',
100
102
  },
101
103
  });
@@ -132,6 +134,35 @@ describe('tool configuration validation', () => {
132
134
  }
133
135
  });
134
136
 
137
+ it('preserves an installable CLI range when the running CLI is a linked prerelease', async () => {
138
+ const root = await mkdtemp(join(tmpdir(), 'smoo-tool-validation-'));
139
+ try {
140
+ const configuredRange = '^0.10.1';
141
+ const runningRange = await currentCliRange();
142
+ const expectedRange = runningRange.includes('-') ? configuredRange : runningRange;
143
+ await writeJson(join(root, 'package.json'), {
144
+ name: '@fixture/app',
145
+ version: '0.0.0',
146
+ private: true,
147
+ workspaces: ['packages/*', 'tooling'],
148
+ });
149
+ await writeJson(join(root, 'tooling/package.json'), {
150
+ name: '@fixture/tooling',
151
+ private: true,
152
+ dependencies: { '@smoothbricks/cli': configuredRange },
153
+ });
154
+
155
+ const context = readToolContext(root);
156
+ expect(context.policy.cliDependencyRange).toBe(expectedRange);
157
+ applyToolingPackageDefaults(root, context.policy);
158
+
159
+ const toolingPackage = JSON.parse(await readFile(join(root, 'tooling/package.json'), 'utf8'));
160
+ expect(toolingPackage.dependencies['@smoothbricks/cli']).toBe(expectedRange);
161
+ } finally {
162
+ await rm(root, { recursive: true, force: true });
163
+ }
164
+ });
165
+
135
166
  it('uses workspace smoo in the SmoothBricks codebase repo', async () => {
136
167
  const root = await mkdtemp(join(tmpdir(), 'smoo-tool-validation-'));
137
168
  try {
@@ -161,10 +192,11 @@ describe('tool configuration validation', () => {
161
192
  workspaces: ['packages/*', 'tooling'],
162
193
  devDependencies: {
163
194
  '@biomejs/biome': '^3.0.0',
164
- '@nx/js': '22.6.0',
195
+ '@nx/js': '23.2.0',
165
196
  eslint: '^10.0.0',
166
- nx: '23.0.0',
197
+ nx: '24.0.0',
167
198
  prettier: '^3.7.0',
199
+ ttsc: '^0.19.0',
168
200
  typescript: '^6.0.0',
169
201
  },
170
202
  });
@@ -195,20 +227,23 @@ async function currentEslintStdoutRange(): Promise<string> {
195
227
  return `^${latestPatchVersion('eslint-stdout', pkg.version) ?? pkg.version}`;
196
228
  }
197
229
 
198
- function mockRegistryFetch(input: Parameters<typeof fetch>[0]): Promise<Response> {
199
- const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
200
- const packageName = decodeURIComponent(new URL(url).pathname.slice(1));
201
- const versions = registryVersions[packageName];
202
- if (!versions) {
203
- return Promise.resolve(new Response('{}', { status: 404 }));
204
- }
205
- return Promise.resolve(
206
- new Response(JSON.stringify({ versions: Object.fromEntries(versions.map((version) => [version, {}])) }), {
207
- status: 200,
208
- headers: { 'content-type': 'application/json' },
209
- }),
210
- );
211
- }
230
+ const mockRegistryFetch = Object.assign(
231
+ (input: string | URL | Request): Promise<Response> => {
232
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
233
+ const packageName = decodeURIComponent(new URL(url).pathname.slice(1));
234
+ const versions = registryVersions[packageName];
235
+ if (!versions) {
236
+ return Promise.resolve(new Response('{}', { status: 404 }));
237
+ }
238
+ return Promise.resolve(
239
+ new Response(JSON.stringify({ versions: Object.fromEntries(versions.map((version) => [version, {}])) }), {
240
+ status: 200,
241
+ headers: { 'content-type': 'application/json' },
242
+ }),
243
+ );
244
+ },
245
+ { preconnect: realFetch.preconnect },
246
+ ) satisfies typeof fetch;
212
247
 
213
248
  function latestPatchVersion(packageName: string, version: string): string | null {
214
249
  const [major, minor] = version.split('.');
@@ -25,11 +25,11 @@ export interface ToolContext {
25
25
 
26
26
  const rootDevDependencies: RequiredDependency[] = [
27
27
  { name: '@biomejs/biome', fallbackVersion: '^2.3.5', minimumVersion: '2.3.0', prefix: '^' },
28
- { name: '@nx/js', fallbackVersion: '22.0.3', minimumVersion: '22.0.0' },
28
+ { name: '@nx/js', fallbackVersion: '23.1.0', minimumVersion: '23.1.0' },
29
29
  {
30
30
  name: '@smoothbricks/nx-plugin',
31
- fallbackVersion: '^0.0.1',
32
- minimumVersion: '0.0.1',
31
+ fallbackVersion: '^0.3.0',
32
+ minimumVersion: '0.3.0',
33
33
  prefix: '^',
34
34
  useWorkspaceRangeInCodebase: true,
35
35
  },
@@ -41,8 +41,11 @@ const rootDevDependencies: RequiredDependency[] = [
41
41
  prefix: '^',
42
42
  useWorkspaceRangeInCodebase: true,
43
43
  },
44
- { name: 'nx', fallbackVersion: '22.5.4', minimumVersion: '22.5.0' },
44
+ { name: 'nx', fallbackVersion: '23.1.0', minimumVersion: '23.1.0' },
45
45
  { name: 'prettier', fallbackVersion: '^3.6.1', minimumVersion: '3.6.0', prefix: '^' },
46
+ { name: 'ttsc', fallbackVersion: '^0.18.4', minimumVersion: '0.18.4', prefix: '^' },
47
+ // Nx and typescript-eslint still load the TypeScript 5.x JavaScript API.
48
+ // Compilation is exclusively delegated to ttsc by the Nx plugin targets.
46
49
  { name: 'typescript', fallbackVersion: '^5.9.3', minimumVersion: '5.9.0', prefix: '^' },
47
50
  ];
48
51
 
@@ -330,17 +333,28 @@ function formatExpectedDependency(policy: ToolPolicy, dependency: RequiredDepend
330
333
 
331
334
  export function readToolContext(root: string): ToolContext {
332
335
  const rootPackage = readPackageJsonObject(join(root, 'package.json'));
333
- return { rootPackage, policy: toolPolicy(rootPackage) };
336
+ const toolingPackage = readJsonObject(join(root, 'tooling', 'package.json'));
337
+ const configuredCliRange = recordProperty(toolingPackage ?? {}, 'dependencies')?.[cliPackageName];
338
+ return {
339
+ rootPackage,
340
+ policy: toolPolicy(rootPackage, typeof configuredCliRange === 'string' ? configuredCliRange : null),
341
+ };
334
342
  }
335
343
 
336
- function toolPolicy(rootPackage: PackageJson | null): ToolPolicy {
344
+ function toolPolicy(rootPackage: PackageJson | null, configuredCliRange: string | null): ToolPolicy {
337
345
  const name = typeof rootPackage?.name === 'string' ? rootPackage.name : null;
338
346
  const isCodebase = isSmoothBricksCodebasePackageName(name ?? undefined);
339
347
  const toolingName = toolingPackageName(name);
348
+ // A locally linked prerelease can be newer than every published CLI. Keep an existing
349
+ // installable range instead of rewriting the consumer manifest to an unpublished version.
350
+ const publishedCliRange =
351
+ cliPackageVersion.includes('-') && configuredCliRange && parseVersion(configuredCliRange)
352
+ ? configuredCliRange
353
+ : `^${cliPackageVersion}`;
340
354
  return {
341
355
  isSmoothBricksCodebase: isCodebase,
342
356
  toolingPackageName: toolingName,
343
- cliDependencyRange: isCodebase ? 'workspace:*' : `^${cliPackageVersion}`,
357
+ cliDependencyRange: isCodebase ? 'workspace:*' : publishedCliRange,
344
358
  };
345
359
  }
346
360
 
@@ -21,7 +21,12 @@ describe('firstWranglerEnv', () => {
21
21
  describe('applyWranglerDefaults', () => {
22
22
  it('injects the wrangler-types target and wires typecheck, idempotently', async () => {
23
23
  const root = await createWorkspace([
24
- { dir: 'api', name: '@acme/api', toml: '[env.production]\n', nx: { targets: { typecheck: { dependsOn: ['^build'] } } } },
24
+ {
25
+ dir: 'api',
26
+ name: '@acme/api',
27
+ toml: '[env.production]\n',
28
+ nx: { targets: { typecheck: { dependsOn: ['^build'] } } },
29
+ },
25
30
  ]);
26
31
  try {
27
32
  applyWranglerDefaults(root);
@@ -51,7 +56,9 @@ describe('applyWranglerDefaults', () => {
51
56
  });
52
57
 
53
58
  it('omits --env from the command when the wrangler.toml declares no env block', async () => {
54
- const root = await createWorkspace([{ dir: 'api', name: '@acme/api', toml: 'name = "svc"\nmain = "src/index.ts"\n' }]);
59
+ const root = await createWorkspace([
60
+ { dir: 'api', name: '@acme/api', toml: 'name = "svc"\nmain = "src/index.ts"\n' },
61
+ ]);
55
62
  captureConsoleLogs();
56
63
  try {
57
64
  applyWranglerDefaults(root);
@@ -7,7 +7,10 @@ import {
7
7
  nxShowProjectCommand,
8
8
  projectNamesFromNxShowProjectsOutput,
9
9
  projectNamesWithTarget,
10
+ projectRootFromNxProjectJson,
11
+ targetDependenciesFromNxProjectJson,
10
12
  targetNamesFromNxProjectJson,
13
+ targetOutputsFromNxProjectJson,
11
14
  } from './index.js';
12
15
 
13
16
  describe('Nx helper command construction', () => {
@@ -67,6 +70,42 @@ describe('Nx helper output formatting', () => {
67
70
  ]);
68
71
  });
69
72
 
73
+ it('extracts resolved roots, output declarations, and same-project target dependencies', () => {
74
+ const metadata = {
75
+ root: 'packages/native',
76
+ targets: {
77
+ 'build-macos': {
78
+ outputs: ['{projectRoot}/dist/*.dmg'],
79
+ dependsOn: ['^build', 'compile-macos', { target: 'package-macos', projects: 'self' }],
80
+ },
81
+ test: { outputs: [] },
82
+ },
83
+ };
84
+
85
+ expect(projectRootFromNxProjectJson(metadata)).toBe('packages/native');
86
+ expect(targetOutputsFromNxProjectJson(metadata)).toEqual(
87
+ new Map([
88
+ ['build-macos', ['{projectRoot}/dist/*.dmg']],
89
+ ['test', []],
90
+ ]),
91
+ );
92
+ expect(targetDependenciesFromNxProjectJson(metadata)).toEqual(
93
+ new Map([['build-macos', ['^build', 'compile-macos', 'package-macos']]]),
94
+ );
95
+ });
96
+
97
+ it('rejects unsupported cross-project object target dependencies', () => {
98
+ expect(() =>
99
+ targetDependenciesFromNxProjectJson({
100
+ targets: {
101
+ build: {
102
+ dependsOn: [{ target: 'build', projects: 'dependencies' }],
103
+ },
104
+ },
105
+ }),
106
+ ).toThrow('unsupported cross-project dependsOn');
107
+ });
108
+
70
109
  it('treats missing target metadata as an empty project', () => {
71
110
  expect(targetNamesFromNxProjectJson({ name: 'cli' })).toEqual([]);
72
111
  });
package/src/nx/index.ts CHANGED
@@ -7,9 +7,12 @@ import { decode, run } from '../lib/run.js';
7
7
 
8
8
  export interface ProjectTargets {
9
9
  project: string;
10
+ root?: string;
10
11
  targets: string[];
11
12
  buildDependsOn?: string[];
13
+ targetDependencies?: Map<string, string[]>;
12
14
  targetExecutors?: Map<string, string>;
15
+ targetOutputs?: Map<string, string[]>;
13
16
  targetScripts?: Map<string, string>;
14
17
  }
15
18
 
@@ -35,13 +38,41 @@ export function targetNamesFromNxProjectJson(value: unknown): string[] {
35
38
  return targets ? Object.keys(targets).sort((a, b) => a.localeCompare(b)) : [];
36
39
  }
37
40
 
41
+ export function projectRootFromNxProjectJson(value: unknown): string | undefined {
42
+ return isRecord(value) && typeof value.root === 'string' ? value.root : undefined;
43
+ }
44
+
38
45
  export function buildDependsOnFromNxProjectJson(value: unknown): string[] | undefined {
46
+ return targetDependenciesFromNxProjectJson(value).get('build');
47
+ }
48
+
49
+ export function targetDependenciesFromNxProjectJson(value: unknown): Map<string, string[]> {
39
50
  const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
40
- const build = targets ? recordProperty(targets, 'build') : null;
41
- if (!Array.isArray(build?.dependsOn) || !build.dependsOn.every((entry) => typeof entry === 'string')) {
42
- return undefined;
51
+ const dependencies = new Map<string, string[]>();
52
+ if (!targets) {
53
+ return dependencies;
54
+ }
55
+ for (const [targetName, target] of Object.entries(targets)) {
56
+ if (!isRecord(target) || !Array.isArray(target.dependsOn)) {
57
+ continue;
58
+ }
59
+ const entries: string[] = [];
60
+ for (const dependency of target.dependsOn) {
61
+ if (typeof dependency === 'string') {
62
+ entries.push(dependency);
63
+ continue;
64
+ }
65
+ if (!isRecord(dependency) || typeof dependency.target !== 'string') {
66
+ throw new Error(`Nx target ${targetName} has an invalid dependsOn entry.`);
67
+ }
68
+ if (dependency.projects !== undefined && dependency.projects !== 'self') {
69
+ throw new Error(`Nx target ${targetName} uses unsupported cross-project dependsOn for ${dependency.target}.`);
70
+ }
71
+ entries.push(dependency.target);
72
+ }
73
+ dependencies.set(targetName, entries);
43
74
  }
44
- return build.dependsOn;
75
+ return dependencies;
45
76
  }
46
77
 
47
78
  export function targetExecutorsFromNxProjectJson(value: unknown): Map<string, string> {
@@ -58,6 +89,10 @@ export function targetExecutorsFromNxProjectJson(value: unknown): Map<string, st
58
89
  return executors;
59
90
  }
60
91
 
92
+ export function targetOutputsFromNxProjectJson(value: unknown): Map<string, string[]> {
93
+ return targetStringArraysFromNxProjectJson(value, 'outputs');
94
+ }
95
+
61
96
  export function targetScriptsFromNxProjectJson(value: unknown): Map<string, string> {
62
97
  const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
63
98
  const scripts = new Map<string, string>();
@@ -76,6 +111,27 @@ export function targetScriptsFromNxProjectJson(value: unknown): Map<string, stri
76
111
  return scripts;
77
112
  }
78
113
 
114
+ function targetStringArraysFromNxProjectJson(value: unknown, property: string): Map<string, string[]> {
115
+ const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
116
+ const values = new Map<string, string[]>();
117
+ if (!targets) {
118
+ return values;
119
+ }
120
+ for (const [targetName, target] of Object.entries(targets)) {
121
+ if (!isRecord(target)) {
122
+ continue;
123
+ }
124
+ const entries = target[property];
125
+ if (Array.isArray(entries)) {
126
+ values.set(
127
+ targetName,
128
+ entries.filter((entry): entry is string => typeof entry === 'string'),
129
+ );
130
+ }
131
+ }
132
+ return values;
133
+ }
134
+
79
135
  export function formatProjectTargetLines(projects: ProjectTargets[]): string {
80
136
  return projects
81
137
  .flatMap((project) => project.targets.map((target) => `${project.project}:${target}`))
@@ -165,11 +221,15 @@ async function readProjectTarget(root: string, project: string): Promise<Project
165
221
  const command = nxShowProjectCommand(project);
166
222
  const result = await $`${command.command} ${command.args}`.cwd(root).quiet();
167
223
  const parsed: unknown = JSON.parse(decode(result.stdout));
224
+ const targetDependencies = targetDependenciesFromNxProjectJson(parsed);
168
225
  return {
169
226
  project,
227
+ root: projectRootFromNxProjectJson(parsed),
170
228
  targets: targetNamesFromNxProjectJson(parsed),
171
- buildDependsOn: buildDependsOnFromNxProjectJson(parsed),
229
+ buildDependsOn: targetDependencies.get('build'),
230
+ targetDependencies,
172
231
  targetExecutors: targetExecutorsFromNxProjectJson(parsed),
232
+ targetOutputs: targetOutputsFromNxProjectJson(parsed),
173
233
  targetScripts: targetScriptsFromNxProjectJson(parsed),
174
234
  };
175
235
  }
@@ -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 }> = [];