@smoothbricks/cli 0.10.2 → 0.10.4

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 (133) hide show
  1. package/README.md +31 -23
  2. package/dist/bun/preload.d.ts +13 -0
  3. package/dist/bun/preload.d.ts.map +1 -0
  4. package/dist/bun/preload.js +12 -0
  5. package/dist/cli.js +36 -4
  6. package/dist/generate/index.d.ts.map +1 -1
  7. package/dist/generate/index.js +0 -6
  8. package/dist/github-ci/index.d.ts +17 -2
  9. package/dist/github-ci/index.d.ts.map +1 -1
  10. package/dist/github-ci/index.js +192 -26
  11. package/dist/github-ci/outputs.d.ts +22 -0
  12. package/dist/github-ci/outputs.d.ts.map +1 -0
  13. package/dist/github-ci/outputs.js +648 -0
  14. package/dist/lib/cli-package.js +2 -2
  15. package/dist/lib/json.d.ts +118 -11
  16. package/dist/lib/json.d.ts.map +1 -1
  17. package/dist/lib/json.js +317 -29
  18. package/dist/lib/workspace.d.ts +7 -14
  19. package/dist/lib/workspace.d.ts.map +1 -1
  20. package/dist/lib/workspace.js +76 -82
  21. package/dist/monorepo/ci-workflow.d.ts +2 -0
  22. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  23. package/dist/monorepo/ci-workflow.js +28 -2
  24. package/dist/monorepo/git-config.js +2 -2
  25. package/dist/monorepo/index.d.ts +3 -1
  26. package/dist/monorepo/index.d.ts.map +1 -1
  27. package/dist/monorepo/index.js +6 -2
  28. package/dist/monorepo/lockfile.d.ts +13 -1
  29. package/dist/monorepo/lockfile.d.ts.map +1 -1
  30. package/dist/monorepo/lockfile.js +33 -21
  31. package/dist/monorepo/managed-files.d.ts +12 -0
  32. package/dist/monorepo/managed-files.d.ts.map +1 -1
  33. package/dist/monorepo/managed-files.js +86 -28
  34. package/dist/monorepo/package-policy.d.ts.map +1 -1
  35. package/dist/monorepo/package-policy.js +78 -45
  36. package/dist/monorepo/packed-manifest.d.ts +5 -2
  37. package/dist/monorepo/packed-manifest.d.ts.map +1 -1
  38. package/dist/monorepo/packed-manifest.js +16 -47
  39. package/dist/monorepo/packed-package.d.ts.map +1 -1
  40. package/dist/monorepo/packed-package.js +28 -16
  41. package/dist/monorepo/packs/index.d.ts +3 -0
  42. package/dist/monorepo/packs/index.d.ts.map +1 -1
  43. package/dist/monorepo/packs/index.js +18 -6
  44. package/dist/monorepo/publish-workflow.d.ts +1 -0
  45. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  46. package/dist/monorepo/publish-workflow.js +242 -3
  47. package/dist/monorepo/runtime.d.ts +15 -0
  48. package/dist/monorepo/runtime.d.ts.map +1 -1
  49. package/dist/monorepo/runtime.js +66 -12
  50. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  51. package/dist/monorepo/tool-validation.js +32 -23
  52. package/dist/monorepo/wrangler.d.ts.map +1 -1
  53. package/dist/monorepo/wrangler.js +13 -13
  54. package/dist/nx/index.d.ts +12 -4
  55. package/dist/nx/index.d.ts.map +1 -1
  56. package/dist/nx/index.js +86 -19
  57. package/dist/pr/index.d.ts.map +1 -1
  58. package/dist/pr/index.js +28 -32
  59. package/dist/release/candidates.d.ts +3 -2
  60. package/dist/release/candidates.d.ts.map +1 -1
  61. package/dist/release/candidates.js +4 -5
  62. package/dist/release/github-release.d.ts +3 -0
  63. package/dist/release/github-release.d.ts.map +1 -1
  64. package/dist/release/github-release.js +11 -0
  65. package/dist/release/index.d.ts +20 -1
  66. package/dist/release/index.d.ts.map +1 -1
  67. package/dist/release/index.js +221 -89
  68. package/dist/release/orchestration.d.ts +8 -1
  69. package/dist/release/orchestration.d.ts.map +1 -1
  70. package/dist/release/orchestration.js +38 -1
  71. package/dist/wrangler/prepare-env.d.ts +0 -1
  72. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  73. package/dist/wrangler/prepare-env.js +100 -51
  74. package/dist/wrangler/scaffold.d.ts.map +1 -1
  75. package/dist/wrangler/scaffold.js +12 -12
  76. package/managed/raw/git-format-staged.yml +19 -1
  77. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +6 -0
  78. package/managed/raw/tooling/direnv/merge-newer-pins.sh +3 -3
  79. package/managed/raw/tooling/direnv/setup-environment.ts +187 -19
  80. package/managed/raw/tooling/git-hooks/pre-commit.sh +0 -6
  81. package/managed/templates/github/actions/cache-nix-devenv/action.yml +12 -10
  82. package/managed/templates/github/actions/cache-node-modules/action.yml +6 -2
  83. package/managed/templates/github/actions/cache-nx/action.yml +2 -2
  84. package/managed/templates/github/actions/cache-ttsc-plugins/action.yml +19 -0
  85. package/managed/templates/github/actions/save-nix-devenv/action.yml +11 -3
  86. package/managed/templates/github/actions/setup-devenv/action.yml +5 -0
  87. package/managed/templates/github/workflows/managed-files.yml +80 -0
  88. package/package.json +18 -4
  89. package/src/bun/preload.ts +12 -0
  90. package/src/cli.ts +49 -11
  91. package/src/generate/index.ts +0 -6
  92. package/src/github-ci/index.test.ts +486 -0
  93. package/src/github-ci/index.ts +220 -30
  94. package/src/github-ci/outputs.ts +505 -0
  95. package/src/lib/cli-package.ts +2 -2
  96. package/src/lib/json.ts +291 -32
  97. package/src/lib/workspace.ts +88 -100
  98. package/src/monorepo/__tests__/ci-workflow.test.ts +14 -3
  99. package/src/monorepo/__tests__/publish-workflow.test.ts +222 -1
  100. package/src/monorepo/ci-workflow.ts +28 -2
  101. package/src/monorepo/git-config.ts +2 -2
  102. package/src/monorepo/index.ts +6 -2
  103. package/src/monorepo/lockfile.test.ts +35 -36
  104. package/src/monorepo/lockfile.ts +48 -24
  105. package/src/monorepo/managed-files.test.ts +63 -0
  106. package/src/monorepo/managed-files.ts +94 -34
  107. package/src/monorepo/package-policy.test.ts +70 -51
  108. package/src/monorepo/package-policy.ts +104 -59
  109. package/src/monorepo/packed-manifest.ts +18 -52
  110. package/src/monorepo/packed-package.ts +43 -22
  111. package/src/monorepo/packs/index.test.ts +18 -1
  112. package/src/monorepo/packs/index.ts +20 -7
  113. package/src/monorepo/publish-workflow.ts +482 -3
  114. package/src/monorepo/runtime.test.ts +42 -3
  115. package/src/monorepo/runtime.ts +90 -12
  116. package/src/monorepo/tool-validation.ts +35 -25
  117. package/src/monorepo/wrangler.test.ts +11 -15
  118. package/src/monorepo/wrangler.ts +15 -15
  119. package/src/nx/index.test.ts +43 -0
  120. package/src/nx/index.ts +110 -23
  121. package/src/pr/index.ts +42 -36
  122. package/src/release/__tests__/candidates.test.ts +5 -4
  123. package/src/release/__tests__/fixture-repo.test.ts +18 -16
  124. package/src/release/__tests__/github-release.test.ts +11 -0
  125. package/src/release/__tests__/orchestration.test.ts +57 -2
  126. package/src/release/__tests__/trust-publisher.test.ts +98 -2
  127. package/src/release/candidates.ts +10 -17
  128. package/src/release/github-release.ts +12 -0
  129. package/src/release/index.ts +321 -112
  130. package/src/release/orchestration.ts +55 -3
  131. package/src/wrangler/prepare-env.ts +75 -54
  132. package/src/wrangler/scaffold.test.ts +9 -12
  133. package/src/wrangler/scaffold.ts +13 -13
