@smoothbricks/cli 0.1.0 → 0.1.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 (92) hide show
  1. package/README.md +208 -83
  2. package/dist/cli.js +50 -20
  3. package/dist/lib/devenv.d.ts +6 -0
  4. package/dist/lib/devenv.d.ts.map +1 -0
  5. package/dist/lib/devenv.js +82 -0
  6. package/dist/lib/workspace.d.ts +5 -0
  7. package/dist/lib/workspace.d.ts.map +1 -1
  8. package/dist/lib/workspace.js +62 -5
  9. package/dist/monorepo/index.d.ts +6 -1
  10. package/dist/monorepo/index.d.ts.map +1 -1
  11. package/dist/monorepo/index.js +38 -5
  12. package/dist/monorepo/lockfile.d.ts +5 -1
  13. package/dist/monorepo/lockfile.d.ts.map +1 -1
  14. package/dist/monorepo/lockfile.js +45 -5
  15. package/dist/monorepo/managed-files.d.ts +1 -1
  16. package/dist/monorepo/managed-files.d.ts.map +1 -1
  17. package/dist/monorepo/managed-files.js +27 -10
  18. package/dist/monorepo/package-hygiene.js +1 -1
  19. package/dist/monorepo/package-policy.d.ts +3 -0
  20. package/dist/monorepo/package-policy.d.ts.map +1 -1
  21. package/dist/monorepo/package-policy.js +122 -19
  22. package/dist/monorepo/packed-manifest.d.ts +4 -0
  23. package/dist/monorepo/packed-manifest.d.ts.map +1 -0
  24. package/dist/monorepo/packed-manifest.js +51 -0
  25. package/dist/monorepo/packed-package.d.ts.map +1 -1
  26. package/dist/monorepo/packed-package.js +6 -0
  27. package/dist/monorepo/packs/index.d.ts.map +1 -1
  28. package/dist/monorepo/packs/index.js +4 -1
  29. package/dist/monorepo/publish-workflow.d.ts +86 -0
  30. package/dist/monorepo/publish-workflow.d.ts.map +1 -0
  31. package/dist/monorepo/publish-workflow.js +343 -0
  32. package/dist/nx-version-actions.cjs +25 -0
  33. package/dist/nx-version-actions.d.cts +6 -0
  34. package/dist/nx-version-actions.d.cts.map +1 -0
  35. package/dist/release/core.d.ts +50 -0
  36. package/dist/release/core.d.ts.map +1 -0
  37. package/dist/release/core.js +97 -0
  38. package/dist/release/github-release.d.ts +46 -0
  39. package/dist/release/github-release.d.ts.map +1 -0
  40. package/dist/release/github-release.js +97 -0
  41. package/dist/release/index.d.ts +14 -5
  42. package/dist/release/index.d.ts.map +1 -1
  43. package/dist/release/index.js +544 -109
  44. package/dist/release/npm-auth.d.ts +17 -0
  45. package/dist/release/npm-auth.d.ts.map +1 -0
  46. package/dist/release/npm-auth.js +51 -0
  47. package/dist/release/orchestration.d.ts +48 -0
  48. package/dist/release/orchestration.d.ts.map +1 -0
  49. package/dist/release/orchestration.js +105 -0
  50. package/dist/release/publish-plan.d.ts +17 -0
  51. package/dist/release/publish-plan.d.ts.map +1 -0
  52. package/dist/release/publish-plan.js +15 -0
  53. package/dist/release/retag-unpublished.d.ts +34 -0
  54. package/dist/release/retag-unpublished.d.ts.map +1 -0
  55. package/dist/release/retag-unpublished.js +77 -0
  56. package/managed/raw/tooling/git-hooks/pre-commit.sh +2 -7
  57. package/managed/templates/github/workflows/ci.yml +2 -0
  58. package/package.json +24 -3
  59. package/src/cli.ts +64 -25
  60. package/src/lib/devenv.test.ts +28 -0
  61. package/src/lib/devenv.ts +89 -0
  62. package/src/lib/workspace.ts +76 -5
  63. package/src/monorepo/__tests__/nx-version-actions.test.ts +75 -0
  64. package/src/monorepo/__tests__/publish-workflow.test.ts +315 -0
  65. package/src/monorepo/index.ts +45 -5
  66. package/src/monorepo/lockfile.ts +52 -7
  67. package/src/monorepo/managed-files.ts +42 -12
  68. package/src/monorepo/package-hygiene.ts +1 -1
  69. package/src/monorepo/package-policy.ts +134 -18
  70. package/src/monorepo/packed-manifest.ts +67 -0
  71. package/src/monorepo/packed-package.ts +7 -0
  72. package/src/monorepo/packs/index.ts +4 -0
  73. package/src/monorepo/publish-workflow.ts +430 -0
  74. package/src/nx-version-actions.cts +36 -0
  75. package/src/release/__tests__/core-properties.test.ts +149 -0
  76. package/src/release/__tests__/core-scenarios.test.ts +177 -0
  77. package/src/release/__tests__/core.test.ts +73 -0
  78. package/src/release/__tests__/fixture-repo.test.ts +305 -0
  79. package/src/release/__tests__/github-release.test.ts +144 -0
  80. package/src/release/__tests__/helpers/fixture-repo.ts +113 -0
  81. package/src/release/__tests__/npm-auth.test.ts +129 -0
  82. package/src/release/__tests__/orchestration.test.ts +202 -0
  83. package/src/release/__tests__/publish-plan.test.ts +61 -0
  84. package/src/release/__tests__/retag-unpublished.test.ts +160 -0
  85. package/src/release/core.ts +170 -0
  86. package/src/release/github-release.ts +134 -0
  87. package/src/release/index.ts +662 -116
  88. package/src/release/npm-auth.ts +122 -0
  89. package/src/release/orchestration.ts +186 -0
  90. package/src/release/publish-plan.ts +37 -0
  91. package/src/release/retag-unpublished.ts +122 -0
  92. package/managed/templates/github/workflows/publish.yml +0 -136
@@ -1,26 +1,47 @@
1
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
1
+ import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { Writable } from 'node:stream';
6
6
  import { $ } from 'bun';
7
+ import { withDevenvEnv } from '../lib/devenv.js';
8
+ import { isRecord, stringProperty } from '../lib/json.js';
7
9
  import { decode, run, runStatus } from '../lib/run.js';
