@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
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { readFile } from 'node:fs/promises';
3
+ import type { ReleasePackageInfo } from '../core.js';
4
+ import {
5
+ createOrUpdateGithubRelease,
6
+ type GithubReleaseWriteShell,
7
+ nxProjectChangelogArgs,
8
+ projectChangelogContents,
9
+ } from '../github-release.js';
10
+
11
+ const stable: ReleasePackageInfo = { name: '@scope/pkg', path: 'packages/pkg', version: '1.2.3' };
12
+ const prerelease: ReleasePackageInfo = { name: '@scope/pkg', path: 'packages/pkg', version: '2.0.0-beta.1' };
13
+
14
+ describe('GitHub release helpers', () => {
15
+ it('configures the Nx changelog API for render-only project changelogs', () => {
16
+ expect(nxProjectChangelogArgs(stable, '@scope/pkg@1.2.2', false)).toEqual({
17
+ version: '1.2.3',
18
+ projects: ['@scope/pkg'],
19
+ gitCommit: false,
20
+ gitTag: false,
21
+ gitPush: false,
22
+ stageChanges: false,
23
+ createRelease: false,
24
+ deleteVersionPlans: false,
25
+ dryRun: false,
26
+ from: '@scope/pkg@1.2.2',
27
+ });
28
+ });
29
+
30
+ it('marks the Nx changelog request as a first release when there is no previous tag', () => {
31
+ expect(nxProjectChangelogArgs(stable, null, true)).toEqual({
32
+ version: '1.2.3',
33
+ projects: ['@scope/pkg'],
34
+ gitCommit: false,
35
+ gitTag: false,
36
+ gitPush: false,
37
+ stageChanges: false,
38
+ createRelease: false,
39
+ deleteVersionPlans: false,
40
+ dryRun: true,
41
+ firstRelease: true,
42
+ });
43
+ });
44
+
45
+ it('extracts the generated Nx project changelog body', () => {
46
+ expect(
47
+ projectChangelogContents(
48
+ {
49
+ projectChangelogs: {
50
+ '@scope/pkg': { contents: 'generated release notes' },
51
+ },
52
+ },
53
+ '@scope/pkg',
54
+ ),
55
+ ).toBe('generated release notes');
56
+ });
57
+
58
+ it('fails when Nx omits the requested project changelog', () => {
59
+ expect(() => projectChangelogContents({ projectChangelogs: {} }, '@scope/pkg')).toThrow(
60
+ 'Nx did not generate a project changelog for @scope/pkg.',
61
+ );
62
+ });
63
+
64
+ it('creates a missing stable GitHub Release from the generated notes file', async () => {
65
+ const shell = new RecordingGithubReleaseShell(false);
66
+
67
+ await createOrUpdateGithubRelease(stable, 'stable notes', shell);
68
+
69
+ expect(shell.existsQueries).toEqual(['@scope/pkg@1.2.3']);
70
+ expect(shell.notes).toEqual(['stable notes']);
71
+ expect(shell.logs).toEqual(['@scope/pkg@1.2.3: creating GitHub Release for @scope/pkg@1.2.3.']);
72
+ const command = onlyCommand(shell.commands);
73
+ expect(command.slice(0, 6)).toEqual([
74
+ 'release',
75
+ 'create',
76
+ '@scope/pkg@1.2.3',
77
+ '--title',
78
+ '@scope/pkg@1.2.3',
79
+ '--notes-file',
80
+ ]);
81
+ expect(command).toContain('--verify-tag');
82
+ expect(command).toContain('--latest=true');
83
+ expect(command).not.toContain('--prerelease');
84
+ });
85
+
86
+ it('edits an existing prerelease and prevents it from becoming latest', async () => {
87
+ const shell = new RecordingGithubReleaseShell(true);
88
+
89
+ await createOrUpdateGithubRelease(prerelease, 'prerelease notes', shell);
90
+
91
+ expect(shell.existsQueries).toEqual(['@scope/pkg@2.0.0-beta.1']);
92
+ expect(shell.notes).toEqual(['prerelease notes']);
93
+ expect(shell.logs).toEqual(['@scope/pkg@2.0.0-beta.1: updating GitHub Release for @scope/pkg@2.0.0-beta.1.']);
94
+ const command = onlyCommand(shell.commands);
95
+ expect(command.slice(0, 6)).toEqual([
96
+ 'release',
97
+ 'edit',
98
+ '@scope/pkg@2.0.0-beta.1',
99
+ '--title',
100
+ '@scope/pkg@2.0.0-beta.1',
101
+ '--notes-file',
102
+ ]);
103
+ expect(command).toContain('--verify-tag');
104
+ expect(command).toContain('--latest=false');
105
+ expect(command).toContain('--prerelease');
106
+ });
107
+ });
108
+
109
+ class RecordingGithubReleaseShell implements GithubReleaseWriteShell {
110
+ readonly existsQueries: string[] = [];
111
+ readonly commands: string[][] = [];
112
+ readonly notes: string[] = [];
113
+ readonly logs: string[] = [];
114
+
115
+ constructor(private readonly releaseExists: boolean) {}
116
+
117
+ async githubReleaseExists(tag: string): Promise<boolean> {
118
+ this.existsQueries.push(tag);
119
+ return this.releaseExists;
120
+ }
121
+
122
+ async runGhRelease(args: string[]): Promise<void> {
123
+ this.commands.push(args);
124
+ const notesFileIndex = args.indexOf('--notes-file') + 1;
125
+ const notesFile = args[notesFileIndex];
126
+ if (!notesFile) {
127
+ throw new Error(`Missing --notes-file in gh release command: ${args.join(' ')}`);
128
+ }
129
+ this.notes.push(await readFile(notesFile, 'utf8'));
130
+ }
131
+
132
+ log(message: string): void {
133
+ this.logs.push(message);
134
+ }
135
+ }
136
+
137
+ function onlyCommand(commands: string[][]): string[] {
138
+ expect(commands).toHaveLength(1);
139
+ const command = commands[0];
140
+ if (!command) {
141
+ throw new Error('Expected one gh command.');
142
+ }
143
+ return command;
144
+ }
@@ -0,0 +1,113 @@
1
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { $ } from 'bun';
5
+ import type { GitReleaseTagInfo } from '../../core.js';
6
+
7
+ export async function withFixtureRepo(fn: (root: string) => Promise<void>): Promise<void> {
8
+ const root = await mkdtemp(join(tmpdir(), 'smoo-release-test-'));
9
+ try {
10
+ await git(root, ['init', '-b', 'main']);
11
+ await git(root, ['config', 'user.name', 'Test User']);
12
+ await git(root, ['config', 'user.email', 'test@example.com']);
13
+ await fn(root);
14
+ } finally {
15
+ await rm(root, { recursive: true, force: true });
16
+ }
17
+ }
18
+
19
+ export async function writeWorkspace(root: string): Promise<void> {
20
+ await symlink(join(import.meta.dir, '../../../../../../node_modules'), join(root, 'node_modules'), 'dir');
21
+ await writeFile(
22
+ join(root, 'package.json'),
23
+ `${JSON.stringify({ name: 'fixture', private: true, workspaces: ['packages/*'] }, null, 2)}\n`,
24
+ );
25
+ await writeFile(
26
+ join(root, 'nx.json'),
27
+ `${JSON.stringify({ targetDefaults: { build: { cache: true, outputs: ['{projectRoot}/dist'] } } }, null, 2)}\n`,
28
+ );
29
+ }
30
+
31
+ export async function writePackage(root: string, name: string, packagePath: string, version: string): Promise<void> {
32
+ await mkdir(join(root, packagePath), { recursive: true });
33
+ await writeFile(join(root, packagePath, 'package.json'), `${JSON.stringify({ name, version }, null, 2)}\n`);
34
+ }
35
+
36
+ export async function writeBuildablePackage(
37
+ root: string,
38
+ name: string,
39
+ packagePath: string,
40
+ version = '1.0.0',
41
+ ): Promise<void> {
42
+ await mkdir(join(root, packagePath), { recursive: true });
43
+ await writeFile(
44
+ join(root, packagePath, 'package.json'),
45
+ `${JSON.stringify(
46
+ {
47
+ name,
48
+ version,
49
+ nx: {
50
+ targets: {
51
+ build: {
52
+ executor: 'nx:run-commands',
53
+ options: { command: "mkdir -p dist && echo '{}' > dist/index.js", cwd: packagePath },
54
+ },
55
+ },
56
+ },
57
+ },
58
+ null,
59
+ 2,
60
+ )}\n`,
61
+ );
62
+ }
63
+
64
+ export async function git(root: string, args: string[], env?: Record<string, string>): Promise<void> {
65
+ await $`git ${args}`
66
+ .cwd(root)
67
+ .env(env ?? {})
68
+ .quiet();
69
+ }
70
+
71
+ export async function gitOutput(root: string, args: string[]): Promise<string> {
72
+ const result = await $`git ${args}`.cwd(root).quiet();
73
+ return new TextDecoder().decode(result.stdout).trim();
74
+ }
75
+
76
+ export async function tag(root: string, tagName: string, date: string): Promise<void> {
77
+ await git(root, ['tag', '-a', tagName, '-m', tagName], { GIT_COMMITTER_DATE: date });
78
+ }
79
+
80
+ export async function gitReleaseTagsByCreatorDate(root: string): Promise<GitReleaseTagInfo[]> {
81
+ const result =
82
+ await $`git for-each-ref --sort=-creatordate --format=${'%(refname:short)%09%(creatordate:unix)%09%(*objectname)%09%(objectname)'} refs/tags`
83
+ .cwd(root)
84
+ .quiet();
85
+ return new TextDecoder()
86
+ .decode(result.stdout)
87
+ .split('\n')
88
+ .map((line) => line.trim())
89
+ .filter(Boolean)
90
+ .map((line) => {
91
+ const [name, timestampText, peeledSha, objectSha] = line.split('\t');
92
+ const timestamp = Number(timestampText);
93
+ const sha = peeledSha || objectSha;
94
+ if (!name || !sha || !Number.isSafeInteger(timestamp)) {
95
+ throw new Error(`Unable to parse release tag ref: ${line}`);
96
+ }
97
+ return { name, sha, timestamp };
98
+ });
99
+ }
100
+
101
+ export async function gitIsAncestor(root: string, ancestor: string, descendant: string): Promise<boolean> {
102
+ const result = await $`git merge-base --is-ancestor ${ancestor} ${descendant}`.cwd(root).quiet().nothrow();
103
+ return result.exitCode === 0;
104
+ }
105
+
106
+ export async function packageVersionAtRef(root: string, packagePath: string, ref: string): Promise<string | null> {
107
+ const result = await $`git show ${`${ref}:${packagePath}/package.json`}`.cwd(root).quiet().nothrow();
108
+ if (result.exitCode !== 0) {
109
+ return null;
110
+ }
111
+ const parsed = JSON.parse(new TextDecoder().decode(result.stdout));
112
+ return typeof parsed.version === 'string' ? parsed.version : null;
113
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import type { ReleasePackageInfo } from '../core.js';
3
+ import {
4
+ type NpmPublishDiagnosticShell,
5
+ npmPublishAuthFailureMarkdown,
6
+ npmPublishAuthFailureMessage,
7
+ publishWithAuthDiagnostics,
8
+ } from '../npm-auth.js';
9
+
10
+ const pkg: Pick<ReleasePackageInfo, 'name' | 'version'> = { name: '@scope/pkg', version: '1.2.3' };
11
+
12
+ describe('npm publish auth diagnostics', () => {
13
+ it('explains trusted publishing setup when an existing package publish is unauthenticated', () => {
14
+ const message = npmPublishAuthFailureMessage(pkg, {
15
+ useBootstrapToken: false,
16
+ tokenPresent: true,
17
+ repository: 'smoothbricks/codebase',
18
+ });
19
+ const markdown = npmPublishAuthFailureMarkdown(pkg, {
20
+ useBootstrapToken: false,
21
+ tokenPresent: true,
22
+ repository: 'smoothbricks/codebase',
23
+ });
24
+
25
+ expect(message).toContain('🚨 npm publish authentication failed for @scope/pkg@1.2.3');
26
+ expect(message).toContain('smoo release trust-publisher');
27
+ expect(message).toContain('NODE_AUTH_TOKEN/NPM_TOKEN is set but unused');
28
+ expect(message).toContain('repository: smoothbricks/codebase');
29
+ expect(message).toContain('workflow: publish.yml');
30
+ expect(markdown).toContain('## 🚨 npm Publish Authentication Failed');
31
+ expect(markdown).toContain('Run locally: `smoo release trust-publisher`');
32
+ expect(markdown).toContain('repository `smoothbricks/codebase` and workflow `publish.yml`');
33
+ });
34
+
35
+ it('explains the bootstrap token path for first package publishes', () => {
36
+ const message = npmPublishAuthFailureMessage(pkg, { useBootstrapToken: true, tokenPresent: false });
37
+ const markdown = npmPublishAuthFailureMarkdown(pkg, { useBootstrapToken: true, tokenPresent: false });
38
+
39
+ expect(message).toContain('temporary npm automation token');
40
+ expect(message).toContain('NODE_AUTH_TOKEN/NPM_TOKEN is not set');
41
+ expect(message).toContain('After the first successful publish, run:');
42
+ expect(message).toContain('smoo release trust-publisher');
43
+ expect(markdown).toContain('Add a temporary `NPM_TOKEN` repository secret');
44
+ expect(markdown).toContain('After the first successful publish, run `smoo release trust-publisher`');
45
+ });
46
+
47
+ it('reports trusted-publishing guidance and writes it to the publish summary after existing package auth failure', async () => {
48
+ const shell = new RecordingPublishShell({ publishFails: true });
49
+
50
+ await expect(
51
+ publishWithAuthDiagnostics(pkg, shell, {
52
+ useBootstrapToken: false,
53
+ tokenPresent: true,
54
+ repository: 'smoothbricks/codebase',
55
+ }),
56
+ ).rejects.toThrow('@scope/pkg@1.2.3: npm publish authentication failed');
57
+
58
+ expect(shell.errors).toHaveLength(1);
59
+ expect(shell.errors[0]).toContain('🚨 npm publish authentication failed for @scope/pkg@1.2.3');
60
+ expect(shell.errors[0]).toContain('NODE_AUTH_TOKEN/NPM_TOKEN is set but unused');
61
+ expect(shell.errors[0]).toContain('smoo release trust-publisher');
62
+ expect(shell.errors[0]).toContain('repository: smoothbricks/codebase');
63
+ expect(shell.summaries).toHaveLength(1);
64
+ expect(shell.summaries[0]).toContain('## 🚨 npm Publish Authentication Failed');
65
+ expect(shell.summaries[0]).toContain('Run locally: `smoo release trust-publisher`');
66
+ expect(shell.logs).toEqual([]);
67
+ });
68
+
69
+ it('reports bootstrap token guidance and writes it to the publish summary after first-publish auth failure', async () => {
70
+ const shell = new RecordingPublishShell({ publishFails: true });
71
+
72
+ await expect(
73
+ publishWithAuthDiagnostics(pkg, shell, { useBootstrapToken: true, tokenPresent: false }),
74
+ ).rejects.toThrow('@scope/pkg@1.2.3: npm publish authentication failed');
75
+
76
+ expect(shell.errors).toHaveLength(1);
77
+ expect(shell.errors[0]).toContain('temporary npm automation token');
78
+ expect(shell.errors[0]).toContain('NODE_AUTH_TOKEN/NPM_TOKEN is not set');
79
+ expect(shell.errors[0]).toContain('smoo release trust-publisher');
80
+ expect(shell.summaries).toHaveLength(1);
81
+ expect(shell.summaries[0]).toContain('Add a temporary `NPM_TOKEN` repository secret');
82
+ expect(shell.logs).toEqual([]);
83
+ });
84
+
85
+ it('continues without auth warning when the package version appears on npm after publish failure', async () => {
86
+ const shell = new RecordingPublishShell({ publishFails: true, versionVisibleAfterFailure: true });
87
+
88
+ await publishWithAuthDiagnostics(pkg, shell, { useBootstrapToken: false, tokenPresent: true });
89
+
90
+ expect(shell.errors).toEqual([]);
91
+ expect(shell.summaries).toEqual([]);
92
+ expect(shell.logs).toEqual(['@scope/pkg@1.2.3: publish result already visible on npm; continuing.']);
93
+ });
94
+ });
95
+
96
+ class RecordingPublishShell implements NpmPublishDiagnosticShell {
97
+ readonly logs: string[] = [];
98
+ readonly errors: string[] = [];
99
+ readonly summaries: string[] = [];
100
+ private readonly publishFails: boolean;
101
+ private readonly versionVisibleAfterFailure: boolean;
102
+
103
+ constructor(options: { publishFails: boolean; versionVisibleAfterFailure?: boolean }) {
104
+ this.publishFails = options.publishFails;
105
+ this.versionVisibleAfterFailure = options.versionVisibleAfterFailure === true;
106
+ }
107
+
108
+ async publish(): Promise<void> {
109
+ if (this.publishFails) {
110
+ throw new Error('ENEEDAUTH');
111
+ }
112
+ }
113
+
114
+ async versionExists(): Promise<boolean> {
115
+ return this.versionVisibleAfterFailure;
116
+ }
117
+
118
+ log(message: string): void {
119
+ this.logs.push(message);
120
+ }
121
+
122
+ error(message: string): void {
123
+ this.errors.push(message);
124
+ }
125
+
126
+ async appendSummary(markdown: string): Promise<void> {
127
+ this.summaries.push(markdown);
128
+ }
129
+ }
@@ -0,0 +1,202 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import type { ReleasePackageInfo, ReleaseTarget } from '../core.js';
3
+ import {
4
+ completeReleaseAtHead,
5
+ type ReleaseRepairShell,
6
+ type ReleaseVersionShell,
7
+ repairPendingTargets,
8
+ runReleaseVersion,
9
+ } from '../orchestration.js';
10
+
11
+ const stable: ReleasePackageInfo = { name: '@scope/stable', path: 'packages/stable', version: '1.0.0' };
12
+ const prerelease: ReleasePackageInfo = {
13
+ name: '@scope/prerelease',
14
+ path: 'packages/prerelease',
15
+ version: '2.0.0-beta.1',
16
+ };
17
+
18
+ describe('release orchestration', () => {
19
+ it('repairs an older target with one checkout, one devenv load, npm-only build, and GitHub create calls', async () => {
20
+ const target = releaseTarget('older-release', [stable, prerelease], [stable], [prerelease]);
21
+ const shell = new RecordingRepairShell();
22
+
23
+ const summaries = await repairPendingTargets(shell, [target], 'restore-ref', false);
24
+
25
+ expect(shell.checkouts).toEqual(['older-release', 'restore-ref']);
26
+ expect(shell.devenvLoads).toBe(1);
27
+ expect(shell.builds).toEqual([['@scope/stable']]);
28
+ expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
29
+ expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
30
+ expect(shell.pushes).toEqual([['@scope/stable', '@scope/prerelease']]);
31
+ expect(summaries).toHaveLength(1);
32
+ expect(summaries[0]?.published.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
33
+ expect(summaries[0]?.githubReleases.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
34
+ });
35
+
36
+ it('skips build and publish for GitHub-only repair targets', async () => {
37
+ const target = releaseTarget('github-only', [prerelease], [], [prerelease]);
38
+ const shell = new RecordingRepairShell();
39
+
40
+ await repairPendingTargets(shell, [target], 'restore-ref', false);
41
+
42
+ expect(shell.builds).toEqual([]);
43
+ expect(shell.publishes).toEqual([]);
44
+ expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
45
+ });
46
+
47
+ it('publishes a partial HEAD release by building npm-missing packages and creating missing GitHub Releases', async () => {
48
+ const shell = new RecordingRepairShell({ npmMissing: ['@scope/stable'], githubMissing: ['@scope/prerelease'] });
49
+
50
+ const summary = await completeReleaseAtHead(shell, [stable, prerelease], false, true);
51
+
52
+ expect(shell.checkouts).toEqual([]);
53
+ expect(shell.devenvLoads).toBe(0);
54
+ expect(shell.builds).toEqual([['@scope/stable']]);
55
+ expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
56
+ expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
57
+ expect(summary.published.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
58
+ expect(summary.alreadyPublished.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
59
+ expect(summary.githubReleases.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
60
+ expect(summary.rerunRequired).toBe(true);
61
+ });
62
+
63
+ it('leaves a complete HEAD release idempotent', async () => {
64
+ const shell = new RecordingRepairShell();
65
+
66
+ const summary = await completeReleaseAtHead(shell, [stable, prerelease], false, false);
67
+
68
+ expect(shell.builds).toEqual([]);
69
+ expect(shell.publishes).toEqual([]);
70
+ expect(shell.githubCreates).toEqual([]);
71
+ expect(summary.published).toEqual([]);
72
+ expect(summary.alreadyPublished.map((pkg) => pkg.name)).toEqual(['@scope/stable', '@scope/prerelease']);
73
+ expect(summary.githubReleases).toEqual([]);
74
+ });
75
+
76
+ it('does not run Nx versioning when HEAD is already a release target', async () => {
77
+ const shell = new RecordingVersionShell({ releasePackagesAtHead: [[stable]] });
78
+
79
+ const result = await runReleaseVersion(shell, { bump: 'patch', dryRun: false });
80
+
81
+ expect(result).toEqual({ mode: 'none', packages: [stable], status: 'already-release-target' });
82
+ expect(shell.nxRuns).toEqual([]);
83
+ expect(shell.ensureCalls).toEqual([['@scope/stable']]);
84
+ });
85
+
86
+ it('runs forced Nx versioning for an untagged HEAD and reports a new release commit', async () => {
87
+ const shell = new RecordingVersionShell({ releasePackagesAtHead: [[], [stable]], heads: ['before', 'after'] });
88
+
89
+ const result = await runReleaseVersion(shell, { bump: 'patch', dryRun: false });
90
+
91
+ expect(result).toEqual({ mode: 'new', packages: [stable], status: 'new-release' });
92
+ expect(shell.nxRuns).toEqual([{ bump: 'patch', dryRun: false }]);
93
+ expect(shell.cleanChecks).toBe(1);
94
+ expect(shell.ensureCalls).toEqual([['@scope/stable']]);
95
+ });
96
+ });
97
+
98
+ function releaseTarget(
99
+ sha: string,
100
+ packages: ReleasePackageInfo[],
101
+ npmPackages: ReleasePackageInfo[],
102
+ githubPackages: ReleasePackageInfo[],
103
+ ): ReleaseTarget<ReleasePackageInfo> {
104
+ return { sha, timestamp: 1, packages, npmPackages, githubPackages };
105
+ }
106
+
107
+ class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
108
+ readonly checkouts: string[] = [];
109
+ readonly pushes: string[][] = [];
110
+ readonly builds: string[][] = [];
111
+ readonly publishes: Array<{ name: string; distTag: string; dryRun: boolean }> = [];
112
+ readonly githubCreates: Array<{ name: string; dryRun: boolean }> = [];
113
+ readonly npmQueries: string[][] = [];
114
+ readonly githubQueries: string[][] = [];
115
+ devenvLoads = 0;
116
+ currentRef = 'head';
117
+ private readonly npmMissing: Set<string>;
118
+ private readonly githubMissing: Set<string>;
119
+
120
+ constructor(options: { npmMissing?: string[]; githubMissing?: string[] } = {}) {
121
+ this.npmMissing = new Set(options.npmMissing ?? []);
122
+ this.githubMissing = new Set(options.githubMissing ?? []);
123
+ }
124
+
125
+ async gitHead(): Promise<string> {
126
+ return this.currentRef;
127
+ }
128
+
129
+ async pushReleaseRefs(packages: ReleasePackageInfo[]): Promise<boolean> {
130
+ this.pushes.push(packageNames(packages));
131
+ return true;
132
+ }
133
+
134
+ async listNpmMissingPackages(packages: ReleasePackageInfo[]): Promise<ReleasePackageInfo[]> {
135
+ this.npmQueries.push(packageNames(packages));
136
+ return packages.filter((pkg) => this.npmMissing.has(pkg.name));
137
+ }
138
+
139
+ async buildReleaseCandidate(packages: ReleasePackageInfo[]): Promise<void> {
140
+ this.builds.push(packageNames(packages));
141
+ }
142
+
143
+ async publishPackage(pkg: ReleasePackageInfo, distTag: string, dryRun: boolean): Promise<void> {
144
+ this.publishes.push({ name: pkg.name, distTag, dryRun });
145
+ }
146
+
147
+ async listGithubMissingPackages(packages: ReleasePackageInfo[]): Promise<ReleasePackageInfo[]> {
148
+ this.githubQueries.push(packageNames(packages));
149
+ return packages.filter((pkg) => this.githubMissing.has(pkg.name));
150
+ }
151
+
152
+ async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<void> {
153
+ this.githubCreates.push({ name: pkg.name, dryRun });
154
+ }
155
+
156
+ async checkout(ref: string): Promise<void> {
157
+ this.currentRef = ref;
158
+ this.checkouts.push(ref);
159
+ }
160
+
161
+ async withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T> {
162
+ this.devenvLoads += 1;
163
+ return runWithEnv();
164
+ }
165
+ }
166
+
167
+ class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
168
+ readonly ensureCalls: string[][] = [];
169
+ readonly nxRuns: Array<{ bump: string; dryRun: boolean }> = [];
170
+ cleanChecks = 0;
171
+ private readonly releaseBatches: ReleasePackageInfo[][];
172
+ private readonly heads: string[];
173
+
174
+ constructor(options: { releasePackagesAtHead: ReleasePackageInfo[][]; heads?: string[] }) {
175
+ this.releaseBatches = [...options.releasePackagesAtHead];
176
+ this.heads = [...(options.heads ?? [])];
177
+ }
178
+
179
+ async releasePackagesAtHead(): Promise<ReleasePackageInfo[]> {
180
+ return this.releaseBatches.shift() ?? [];
181
+ }
182
+
183
+ async ensureLocalReleaseTags(packages: ReleasePackageInfo[]): Promise<void> {
184
+ this.ensureCalls.push(packageNames(packages));
185
+ }
186
+
187
+ async gitHead(): Promise<string> {
188
+ return this.heads.shift() ?? 'head';
189
+ }
190
+
191
+ async runNxReleaseVersion(bump: string, dryRun: boolean): Promise<void> {
192
+ this.nxRuns.push({ bump, dryRun });
193
+ }
194
+
195
+ async assertCleanGitTree(): Promise<void> {
196
+ this.cleanChecks += 1;
197
+ }
198
+ }
199
+
200
+ function packageNames(packages: ReleasePackageInfo[]): string[] {
201
+ return packages.map((pkg) => pkg.name);
202
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import type { ReleasePackageInfo } from '../core.js';
3
+ import { planPublishActions } from '../publish-plan.js';
4
+
5
+ const stable: ReleasePackageInfo = { name: '@scope/stable', path: 'packages/stable', version: '1.0.0' };
6
+ const prerelease: ReleasePackageInfo = {
7
+ name: '@scope/prerelease',
8
+ path: 'packages/prerelease',
9
+ version: '2.0.0-beta.1',
10
+ };
11
+
12
+ describe('publish plan', () => {
13
+ it('returns empty actions when durable release state is complete', () => {
14
+ expect(
15
+ planPublishActions({
16
+ releasePackages: [stable, prerelease],
17
+ npmMissingPackages: [],
18
+ githubMissingPackages: [],
19
+ }),
20
+ ).toEqual({ buildProjects: [], publishPackages: [], githubReleasePackages: [] });
21
+ });
22
+
23
+ it('builds and publishes exactly npm-missing packages', () => {
24
+ const plan = planPublishActions({
25
+ releasePackages: [stable, prerelease],
26
+ npmMissingPackages: [stable],
27
+ githubMissingPackages: [],
28
+ });
29
+
30
+ expect(plan.buildProjects).toEqual(['@scope/stable']);
31
+ expect(plan.publishPackages).toEqual([{ pkg: stable, distTag: 'latest' }]);
32
+ expect(plan.githubReleasePackages).toEqual([]);
33
+ });
34
+
35
+ it('does not build for GitHub-only repair actions', () => {
36
+ const plan = planPublishActions({
37
+ releasePackages: [stable, prerelease],
38
+ npmMissingPackages: [],
39
+ githubMissingPackages: [prerelease],
40
+ });
41
+
42
+ expect(plan.buildProjects).toEqual([]);
43
+ expect(plan.publishPackages).toEqual([]);
44
+ expect(plan.githubReleasePackages).toEqual([prerelease]);
45
+ });
46
+
47
+ it('uses latest for stable packages and next for prerelease packages', () => {
48
+ const plan = planPublishActions({
49
+ releasePackages: [stable, prerelease],
50
+ npmMissingPackages: [stable, prerelease],
51
+ githubMissingPackages: [],
52
+ });
53
+
54
+ expect(plan.buildProjects).toEqual(['@scope/stable', '@scope/prerelease']);
55
+ expect(plan.publishPackages).toEqual([
56
+ { pkg: stable, distTag: 'latest' },
57
+ { pkg: prerelease, distTag: 'next' },
58
+ ]);
59
+ expect(plan.githubReleasePackages).toEqual([]);
60
+ });
61
+ });