package/src/nx/index.ts CHANGED
@@ -2,14 +2,23 @@ import { existsSync } from 'node:fs';
2
2
  import { lstat, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { $ } from 'bun';
5
- import { isRecord, recordProperty } from '../lib/json.js';
5
+ import {
6
+ type NxDependsOn,
7
+ type NxProjectJson,
8
+ type NxTargetConfig,
9
+ parseNxProjectJsonText,
10
+ parseStringArrayText,
11
+ } from '../lib/json.js';
6
12
  import { decode, run } from '../lib/run.js';
7
13
 
8
14
  export interface ProjectTargets {
9
15
  project: string;
16
+ root?: string;
10
17
  targets: string[];
11
18
  buildDependsOn?: string[];
19
+ targetDependencies?: Map<string, string[]>;
12
20
  targetExecutors?: Map<string, string>;
21
+ targetOutputs?: Map<string, string[]>;
13
22
  targetScripts?: Map<string, string>;
14
23
  }
15
24
 
@@ -30,52 +39,121 @@ export function nxCacheDirectories(root: string): string[] {
30
39
  return [join(root, '.nx/cache'), join(root, '.nx/workspace-data'), join(root, 'node_modules/.cache/nx')];
31
40
  }
32
41
 
33
- export function targetNamesFromNxProjectJson(value: unknown): string[] {
34
- const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
42
+ export function targetNamesFromNxProjectJson(value: NxProjectJson | null | undefined): string[] {
43
+ const targets = value?.targets;
35
44
  return targets ? Object.keys(targets).sort((a, b) => a.localeCompare(b)) : [];
36
45
  }
37
46
 
38
- export function buildDependsOnFromNxProjectJson(value: unknown): string[] | undefined {
39
- 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')) {
47
+ export function projectRootFromNxProjectJson(value: NxProjectJson | null | undefined): string | undefined {
48
+ return typeof value?.root === 'string' ? value.root : undefined;
49
+ }
50
+
51
+ export function buildDependsOnFromNxProjectJson(value: NxProjectJson | null | undefined): string[] | undefined {
52
+ return targetDependenciesFromNxProjectJson(value).get('build');
53
+ }
54
+
55
+ export function targetDependenciesFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, string[]> {
56
+ const targets = value?.targets;
57
+ const dependencies = new Map<string, string[]>();
58
+ if (!targets) {
59
+ return dependencies;
60
+ }
61
+ for (const [targetName, target] of Object.entries(targets)) {
62
+ if (!target.dependsOn) {
63
+ continue;
64
+ }
65
+ const entries: string[] = [];
66
+ for (const dependency of target.dependsOn) {
67
+ const local = localDependsOnTarget(dependency);
68
+ if (local === undefined) {
69
+ if (isInvalidDependsOn(dependency)) {
70
+ throw new Error(`Nx target ${targetName} has an invalid dependsOn entry.`);
71
+ }
72
+ continue;
73
+ }
74
+ entries.push(local);
75
+ }
76
+ dependencies.set(targetName, entries);
77
+ }
78
+ return dependencies;
79
+ }
80
+
81
+ function localDependsOnTarget(dependency: NxDependsOn): string | undefined {
82
+ if (typeof dependency === 'string') {
83
+ return dependency;
84
+ }
85
+ if (typeof dependency.target !== 'string') {
86
+ return undefined;
87
+ }
88
+ if (dependency.projects !== undefined && dependency.projects !== 'self') {
89
+ // Target closures are intentionally project-local. Nx schedules explicit
90
+ // cross-project prerequisites itself; treating them as local targets
91
+ // would collect or verify outputs from the wrong project.
42
92
  return undefined;
43
93
  }
44
- return build.dependsOn;
94
+ return dependency.target;
45
95
  }
46
96
 
47
- export function targetExecutorsFromNxProjectJson(value: unknown): Map<string, string> {
48
- const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
97
+ function isInvalidDependsOn(dependency: NxDependsOn): boolean {
98
+ return typeof dependency !== 'string' && typeof dependency.target !== 'string';
99
+ }
100
+
101
+ export function targetExecutorsFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, string> {
102
+ const targets = value?.targets;
49
103
  const executors = new Map<string, string>();
50
104
  if (!targets) {
51
105
  return executors;
52
106
  }
53
107
  for (const [targetName, target] of Object.entries(targets)) {
54
- if (isRecord(target) && typeof target.executor === 'string') {
108
+ if (typeof target.executor === 'string') {
55
109
  executors.set(targetName, target.executor);
56
110
  }
57
111
  }
58
112
  return executors;
59
113
  }
60
114
 
61
- export function targetScriptsFromNxProjectJson(value: unknown): Map<string, string> {
62
- const targets = isRecord(value) ? recordProperty(value, 'targets') : null;
115
+ export function targetOutputsFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, string[]> {
116
+ return targetStringArraysFromNxProjectJson(value, 'outputs');
117
+ }
118
+
119
+ export function targetScriptsFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, string> {
120
+ const targets = value?.targets;
63
121
  const scripts = new Map<string, string>();
64
122
  if (!targets) {
65
123
  return scripts;
66
124
  }
67
125
  for (const [targetName, target] of Object.entries(targets)) {
68
- if (!isRecord(target)) {
69
- continue;
70
- }
71
- const options = recordProperty(target, 'options');
72
- if (typeof options?.script === 'string') {
73
- scripts.set(targetName, options.script);
126
+ if (typeof target.options?.script === 'string') {
127
+ scripts.set(targetName, target.options.script);
74
128
  }
75
129
  }
76
130
  return scripts;
77
131
  }
78
132
 
133
+ function targetStringArraysFromNxProjectJson(
134
+ value: NxProjectJson | null | undefined,
135
+ property: 'outputs' | 'inputs',
136
+ ): Map<string, string[]> {
137
+ const targets = value?.targets;
138
+ const values = new Map<string, string[]>();
139
+ if (!targets) {
140
+ return values;
141
+ }
142
+ for (const [targetName, target] of Object.entries(targets)) {
143
+ const entries = target[property];
144
+ if (Array.isArray(entries)) {
145
+ const strings: string[] = [];
146
+ for (const entry of entries) {
147
+ if (typeof entry === 'string') {
148
+ strings.push(entry);
149
+ }
150
+ }
151
+ values.set(targetName, strings);
152
+ }
153
+ }
154
+ return values;
155
+ }
156
+
79
157
  export function formatProjectTargetLines(projects: ProjectTargets[]): string {
80
158
  return projects
81
159
  .flatMap((project) => project.targets.map((target) => `${project.project}:${target}`))
@@ -98,8 +176,8 @@ export function projectNamesFromNxShowProjectsOutput(output: string): string[] {
98
176
 
99
177
  if (trimmed.startsWith('[')) {
100
178
  try {
101
- const parsed: unknown = JSON.parse(trimmed);
102
- if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
179
+ const parsed = parseStringArrayText(trimmed);
180
+ if (parsed) {
103
181
  return parsed.sort((a, b) => a.localeCompare(b));
104
182
  }
105
183
  } catch {
@@ -164,12 +242,21 @@ async function readNxProjectNames(root: string): Promise<string[]> {
164
242
  async function readProjectTarget(root: string, project: string): Promise<ProjectTargets> {
165
243
  const command = nxShowProjectCommand(project);
166
244
  const result = await $`${command.command} ${command.args}`.cwd(root).quiet();
167
- const parsed: unknown = JSON.parse(decode(result.stdout));
245
+ const parsed = parseNxProjectJsonText(decode(result.stdout));
246
+ if (!parsed) {
247
+ throw new Error(`Unable to inspect Nx project ${project}.`);
248
+ }
249
+ const targetDependencies = targetDependenciesFromNxProjectJson(parsed);
168
250
  return {
169
251
  project,
252
+ root: projectRootFromNxProjectJson(parsed),
170
253
  targets: targetNamesFromNxProjectJson(parsed),
171
- buildDependsOn: buildDependsOnFromNxProjectJson(parsed),
254
+ buildDependsOn: targetDependencies.get('build'),
255
+ targetDependencies,
172
256
  targetExecutors: targetExecutorsFromNxProjectJson(parsed),
257
+ targetOutputs: targetOutputsFromNxProjectJson(parsed),
173
258
  targetScripts: targetScriptsFromNxProjectJson(parsed),
174
259
  };
175
260
  }
261
+
262
+ export type { NxProjectJson, NxTargetConfig };
package/src/pr/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
+ import typia from 'typia';
3
4
  import { formatMarkerHits, scanRefChangedForMarkers } from '../lib/conflict-markers.js';
4
- import { isRecord, readJson } from '../lib/json.js';
5
+ import { readJson } from '../lib/json.js';
5
6
  import { run, runResult } from '../lib/run.js';
6
7
 
7
8
  /** 0 = clean/done, 2 = action required (conflicts to resolve), 1 = usage/error. */
@@ -50,6 +51,29 @@ interface PrResolveState {
50
51
  startedAt: string;
51
52
  }
52
53
 
54
+ interface GhPrViewJson {
55
+ number: number;
56
+ url: string;
57
+ headRefName: string;
58
+ baseRefName: string;
59
+ isCrossRepository?: boolean;
60
+ }
61
+
62
+ interface PrResolveStateJson {
63
+ pr: number;
64
+ url?: string;
65
+ headBranch: string;
66
+ baseBranch: string;
67
+ remote: string;
68
+ crossRepo?: boolean;
69
+ originalBranch?: string;
70
+ originalSha?: string;
71
+ startedAt?: string;
72
+ }
73
+
74
+ const parseGhPrViewJson = typia.json.createIsParse<GhPrViewJson>();
75
+ const isPrResolveStateJson = typia.createIs<PrResolveStateJson>();
76
+
53
77
  /**
54
78
  * Agent-first conflict resolution for a GitHub PR.
55
79
  *
@@ -209,25 +233,16 @@ async function prMeta(shell: PrResolveShell, root: string, prArg: string): Promi
209
233
  if (result.exitCode !== 0) {
210
234
  throw new Error(`Could not resolve PR '${prArg}' via gh: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
211
235
  }
212
- const raw: unknown = JSON.parse(result.stdout);
213
- if (!isRecord(raw)) {
236
+ const raw = parseGhPrViewJson(result.stdout);
237
+ if (!raw) {
214
238
  throw new Error(`gh pr view returned unexpected JSON for '${prArg}'.`);
215
239
  }
216
- const { number, url, headRefName, baseRefName } = raw;
217
- if (
218
- typeof number !== 'number' ||
219
- typeof url !== 'string' ||
220
- typeof headRefName !== 'string' ||
221
- typeof baseRefName !== 'string'
222
- ) {
223
- throw new Error(`gh pr view JSON for '${prArg}' is missing expected fields.`);
224
- }
225
- const match = /github\.com\/([^/]+\/[^/]+)\/pull\//.exec(url);
240
+ const match = /github\.com\/([^/]+\/[^/]+)\/pull\//.exec(raw.url);
226
241
  return {
227
- number,
228
- url,
229
- headBranch: headRefName,
230
- baseBranch: baseRefName,
242
+ number: raw.number,
243
+ url: raw.url,
244
+ headBranch: raw.headRefName,
245
+ baseBranch: raw.baseRefName,
231
246
  crossRepo: raw.isCrossRepository === true,
232
247
  nameWithOwner: match ? match[1] : '',
233
248
  };
@@ -326,28 +341,19 @@ function readState(statePath: string): PrResolveState | null {
326
341
  } catch {
327
342
  return null;
328
343
  }
329
- if (!isRecord(raw)) {
330
- return null;
331
- }
332
- const { pr, url, headBranch, baseBranch, remote, crossRepo, originalBranch, originalSha, startedAt } = raw;
333
- if (
334
- typeof pr !== 'number' ||
335
- typeof headBranch !== 'string' ||
336
- typeof baseBranch !== 'string' ||
337
- typeof remote !== 'string'
338
- ) {
344
+ if (!isPrResolveStateJson(raw)) {
339
345
  return null;
340
346
  }
341
347
  return {
342
- pr,
343
- url: typeof url === 'string' ? url : '',
344
- headBranch,
345
- baseBranch,
346
- remote,
347
- crossRepo: crossRepo === true,
348
- originalBranch: typeof originalBranch === 'string' ? originalBranch : '',
349
- originalSha: typeof originalSha === 'string' ? originalSha : '',
350
- startedAt: typeof startedAt === 'string' ? startedAt : '',
348
+ pr: raw.pr,
349
+ url: raw.url ?? '',
350
+ headBranch: raw.headBranch,
351
+ baseBranch: raw.baseBranch,
352
+ remote: raw.remote,
353
+ crossRepo: raw.crossRepo === true,
354
+ originalBranch: raw.originalBranch ?? '',
355
+ originalSha: raw.originalSha ?? '',
356
+ startedAt: raw.startedAt ?? '',
351
357
  };
352
358
  }
353
359
 
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'bun:test';
2
2
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { $ } from 'bun';
5
- import { isRecord } from '../../lib/json.js';
5
+ import type { PackageJson } from '../../lib/json.js';
6
+ import { parsePackageJsonText } from '../../lib/json.js';
6
7
  import { type AutoReleaseCandidateShell, autoReleaseCandidatePackages } from '../candidates.js';
7
8
  import type { ReleasePackageInfo } from '../core.js';
8
9
  import { git, tag, withFixtureRepo, writePackage } from './helpers/fixture-repo.js';
@@ -309,9 +310,9 @@ function gitCandidateShell(
309
310
  };
310
311
  }
311
312
 
312
- function parseJsonObject(text: string): Record<string, unknown> {
313
- const parsed = JSON.parse(text);
314
- if (!isRecord(parsed)) {
313
+ function parseJsonObject(text: string): PackageJson {
314
+ const parsed = parsePackageJsonText(text);
315
+ if (!parsed) {
315
316
  throw new Error('expected JSON object');
316
317
  }
317
318
  return parsed;
@@ -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 }> = [];