8
- import { listPublicPackages, readPackageJson, repositoryInfo } from '../lib/workspace.js';
10
+ import { listReleasePackages, readPackageJson, repositoryInfo } from '../lib/workspace.js';
11
+ import { readPackedPackageJson, validatePackedWorkspaceDependencies } from '../monorepo/packed-manifest.js';
12
+ import {
13
+ type ReleaseTagRecord as CoreReleaseTagRecord,
14
+ type ReleaseTarget as CoreReleaseTarget,
15
+ collectOwnedReleaseTagRecords,
16
+ type GitReleaseTagInfo,
17
+ pendingReleaseTargets,
18
+ releaseTag,
19
+ } from './core.js';
20
+ import { createOrUpdateGithubRelease, renderNxProjectChangelogContents } from './github-release.js';
21
+ import { publishWithAuthDiagnostics } from './npm-auth.js';
22
+ import {
23
+ completeReleaseAtHead as completeReleaseAtHeadWithShell,
24
+ type ReleaseCompletionShell,
25
+ type ReleaseSummary,
26
+ type ReleaseVersionMode,
27
+ repairPendingTargets,
28
+ runReleaseVersion,
29
+ } from './orchestration.js';
30
+ import { type RetagUnpublishedTagUpdate, retagUnpublished } from './retag-unpublished.js';
9
31
 
10
32
  export interface ReleaseVersionOptions {
11
33
  bump: string;
12
34
  dryRun?: boolean;
35
+ githubOutput?: string;
13
36
  }
14
37
 
15
38
  export interface ReleasePublishOptions {
16
39
  bump: string;
17
- tag?: string;
18
- npmTag?: string;
19
40
  dryRun?: boolean;
20
41
  }
21
42
 
22
- export interface ReleaseGithubOptions extends ReleasePublishOptions {
23
- tags?: string;
43
+ export interface ReleaseRepairPendingOptions {
44
+ dryRun?: boolean;
24
45
  }
25
46
 
26
47
  export interface ReleaseTrustPublisherOptions {
@@ -29,90 +50,98 @@ export interface ReleaseTrustPublisherOptions {
29
50
  skipLogin?: boolean;
30
51
  }
31
52
 
53
+ export interface ReleaseRetagUnpublishedOptions {
54
+ tags: string[];
55
+ to?: string;
56
+ push?: boolean;
57
+ dispatch?: boolean;
58
+ dryRun?: boolean;
59
+ remote?: string;
60
+ branch?: string;
61
+ }
62
+
32
63
  export async function releaseVersion(root: string, options: ReleaseVersionOptions): Promise<void> {
33
64
  const bump = releaseBumpArg(options.bump);
34
- const projects = listPublicPackages(root)
35
- .map((pkg) => pkg.name)
36
- .join(',');
37
- const state = await getReleaseState(root);
38
- if (state.allPublished) {
39
- console.log('Current package versions are already published; skipping version bump.');
40
- return;
41
- }
42
-
43
- const headBeforeVersioning = await gitHead(root);
44
-
45
- // Nx owns local release mutation: package versions, bun.lock updates, the
46
- // release commit, and annotated tags. smoo owns the remote push so retries do
47
- // not push an older checkout when Nx skips the release commit.
48
- const nxArgs = ['release', 'version'];
49
- if (bump !== 'auto') {
50
- nxArgs.push(bump);
51
- }
52
- nxArgs.push(`--projects=${projects}`, '--git-commit=true', '--git-tag=true', '--git-push=false');
53
- if (options.dryRun) {
54
- nxArgs.push('--dryRun');
55
- }
56
- await run('nx', nxArgs, root);
57
- if (!options.dryRun) {
58
- // Guard against future Nx/config regressions that leave release files, such
59
- // as bun.lock, outside the release commit after tagging/pushing.
60
- await assertCleanGitTree(root);
61
- const headAfterVersioning = await gitHead(root);
62
- if (headAfterVersioning === headBeforeVersioning) {
63
- console.log('Nx did not create a release commit; skipping git push.');
64
- return;
65
- }
66
- await pushReleaseCommit(root);
65
+ const packages = releasePackages(root);
66
+ const projects = releasePackageProjects(packages);
67
+ const result = await runReleaseVersion(
68
+ {
69
+ releasePackagesAtHead: () => releasePackagesAtHead(root, packages),
70
+ ensureLocalReleaseTags: (releasePackages) => ensureLocalReleaseTags(root, releasePackages),
71
+ gitHead: () => gitHead(root),
72
+ runNxReleaseVersion: (releaseBump, dryRun) => runNxReleaseVersion(root, projects, releaseBump, dryRun),
73
+ assertCleanGitTree: () => assertCleanGitTree(root),
74
+ },
75
+ { bump, dryRun: options.dryRun === true },
76
+ );
77
+ if (result.status === 'already-release-target') {
78
+ console.log('HEAD is already a release target; publish will complete any missing durable state.');
79
+ } else if (result.status === 'no-release-needed') {
80
+ console.log('Nx did not create a release commit; no release needed.');
67
81
  }
82
+ await writeReleaseGithubOutput(options.githubOutput, result.packages, result.mode);
68
83
  }
69
84
 
70
85
  export async function releasePublish(root: string, options: ReleasePublishOptions): Promise<void> {
71
- const tag = releaseNpmTagArg(options);
72
- const unpublishedPackages = await listUnpublishedPackages(root);
73
- if (unpublishedPackages.length === 0) {
74
- console.log('Current package versions are already published; skipping npm publish.');
75
- return;
76
- }
77
- // npm package versions are immutable. Publishing only missing name@version
78
- // pairs makes auth/network retries idempotent after a partial publish failure.
79
- for (const pkg of unpublishedPackages) {
80
- await publishPackedPackage(root, pkg, tag, options.dryRun === true);
86
+ const bump = releaseBumpArg(options.bump);
87
+ const packages = await releasePackagesAtHead(root, releasePackages(root));
88
+ if (packages.length === 0) {
89
+ if (bump === 'auto') {
90
+ console.log('No release tags found at HEAD; no release to publish.');
91
+ const summary: ReleaseSummary<ReleasePackage> = {
92
+ sha: await gitHead(root),
93
+ dryRun: options.dryRun === true,
94
+ packages,
95
+ pushed: false,
96
+ published: [],
97
+ alreadyPublished: [],
98
+ githubReleases: [],
99
+ rerunRequired: false,
100
+ noRelease: true,
101
+ };
102
+ await writeReleaseSummary(summary);
103
+ return;
104
+ }
105
+ throw new Error('No release tags found at HEAD for current package versions. Run smoo release version first.');
81
106
  }
107
+ const summary = await completeReleaseAtHeadWithShell(
108
+ releaseCompletionShell(root),
109
+ packages,
110
+ options.dryRun === true,
111
+ await newerCommitsRemain(root),
112
+ );
113
+ await writeReleaseSummary(summary);
82
114
  }
83
115
 
84
- export async function releaseGithubRelease(root: string, options: ReleaseGithubOptions): Promise<void> {
85
- if (options.dryRun) {
86
- console.log('Dry run; skipping GitHub Release creation.');
87
- return;
88
- }
89
- const npmTag = releaseNpmTagArg(options);
90
- const tags = options.tags ? options.tags.split(/\s+/).filter(Boolean) : await gitTagsAtHead(root);
91
- if (tags.length === 0) {
92
- throw new Error('No release tags found. Pass --tags or run from a tagged release commit.');
93
- }
94
- const latestFlag = npmTag === 'latest' ? 'true' : 'false';
95
- for (const tag of tags) {
96
- const exists = (await runStatus('gh', ['release', 'view', tag], root, true)) === 0;
97
- if (exists) {
98
- await run('gh', ['release', 'edit', tag, '--title', tag, `--latest=${latestFlag}`], root);
99
- } else {
100
- await run('gh', ['release', 'create', tag, '--title', tag, '--generate-notes', `--latest=${latestFlag}`], root);
101
- }
102
- }
116
+ export async function releaseRepairPending(root: string, options: ReleaseRepairPendingOptions): Promise<void> {
117
+ const branch = await releaseBranch(root);
118
+ const remote = await releaseRemote(root, branch);
119
+ console.log(`Repair pending releases: fetching ${remote}/${branch} and tags.`);
120
+ await fetchReleaseRefs(root, remote, branch);
121
+ const remoteRef = `${remote}/${branch}`;
122
+ const restoreRef = (await gitRefExists(root, remoteRef)) ? remoteRef : await gitHead(root);
123
+ console.log(`Repair pending releases: planning from ${restoreRef}.`);
124
+ const targets = await listPendingReleaseTargets(root, restoreRef);
125
+ console.log(
126
+ targets.length === 0
127
+ ? 'Repair pending releases: no pending durable state repairs found.'
128
+ : `Repair pending releases: ${targets.length} release target${targets.length === 1 ? '' : 's'} need repair.`,
129
+ );
130
+ const summaries = await repairPendingTargets(releaseRepairShell(root), targets, restoreRef, options.dryRun === true);
131
+ await writeRepairSummary(summaries, options.dryRun === true);
103
132
  }
104
133
 
105
134
  export async function releaseTrustPublisher(root: string, options: ReleaseTrustPublisherOptions): Promise<void> {
106
135
  const repository = githubRepositoryFromRootPackage(root);
107
136
  const workflow = 'publish.yml';
108
- const packages = listPublicPackages(root);
137
+ const packages = listReleasePackages(root);
109
138
  if (packages.length === 0) {
110
- throw new Error('No npm:public packages found.');
139
+ throw new Error('No owned release packages found.');
111
140
  }
112
141
 
113
142
  if (!options.dryRun) {
114
143
  const packageStates = await Promise.all(
115
- packages.map(async (pkg) => ((await npmPackageExists(pkg.name)) ? null : pkg.name)),
144
+ packages.map(async (pkg) => ((await npmPackageExists(root, pkg.name)) ? null : pkg.name)),
116
145
  );
117
146
  const missingPackages = packageStates.filter((name): name is string => name !== null);
118
147
  if (missingPackages.length > 0) {
@@ -128,17 +157,57 @@ export async function releaseTrustPublisher(root: string, options: ReleaseTrustP
128
157
  await runLatestNpm(root, ['login', '--auth-type=web']);
129
158
  }
130
159
 
160
+ const failedPackages: string[] = [];
131
161
  for (const pkg of packages) {
132
162
  console.log(`${pkg.name}: trusting GitHub Actions ${repository}/${workflow}`);
133
163
  const args = ['trust', 'github', pkg.name, '--file', workflow, '--repo', repository, '--yes'];
134
164
  if (options.dryRun) {
135
165
  args.push('--dry-run');
136
166
  }
137
- const otp = options.dryRun ? undefined : (options.otp ?? (await promptForNpmOtp(pkg.name)));
138
- await runLatestNpm(root, args, otp ? { NPM_CONFIG_OTP: otp } : undefined);
167
+ while (true) {
168
+ const otp = options.dryRun ? undefined : (options.otp ?? (await promptForNpmOtp(pkg.name)));
169
+ try {
170
+ await runLatestNpm(root, args, otp ? { NPM_CONFIG_OTP: otp } : undefined);
171
+ break;
172
+ } catch (error) {
173
+ console.error(`${pkg.name}: npm trusted publisher setup failed.`);
174
+ console.error(error instanceof Error ? error.message : String(error));
175
+ if (!options.dryRun) {
176
+ console.error('npm OTP codes are single-use. Generate a fresh OTP before retrying this package.');
177
+ }
178
+ if (options.otp || options.dryRun) {
179
+ failedPackages.push(pkg.name);
180
+ break;
181
+ }
182
+ console.error('Retrying this package. Press Ctrl-C to stop.');
183
+ }
184
+ }
185
+ }
186
+ if (failedPackages.length > 0) {
187
+ console.error(`Trusted publishing was not configured for: ${failedPackages.join(', ')}`);
188
+ if (options.otp) {
189
+ console.error('Rerun smoo release trust-publisher without --otp to enter a fresh OTP for each failed package.');
190
+ }
139
191
  }
140
192
  }
141
193
 
194
+ export async function releaseRetagUnpublished(root: string, options: ReleaseRetagUnpublishedOptions): Promise<void> {
195
+ const toRef = options.to ?? 'HEAD';
196
+ const dispatch = options.dispatch === true;
197
+ const push = options.push === true || dispatch;
198
+ const branch = options.branch ?? (await releaseBranch(root));
199
+ const remote = options.remote ?? (push ? await releaseRemote(root, branch) : 'origin');
200
+ await retagUnpublished(releaseRetagShell(root, remote), {
201
+ tags: options.tags,
202
+ toRef,
203
+ push,
204
+ dispatch,
205
+ dryRun: options.dryRun === true,
206
+ branch,
207
+ workflow: 'publish.yml',
208
+ });
209
+ }
210
+
142
211
  export async function printReleaseState(root: string): Promise<void> {
143
212
  console.log(JSON.stringify(await getReleaseState(root), null, 2));
144
213
  }
@@ -148,34 +217,45 @@ interface ReleaseState {
148
217
  allPublished: boolean;
149
218
  }
150
219
 
151
- type PublicPackage = ReturnType<typeof listPublicPackages>[number];
220
+ type ReleasePackage = ReturnType<typeof listReleasePackages>[number];
221
+
222
+ type ReleaseTarget = CoreReleaseTarget<ReleasePackage>;
223
+ type ReleaseTagRecord = CoreReleaseTagRecord<ReleasePackage>;
152
224
 
153
225
  async function getReleaseState(root: string): Promise<ReleaseState> {
154
- const packages = listPublicPackages(root);
226
+ return getReleaseStateForPackages(root, listReleasePackages(root));
227
+ }
228
+
229
+ async function getReleaseStateForPackages(root: string, packages: ReleasePackage[]): Promise<ReleaseState> {
155
230
  const states = await Promise.all(
156
231
  packages.map(async (pkg) => {
157
- const published = await npmVersionExists(pkg.name, pkg.version);
232
+ const published = await npmVersionExists(root, pkg.name, pkg.version);
158
233
  return { name: pkg.name, version: pkg.version, published };
159
234
  }),
160
235
  );
161
236
  return { packages: states, allPublished: states.every((state) => state.published) };
162
237
  }
163
238
 
164
- async function listUnpublishedPackages(root: string): Promise<PublicPackage[]> {
165
- const packages = listPublicPackages(root);
239
+ async function listUnpublishedPackages(root: string, packages: ReleasePackage[]): Promise<ReleasePackage[]> {
166
240
  const states = await Promise.all(
167
- packages.map(async (pkg) => ({ pkg, published: await npmVersionExists(pkg.name, pkg.version) })),
241
+ packages.map(async (pkg) => ({ pkg, published: await npmVersionExists(root, pkg.name, pkg.version) })),
168
242
  );
169
243
  return states.filter((state) => !state.published).map((state) => state.pkg);
170
244
  }
171
245
 
172
- async function publishPackedPackage(root: string, pkg: PublicPackage, tag: string, dryRun: boolean): Promise<void> {
246
+ async function publishPackedPackage(
247
+ root: string,
248
+ pkg: ReleasePackage,
249
+ tag: string,
250
+ dryRun: boolean,
251
+ useBootstrapToken: boolean,
252
+ ): Promise<void> {
173
253
  const tempDir = await mkdtemp(join(tmpdir(), 'smoo-publish-'));
174
254
  const tarball = join(tempDir, `${safeTarballPrefix(pkg.name)}-${pkg.version}.tgz`);
175
255
  try {
176
256
  console.log(`${pkg.name}@${pkg.version}: packing with bun pm pack`);
177
257
  await run('bun', ['pm', 'pack', '--filename', tarball, '--ignore-scripts', '--quiet'], join(root, pkg.path));
178
- await assertNoWorkspaceProtocolInTarball(root, tarball, pkg);
258
+ await assertPackedWorkspaceDependencies(root, tarball, pkg);
179
259
  // npm CLI owns authentication here: trusted publishing OIDC when configured,
180
260
  // or the temporary NODE_AUTH_TOKEN bootstrap path below before trust exists.
181
261
  // Bun still produces the tarball so workspace:* dependencies are resolved the
@@ -184,39 +264,146 @@ async function publishPackedPackage(root: string, pkg: PublicPackage, tag: strin
184
264
  if (dryRun) {
185
265
  args.push('--dry-run');
186
266
  }
187
- await runLatestNpmPublish(root, args);
267
+ await publishWithAuthDiagnostics(
268
+ pkg,
269
+ {
270
+ publish: () => runLatestNpmPublish(root, args, useBootstrapToken),
271
+ versionExists: () => npmVersionExists(root, pkg.name, pkg.version),
272
+ log: (message) => console.log(message),
273
+ error: (message) => console.error(message),
274
+ appendSummary: async (markdown) => {
275
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
276
+ if (summaryPath) {
277
+ await appendFile(summaryPath, `${markdown}\n\n`);
278
+ }
279
+ },
280
+ },
281
+ {
282
+ useBootstrapToken,
283
+ tokenPresent: npmAuthTokenPresent(),
284
+ repository: githubRepositoryFromRootPackage(root),
285
+ },
286
+ );
188
287
  } finally {
189
288
  await rm(tempDir, { recursive: true, force: true });
190
289
  }
191
290
  }
192
291
 
292
+ function npmAuthTokenPresent(): boolean {
293
+ return Boolean(process.env.NODE_AUTH_TOKEN || process.env.NPM_TOKEN || process.env.NPM_CONFIG_USERCONFIG);
294
+ }
295
+
193
296
  function safeTarballPrefix(name: string): string {
194
297
  return name.replace(/^@/, '').replace(/[^a-zA-Z0-9._-]+/g, '-');
195
298
  }
196
299
 
197
- async function assertNoWorkspaceProtocolInTarball(root: string, tarball: string, pkg: PublicPackage): Promise<void> {
198
- const result = await $`tar -xOf ${tarball} package/package.json`.cwd(root).quiet().nothrow();
300
+ async function assertPackedWorkspaceDependencies(root: string, tarball: string, pkg: ReleasePackage): Promise<void> {
301
+ const manifest = await readPackedPackageJson(root, tarball, pkg.name);
302
+ const failures = validatePackedWorkspaceDependencies(root, pkg, manifest);
303
+ if (failures.length > 0) {
304
+ throw new Error(failures.join('\n'));
305
+ }
306
+ }
307
+
308
+ function releasePackages(root: string): ReleasePackage[] {
309
+ const packages = listReleasePackages(root);
310
+ if (packages.length === 0) {
311
+ throw new Error('No owned release packages found.');
312
+ }
313
+ return packages;
314
+ }
315
+
316
+ function releasePackageProjects(packages: ReleasePackage[]): string {
317
+ return packages.map((pkg) => pkg.name).join(',');
318
+ }
319
+
320
+ async function runNxReleaseVersion(root: string, projects: string, bump: string, dryRun: boolean): Promise<void> {
321
+ // Nx owns local release mutation: package versions, bun.lock updates, the
322
+ // release commit, and annotated tags. smoo owns remote publication after the
323
+ // workflow validates the exact release commit Nx produced.
324
+ const nxArgs = ['release', 'version'];
325
+ if (bump !== 'auto') {
326
+ nxArgs.push(bump);
327
+ }
328
+ nxArgs.push(`--projects=${projects}`, '--git-commit=true', '--git-tag=true', '--git-push=false');
329
+ if (dryRun) {
330
+ nxArgs.push('--dry-run');
331
+ }
332
+ await run('nx', nxArgs, root);
333
+ }
334
+
335
+ async function releasePackagesAtHead(root: string, packages: ReleasePackage[]): Promise<ReleasePackage[]> {
336
+ return releasePackagesAtRef(root, packages, 'HEAD');
337
+ }
338
+
339
+ async function writeReleaseGithubOutput(
340
+ outputPath: string | undefined,
341
+ packages: ReleasePackage[],
342
+ mode: ReleaseVersionMode,
343
+ ): Promise<void> {
344
+ if (!outputPath) {
345
+ return;
346
+ }
347
+ await appendFile(outputPath, `mode=${mode}\nprojects=${releasePackageProjects(packages)}\n`);
348
+ }
349
+
350
+ async function releasePackagesAtRef(root: string, packages: ReleasePackage[], ref: string): Promise<ReleasePackage[]> {
351
+ const packagesAtRef = await Promise.all(packages.map(async (pkg) => releasePackageAtRef(root, pkg, ref)));
352
+ const presentPackages = packagesAtRef.filter((pkg): pkg is ReleasePackage => pkg !== null);
353
+ const tags = new Set(await gitTagsAtRef(root, ref));
354
+ const taggedPackages = presentPackages.filter((pkg) => tags.has(releaseTag(pkg)));
355
+ if (taggedPackages.length > 0) {
356
+ return taggedPackages;
357
+ }
358
+ if (!(await isNxReleaseCommit(root, ref))) {
359
+ return [];
360
+ }
361
+ return releasePackagesChangedAtRef(root, presentPackages, ref);
362
+ }
363
+
364
+ async function releasePackageAtRef(root: string, pkg: ReleasePackage, ref: string): Promise<ReleasePackage | null> {
365
+ const version = await packageVersionAtRef(root, pkg.path, ref);
366
+ return version ? { ...pkg, version } : null;
367
+ }
368
+
369
+ async function packageVersionAtRef(root: string, packagePath: string, ref: string): Promise<string | null> {
370
+ const result = await $`git show ${`${ref}:${packagePath}/package.json`}`.cwd(root).quiet().nothrow();
199
371
  if (result.exitCode !== 0) {
200
- throw new Error(`${pkg.name}: unable to inspect packed package.json before publish.`);
372
+ return null;
201
373
  }
202
- const manifest = decode(result.stdout);
203
- if (manifest.includes('workspace:')) {
204
- throw new Error(`${pkg.name}: packed package.json still contains workspace: dependency references.`);
374
+ try {
375
+ const parsed = JSON.parse(decode(result.stdout));
376
+ return isRecord(parsed) ? stringProperty(parsed, 'version') : null;
377
+ } catch {
378
+ return null;
205
379
  }
206
380
  }
207
381
 
208
- async function npmVersionExists(name: string, version: string): Promise<boolean> {
209
- const result = await $`bun pm view ${`${name}@${version}`} version`.cwd(process.cwd()).quiet().nothrow();
210
- return result.exitCode === 0 && decode(result.stdout).trim() === version;
382
+ async function isNxReleaseCommit(root: string, ref: string): Promise<boolean> {
383
+ const result = await $`git show -s --format=%s ${ref}`.cwd(root).quiet().nothrow();
384
+ return result.exitCode === 0 && decode(result.stdout).trim() === 'chore(release): publish';
211
385
  }
212
386
 
213
- async function npmPackageExists(name: string): Promise<boolean> {
214
- const result = await $`bun pm view ${name} name`.cwd(process.cwd()).quiet().nothrow();
215
- return result.exitCode === 0;
387
+ async function releasePackagesChangedAtRef(
388
+ root: string,
389
+ packages: ReleasePackage[],
390
+ ref: string,
391
+ ): Promise<ReleasePackage[]> {
392
+ if (!(await gitRefExists(root, `${ref}^`))) {
393
+ return packages;
394
+ }
395
+ const changed: ReleasePackage[] = [];
396
+ for (const pkg of packages) {
397
+ const previousVersion = await packageVersionAtRef(root, pkg.path, `${ref}^`);
398
+ if (previousVersion !== pkg.version) {
399
+ changed.push(pkg);
400
+ }
401
+ }
402
+ return changed;
216
403
  }
217
404
 
218
- async function gitTagsAtHead(root: string): Promise<string[]> {
219
- const result = await $`git tag --points-at HEAD`.cwd(root).quiet().nothrow();
405
+ async function gitTagsAtRef(root: string, ref: string): Promise<string[]> {
406
+ const result = await $`git tag --points-at ${ref}`.cwd(root).quiet().nothrow();
220
407
  if (result.exitCode !== 0) {
221
408
  return [];
222
409
  }
@@ -226,6 +413,375 @@ async function gitTagsAtHead(root: string): Promise<string[]> {
226
413
  .filter(Boolean);
227
414
  }
228
415
 
416
+ async function previousReleaseTag(root: string, pkg: ReleasePackage, currentTag: string): Promise<string | null> {
417
+ const result = await $`git tag --list ${`${pkg.name}@*`} --sort=-v:refname --merged ${currentTag}`
418
+ .cwd(root)
419
+ .quiet()
420
+ .nothrow();
421
+ if (result.exitCode !== 0) {
422
+ throw new Error(`Unable to list release tags for ${pkg.name}.`);
423
+ }
424
+ return (
425
+ decode(result.stdout)
426
+ .split('\n')
427
+ .map((tag) => tag.trim())
428
+ .find((tag) => tag && tag !== currentTag) ?? null
429
+ );
430
+ }
431
+
432
+ async function ensureLocalReleaseTags(root: string, packages: ReleasePackage[]): Promise<void> {
433
+ for (const pkg of packages) {
434
+ const tag = releaseTag(pkg);
435
+ if (await gitRefExists(root, `refs/tags/${tag}`)) {
436
+ if (!(await gitTagPointsAt(root, tag, 'HEAD'))) {
437
+ throw new Error(`Release tag ${tag} exists but does not point at HEAD.`);
438
+ }
439
+ continue;
440
+ }
441
+ await run('git', ['tag', '-a', tag, '-m', tag, 'HEAD'], root);
442
+ }
443
+ }
444
+
445
+ async function pushReleaseRefs(root: string, packages: ReleasePackage[]): Promise<boolean> {
446
+ const branch = await releaseBranch(root);
447
+ const remote = await releaseRemote(root, branch);
448
+ await fetchReleaseRefs(root, remote, branch);
449
+ await ensureLocalReleaseTags(root, packages);
450
+ const refspecs: string[] = [];
451
+ const remoteRef = `${remote}/${branch}`;
452
+ const head = await gitHead(root);
453
+ if (!(await gitRefExists(root, remoteRef)) || !(await gitIsAncestor(root, head, remoteRef))) {
454
+ refspecs.push(`HEAD:refs/heads/${branch}`);
455
+ }
456
+ for (const pkg of packages) {
457
+ const tag = releaseTag(pkg);
458
+ if (!(await remoteReleaseTagExists(root, remote, tag))) {
459
+ refspecs.push(`refs/tags/${tag}:refs/tags/${tag}`);
460
+ }
461
+ }
462
+ if (refspecs.length === 0) {
463
+ console.log('Release branch and tags are already present on the remote.');
464
+ return false;
465
+ }
466
+ await run('git', ['push', '--atomic', remote, ...refspecs], root);
467
+ return true;
468
+ }
469
+
470
+ function releaseCompletionShell(root: string): ReleaseCompletionShell<ReleasePackage> {
471
+ return {
472
+ gitHead: () => gitHead(root),
473
+ pushReleaseRefs: (packages) => pushReleaseRefs(root, packages),
474
+ listNpmMissingPackages: (packages) => listUnpublishedPackages(root, packages),
475
+ buildReleaseCandidate: (packages) => buildReleaseCandidate(root, packages),
476
+ publishPackage: async (pkg, distTag, dryRun) => {
477
+ const packageExists = dryRun ? true : await npmPackageExists(root, pkg.name);
478
+ await publishPackedPackage(root, pkg, distTag, dryRun, !dryRun && !packageExists);
479
+ },
480
+ listGithubMissingPackages: (packages) => listMissingGithubReleasePackages(root, packages),
481
+ createGithubRelease: (pkg, dryRun) => createGithubRelease(root, pkg, dryRun),
482
+ };
483
+ }
484
+
485
+ function releaseRepairShell(root: string): ReleaseCompletionShell<ReleasePackage> & {
486
+ checkout(ref: string): Promise<void>;
487
+ withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T>;
488
+ beforeRepairTarget(target: ReleaseTarget): void;
489
+ afterRepairTarget(target: ReleaseTarget): void;
490
+ } {
491
+ return {
492
+ ...releaseCompletionShell(root),
493
+ checkout: (ref) => run('git', ['switch', '--detach', ref], root),
494
+ withDevenvEnv: (runWithEnv) => withDevenvEnv(root, runWithEnv),
495
+ beforeRepairTarget: (target) => {
496
+ console.log(`::group::Repair pending release ${target.sha.slice(0, 12)} (${packageSummary(target.packages)})`);
497
+ },
498
+ afterRepairTarget: () => {
499
+ console.log('::endgroup::');
500
+ },
501
+ };
502
+ }
503
+
504
+ function releaseRetagShell(root: string, remote: string) {
505
+ return {
506
+ listReleasePackages: () => releasePackages(root),
507
+ resolveRef: (ref: string) => gitSha(root, ref),
508
+ resolveDispatchRef: (branch: string) => remoteBranchSha(root, remote, branch),
509
+ packageVersionAtRef: (packagePath: string, ref: string) => packageVersionAtRef(root, packagePath, ref),
510
+ npmVersionExists: (name: string, version: string) => npmVersionExists(root, name, version),
511
+ githubReleaseExists: (tag: string) => githubReleaseExists(root, tag),
512
+ remoteTagObject: (tag: string) => remoteReleaseTagObject(root, remote, tag),
513
+ createOrMoveTag: (tag: string, ref: string) => run('git', ['tag', '-fa', tag, '-m', tag, ref], root),
514
+ pushTags: (updates: RetagUnpublishedTagUpdate<ReleasePackage>[]) => pushRetaggedReleaseTags(root, remote, updates),
515
+ dispatchPublishWorkflow: (workflow: string, branch: string) =>
516
+ run('gh', ['workflow', 'run', workflow, '--ref', branch, '-f', 'bump=auto', '-f', 'dry_run=false'], root),
517
+ log: (message: string) => console.log(message),
518
+ };
519
+ }
520
+
521
+ async function listPendingReleaseTargets(root: string, ref: string): Promise<ReleaseTarget[]> {
522
+ const head = await gitHead(root);
523
+ return pendingReleaseTargets(await listOwnedReleaseTagRecords(root, ref), head);
524
+ }
525
+
526
+ async function listOwnedReleaseTagRecords(root: string, ref: string): Promise<ReleaseTagRecord[]> {
527
+ const tags = await gitReleaseTagsByCreatorDate(root);
528
+ console.log(`Repair pending releases: scanning ${tags.length} local tag${tags.length === 1 ? '' : 's'}.`);
529
+ return collectOwnedReleaseTagRecords(releasePackages(root), ref, {
530
+ listReleaseTagsByCreatorDate: async () => tags,
531
+ isAncestor: (ancestor, descendant) => gitIsAncestor(root, ancestor, descendant),
532
+ packageVersionAtRef: (packagePath, tagRef) => packageVersionAtRef(root, packagePath, tagRef),
533
+ durableTagState: (pkg, tag) => durableReleaseTagState(root, pkg, tag),
534
+ });
535
+ }
536
+
537
+ async function durableReleaseTagState(root: string, pkg: Pick<ReleasePackage, 'name' | 'version'>, tag: string) {
538
+ const packageVersion = `${pkg.name}@${pkg.version}`;
539
+ const start = Date.now();
540
+ console.log(`${packageVersion}: checking durable state (npm + GitHub Release ${tag}).`);
541
+ const [npmPublished, githubReleasePresent] = await Promise.all([
542
+ npmVersionExists(root, pkg.name, pkg.version),
543
+ githubReleaseExists(root, tag),
544
+ ]);
545
+ console.log(
546
+ `${packageVersion}: durable state npm=${yesNo(npmPublished)} github=${yesNo(githubReleasePresent)} (${Date.now() - start}ms).`,
547
+ );
548
+ return { npmPublished, githubReleaseExists: githubReleasePresent };
549
+ }
550
+
551
+ async function gitReleaseTagsByCreatorDate(root: string): Promise<GitReleaseTagInfo[]> {
552
+ const result =
553
+ await $`git for-each-ref --sort=-creatordate --format=${'%(refname:short)%09%(creatordate:unix)%09%(*objectname)%09%(objectname)'} refs/tags`
554
+ .cwd(root)
555
+ .quiet()
556
+ .nothrow();
557
+ if (result.exitCode !== 0) {
558
+ throw new Error('Unable to list release tags by creator date.');
559
+ }
560
+ return decode(result.stdout)
561
+ .split('\n')
562
+ .map((line) => line.trim())
563
+ .filter(Boolean)
564
+ .map((line) => {
565
+ const [name, timestampText, peeledSha, objectSha] = line.split('\t');
566
+ const timestamp = Number(timestampText);
567
+ const sha = peeledSha || objectSha;
568
+ if (!name || !sha || !Number.isSafeInteger(timestamp)) {
569
+ throw new Error(`Unable to parse release tag ref: ${line}`);
570
+ }
571
+ return { name, sha, timestamp };
572
+ });
573
+ }
574
+
575
+ async function buildReleaseCandidate(root: string, packages: ReleasePackage[]): Promise<void> {
576
+ await run('nx', ['run-many', '-t', 'build', `--projects=${releasePackageProjects(packages)}`], root);
577
+ }
578
+
579
+ async function listMissingGithubReleasePackages(root: string, packages: ReleasePackage[]): Promise<ReleasePackage[]> {
580
+ return (
581
+ await Promise.all(packages.map(async (pkg) => ((await githubReleaseExists(root, releaseTag(pkg))) ? null : pkg)))
582
+ ).filter((pkg): pkg is ReleasePackage => pkg !== null);
583
+ }
584
+
585
+ async function createGithubRelease(root: string, pkg: ReleasePackage, dryRun: boolean): Promise<void> {
586
+ const currentTag = releaseTag(pkg);
587
+ console.log(`${pkg.name}@${pkg.version}: rendering GitHub Release notes for ${currentTag}.`);
588
+ console.log(`GitHub release auth: ${envPresence('GH_TOKEN')}, ${envPresence('GITHUB_TOKEN')}.`);
589
+ if (!dryRun) {
590
+ await assertRemoteTagExists(root, currentTag);
591
+ }
592
+ const previousTag = await previousReleaseTag(root, pkg, currentTag);
593
+ if (dryRun) {
594
+ await renderNxProjectChangelogContents({ root, pkg, previousTag, dryRun });
595
+ return;
596
+ }
597
+ const contents = await renderNxProjectChangelogContents({ root, pkg, previousTag, dryRun });
598
+ await createOrUpdateGithubRelease(pkg, contents, {
599
+ githubReleaseExists: (tag) => githubReleaseExists(root, tag),
600
+ runGhRelease: (args) => run('gh', args, root),
601
+ log: (message) => console.log(message),
602
+ });
603
+ }
604
+
605
+ function envPresence(name: string): string {
606
+ return `${name}=${process.env[name] ? 'present' : 'missing'}`;
607
+ }
608
+
609
+ function yesNo(value: boolean): string {
610
+ return value ? 'yes' : 'no';
611
+ }
612
+
613
+ async function newerCommitsRemain(root: string): Promise<boolean> {
614
+ const branch = await releaseBranch(root);
615
+ const remote = await releaseRemote(root, branch);
616
+ await fetchReleaseRefs(root, remote, branch);
617
+ const remoteRef = `${remote}/${branch}`;
618
+ if (!(await gitRefExists(root, remoteRef))) {
619
+ return false;
620
+ }
621
+ const head = await gitHead(root);
622
+ return head !== (await gitSha(root, remoteRef)) && (await gitIsAncestor(root, head, remoteRef));
623
+ }
624
+
625
+ async function writeReleaseSummary(summary: ReleaseSummary<ReleasePackage>): Promise<void> {
626
+ const shortSha = summary.sha.slice(0, 12);
627
+ const lines = [
628
+ '## Release Summary',
629
+ '',
630
+ `- Commit: \`${shortSha}\``,
631
+ `- Mode: ${summary.dryRun ? 'dry run' : 'release'}`,
632
+ ];
633
+ if (summary.noRelease) {
634
+ lines.push('- Result: no release tags found at HEAD');
635
+ } else {
636
+ lines.push(`- Packages: ${packageSummary(summary.packages)}`);
637
+ lines.push(`- Git refs pushed: ${summary.pushed ? 'yes' : 'already current'}`);
638
+ lines.push(`- npm published: ${packageSummary(summary.published)}`);
639
+ lines.push(`- npm already published: ${packageSummary(summary.alreadyPublished)}`);
640
+ lines.push(`- GitHub Releases created/updated: ${packageSummary(summary.githubReleases)}`);
641
+ }
642
+ if (summary.rerunRequired) {
643
+ const message = 'A previous incomplete release was repaired; newer commits remain. Run Publish again.';
644
+ console.log(`::warning::${message}`);
645
+ lines.push(`- Warning: ${message}`);
646
+ }
647
+ const text = `${lines.join('\n')}\n`;
648
+ console.log(text.trimEnd());
649
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
650
+ if (summaryPath) {
651
+ await appendFile(summaryPath, `${text}\n`);
652
+ }
653
+ }
654
+
655
+ async function writeRepairSummary(summaries: Array<ReleaseSummary<ReleasePackage>>, dryRun: boolean): Promise<void> {
656
+ const lines = ['## Pending Release Repair', '', `- Mode: ${dryRun ? 'dry run' : 'release'}`];
657
+ if (summaries.length === 0) {
658
+ lines.push('- Result: no pending releases needed repair');
659
+ } else {
660
+ for (const summary of summaries) {
661
+ lines.push(`- Repaired \`${summary.sha.slice(0, 12)}\`: ${packageSummary(summary.packages)}`);
662
+ }
663
+ }
664
+ const text = `${lines.join('\n')}\n`;
665
+ console.log(text.trimEnd());
666
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
667
+ if (summaryPath) {
668
+ await appendFile(summaryPath, `${text}\n`);
669
+ }
670
+ }
671
+
672
+ function packageSummary(packages: ReleasePackage[]): string {
673
+ if (packages.length === 0) {
674
+ return 'none';
675
+ }
676
+ return packages.map((pkg) => `${pkg.name}@${pkg.version}`).join(', ');
677
+ }
678
+
679
+ async function fetchReleaseRefs(root: string, remote: string, branch: string): Promise<void> {
680
+ await run('git', ['fetch', '--tags', remote, branch], root);
681
+ }
682
+
683
+ async function gitRefExists(root: string, ref: string): Promise<boolean> {
684
+ const result = await $`git rev-parse --verify ${ref}`.cwd(root).quiet().nothrow();
685
+ return result.exitCode === 0;
686
+ }
687
+
688
+ async function gitIsAncestor(root: string, ancestor: string, descendant: string): Promise<boolean> {
689
+ const result = await $`git merge-base --is-ancestor ${ancestor} ${descendant}`.cwd(root).quiet().nothrow();
690
+ return result.exitCode === 0;
691
+ }
692
+
693
+ async function gitSha(root: string, ref: string): Promise<string> {
694
+ const result = await $`git rev-parse ${ref}`.cwd(root).quiet().nothrow();
695
+ if (result.exitCode !== 0) {
696
+ throw new Error(`Unable to resolve git ref ${ref}.`);
697
+ }
698
+ return decode(result.stdout).trim();
699
+ }
700
+
701
+ async function gitTagPointsAt(root: string, tag: string, ref: string): Promise<boolean> {
702
+ return (await gitCommitForTag(root, tag)) === (await gitSha(root, ref));
703
+ }
704
+
705
+ async function gitCommitForTag(root: string, tag: string): Promise<string> {
706
+ const result = await $`git rev-list -n 1 ${tag}`.cwd(root).quiet().nothrow();
707
+ if (result.exitCode !== 0) {
708
+ throw new Error(`Unable to resolve release tag ${tag}.`);
709
+ }
710
+ return decode(result.stdout).trim();
711
+ }
712
+
713
+ async function remoteReleaseTagExists(root: string, remote: string, tag: string): Promise<boolean> {
714
+ const result = await $`git ls-remote --exit-code --tags ${remote} ${`refs/tags/${tag}`}`.cwd(root).quiet().nothrow();
715
+ return result.exitCode === 0;
716
+ }
717
+
718
+ async function remoteReleaseTagObject(root: string, remote: string, tag: string): Promise<string | null> {
719
+ const result = await $`git ls-remote --exit-code --tags ${remote} ${`refs/tags/${tag}`}`.cwd(root).quiet().nothrow();
720
+ if (result.exitCode === 2) {
721
+ return null;
722
+ }
723
+ if (result.exitCode !== 0) {
724
+ throw new Error(`Unable to inspect remote release tag ${tag} on ${remote}.`);
725
+ }
726
+ const [object] = decode(result.stdout).trim().split('\t');
727
+ if (!object) {
728
+ throw new Error(`Unable to parse remote release tag ${tag} on ${remote}.`);
729
+ }
730
+ return object;
731
+ }
732
+
733
+ async function remoteBranchSha(root: string, remote: string, branch: string): Promise<string | null> {
734
+ const result = await $`git ls-remote --exit-code --heads ${remote} ${`refs/heads/${branch}`}`
735
+ .cwd(root)
736
+ .quiet()
737
+ .nothrow();
738
+ if (result.exitCode === 2) {
739
+ return null;
740
+ }
741
+ if (result.exitCode !== 0) {
742
+ throw new Error(`Unable to inspect remote branch ${branch} on ${remote}.`);
743
+ }
744
+ const [sha] = decode(result.stdout).trim().split('\t');
745
+ if (!sha) {
746
+ throw new Error(`Unable to parse remote branch ${branch} on ${remote}.`);
747
+ }
748
+ return sha;
749
+ }
750
+
751
+ async function pushRetaggedReleaseTags(
752
+ root: string,
753
+ remote: string,
754
+ updates: Array<RetagUnpublishedTagUpdate<ReleasePackage>>,
755
+ ): Promise<void> {
756
+ const leases = updates.map(
757
+ (update) => `--force-with-lease=refs/tags/${update.tag}:${update.expectedRemoteObject ?? ''}`,
758
+ );
759
+ const refspecs = updates.map((update) => `refs/tags/${update.tag}:refs/tags/${update.tag}`);
760
+ await run('git', ['push', '--atomic', ...leases, remote, ...refspecs], root);
761
+ }
762
+
763
+ async function githubReleaseExists(root: string, tag: string): Promise<boolean> {
764
+ return (await runStatus('gh', ['release', 'view', tag, '--json', 'tagName'], root, true)) === 0;
765
+ }
766
+
767
+ async function assertRemoteTagExists(root: string, tag: string): Promise<void> {
768
+ const branch = await releaseBranch(root);
769
+ const remote = await releaseRemote(root, branch);
770
+ if (!(await remoteReleaseTagExists(root, remote, tag))) {
771
+ throw new Error(`Release tag ${tag} is not present on remote ${remote}. Run smoo release version first.`);
772
+ }
773
+ }
774
+
775
+ async function npmVersionExists(root: string, name: string, version: string): Promise<boolean> {
776
+ const result = await $`bun pm view ${`${name}@${version}`} version`.cwd(root).quiet().nothrow();
777
+ return result.exitCode === 0 && decode(result.stdout).trim() === version;
778
+ }
779
+
780
+ async function npmPackageExists(root: string, name: string): Promise<boolean> {
781
+ const result = await $`bun pm view ${name} name`.cwd(root).quiet().nothrow();
782
+ return result.exitCode === 0;
783
+ }
784
+
229
785
  async function assertCleanGitTree(root: string): Promise<void> {
230
786
  const result = await $`git status --porcelain --untracked-files=no`.cwd(root).quiet().nothrow();
231
787
  if (result.exitCode !== 0) {
@@ -245,12 +801,6 @@ async function gitHead(root: string): Promise<string> {
245
801
  return decode(result.stdout).trim();
246
802
  }
247
803
 
248
- async function pushReleaseCommit(root: string): Promise<void> {
249
- const branch = await releaseBranch(root);
250
- const remote = await releaseRemote(root, branch);
251
- await run('git', ['push', '--follow-tags', '--atomic', remote, `HEAD:refs/heads/${branch}`], root);
252
- }
253
-
254
804
  async function releaseBranch(root: string): Promise<string> {
255
805
  const githubBranch = process.env.GITHUB_REF_NAME?.trim();
256
806
  if (githubBranch) {
@@ -304,19 +854,6 @@ function releaseBumpArg(bump = 'auto'): string {
304
854
  return bump;
305
855
  }
306
856
 
307
- function releaseNpmTagArg(options: ReleasePublishOptions): string {
308
- const bump = releaseBumpArg(options.bump);
309
- const derivedTag = bump === 'prerelease' ? 'next' : 'latest';
310
- const explicitTag = options.tag ?? options.npmTag;
311
- if (!explicitTag) {
312
- return derivedTag;
313
- }
314
- if (explicitTag !== derivedTag) {
315
- throw new Error(`--bump ${bump} publishes with npm dist-tag ${derivedTag}, not ${explicitTag}.`);
316
- }
317
- return explicitTag;
318
- }
319
-
320
857
  function githubRepositoryFromRootPackage(root: string): string {
321
858
  const pkg = readPackageJson(join(root, 'package.json'));
322
859
  const repository = pkg ? repositoryInfo(pkg.json) : null;
@@ -350,9 +887,18 @@ async function runLatestNpm(root: string, npmArgs: string[], env?: Record<string
350
887
  await run('nix', ['shell', 'nixpkgs#nodejs_latest', '-c', 'npm', ...npmArgs], root, env);
351
888
  }
352
889
 
353
- async function runLatestNpmPublish(root: string, npmArgs: string[]): Promise<void> {
890
+ async function runLatestNpmPublish(root: string, npmArgs: string[], useBootstrapToken: boolean): Promise<void> {
891
+ if (!useBootstrapToken) {
892
+ await runLatestNpm(root, npmArgs, { NODE_AUTH_TOKEN: '', NPM_TOKEN: '' });
893
+ return;
894
+ }
354
895
  const token = process.env.NODE_AUTH_TOKEN || process.env.NPM_TOKEN;
355
896
  if (!token || process.env.NPM_CONFIG_USERCONFIG) {
897
+ if (!token && !process.env.NPM_CONFIG_USERCONFIG) {
898
+ throw new Error(
899
+ 'First publish for a package requires NODE_AUTH_TOKEN or NPM_TOKEN until trusted publishing exists.',
900
+ );
901
+ }
356
902
  await runLatestNpm(root, npmArgs);
357
903
  return;
358
904
  }