@smoothbricks/cli 0.1.0 → 0.2.0

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 (148) hide show
  1. package/README.md +338 -88
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +136 -22
  4. package/dist/devenv/index.d.ts +10 -0
  5. package/dist/devenv/index.d.ts.map +1 -0
  6. package/dist/devenv/index.js +27 -0
  7. package/dist/lib/cli-package.d.ts +4 -0
  8. package/dist/lib/cli-package.d.ts.map +1 -0
  9. package/dist/lib/cli-package.js +15 -0
  10. package/dist/lib/devenv.d.ts +6 -0
  11. package/dist/lib/devenv.d.ts.map +1 -0
  12. package/dist/lib/devenv.js +82 -0
  13. package/dist/lib/run.d.ts +5 -0
  14. package/dist/lib/run.d.ts.map +1 -1
  15. package/dist/lib/run.js +13 -0
  16. package/dist/lib/workspace.d.ts +20 -1
  17. package/dist/lib/workspace.d.ts.map +1 -1
  18. package/dist/lib/workspace.js +87 -7
  19. package/dist/monorepo/commit-msg.d.ts +8 -1
  20. package/dist/monorepo/commit-msg.d.ts.map +1 -1
  21. package/dist/monorepo/commit-msg.js +85 -7
  22. package/dist/monorepo/index.d.ts +24 -2
  23. package/dist/monorepo/index.d.ts.map +1 -1
  24. package/dist/monorepo/index.js +136 -15
  25. package/dist/monorepo/lockfile.d.ts +5 -1
  26. package/dist/monorepo/lockfile.d.ts.map +1 -1
  27. package/dist/monorepo/lockfile.js +45 -5
  28. package/dist/monorepo/managed-files.d.ts +1 -1
  29. package/dist/monorepo/managed-files.d.ts.map +1 -1
  30. package/dist/monorepo/managed-files.js +28 -11
  31. package/dist/monorepo/nx-sync.d.ts +2 -1
  32. package/dist/monorepo/nx-sync.d.ts.map +1 -1
  33. package/dist/monorepo/nx-sync.js +8 -2
  34. package/dist/monorepo/package-hygiene.d.ts +6 -2
  35. package/dist/monorepo/package-hygiene.d.ts.map +1 -1
  36. package/dist/monorepo/package-hygiene.js +12 -4
  37. package/dist/monorepo/package-policy.d.ts +25 -2
  38. package/dist/monorepo/package-policy.d.ts.map +1 -1
  39. package/dist/monorepo/package-policy.js +1289 -27
  40. package/dist/monorepo/packed-manifest.d.ts +4 -0
  41. package/dist/monorepo/packed-manifest.d.ts.map +1 -0
  42. package/dist/monorepo/packed-manifest.js +51 -0
  43. package/dist/monorepo/packed-package.d.ts +3 -0
  44. package/dist/monorepo/packed-package.d.ts.map +1 -1
  45. package/dist/monorepo/packed-package.js +69 -31
  46. package/dist/monorepo/packs/index.d.ts +20 -1
  47. package/dist/monorepo/packs/index.d.ts.map +1 -1
  48. package/dist/monorepo/packs/index.js +240 -18
  49. package/dist/monorepo/publish-workflow.d.ts +82 -0
  50. package/dist/monorepo/publish-workflow.d.ts.map +1 -0
  51. package/dist/monorepo/publish-workflow.js +336 -0
  52. package/dist/monorepo/tool-validation.d.ts +22 -0
  53. package/dist/monorepo/tool-validation.d.ts.map +1 -0
  54. package/dist/monorepo/tool-validation.js +399 -0
  55. package/dist/nx/index.d.ts +24 -0
  56. package/dist/nx/index.d.ts.map +1 -0
  57. package/dist/nx/index.js +94 -0
  58. package/dist/nx-version-actions.cjs +25 -0
  59. package/dist/nx-version-actions.d.cts +6 -0
  60. package/dist/nx-version-actions.d.cts.map +1 -0
  61. package/dist/release/bootstrap-npm-packages.d.ts +19 -0
  62. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -0
  63. package/dist/release/bootstrap-npm-packages.js +58 -0
  64. package/dist/release/candidates.d.ts +11 -0
  65. package/dist/release/candidates.d.ts.map +1 -0
  66. package/dist/release/candidates.js +181 -0
  67. package/dist/release/core.d.ts +51 -0
  68. package/dist/release/core.d.ts.map +1 -0
  69. package/dist/release/core.js +97 -0
  70. package/dist/release/github-release.d.ts +46 -0
  71. package/dist/release/github-release.d.ts.map +1 -0
  72. package/dist/release/github-release.js +97 -0
  73. package/dist/release/index.d.ts +39 -5
  74. package/dist/release/index.d.ts.map +1 -1
  75. package/dist/release/index.js +763 -130
  76. package/dist/release/npm-auth.d.ts +16 -0
  77. package/dist/release/npm-auth.d.ts.map +1 -0
  78. package/dist/release/npm-auth.js +39 -0
  79. package/dist/release/orchestration.d.ts +49 -0
  80. package/dist/release/orchestration.d.ts.map +1 -0
  81. package/dist/release/orchestration.js +113 -0
  82. package/dist/release/publish-plan.d.ts +17 -0
  83. package/dist/release/publish-plan.d.ts.map +1 -0
  84. package/dist/release/publish-plan.js +15 -0
  85. package/dist/release/retag-unpublished.d.ts +34 -0
  86. package/dist/release/retag-unpublished.d.ts.map +1 -0
  87. package/dist/release/retag-unpublished.js +77 -0
  88. package/managed/raw/git-format-staged.yml +54 -0
  89. package/managed/raw/tooling/git-hooks/commit-msg.sh +1 -1
  90. package/managed/raw/tooling/git-hooks/pre-commit.sh +2 -7
  91. package/managed/templates/github/actions/setup-devenv/action.yml +1 -7
  92. package/managed/templates/github/workflows/ci.yml +3 -0
  93. package/package.json +37 -4
  94. package/src/cli.ts +193 -40
  95. package/src/devenv/index.test.ts +49 -0
  96. package/src/devenv/index.ts +38 -0
  97. package/src/lib/cli-package.ts +18 -0
  98. package/src/lib/devenv.test.ts +28 -0
  99. package/src/lib/devenv.ts +89 -0
  100. package/src/lib/run.ts +19 -0
  101. package/src/lib/workspace.ts +117 -7
  102. package/src/monorepo/__tests__/nx-version-actions.test.ts +75 -0
  103. package/src/monorepo/__tests__/publish-workflow.test.ts +322 -0
  104. package/src/monorepo/commit-msg.test.ts +45 -0
  105. package/src/monorepo/commit-msg.ts +107 -7
  106. package/src/monorepo/index.ts +181 -15
  107. package/src/monorepo/lockfile.ts +52 -7
  108. package/src/monorepo/managed-files.ts +43 -13
  109. package/src/monorepo/nx-sync.ts +9 -2
  110. package/src/monorepo/package-hygiene.test.ts +34 -0
  111. package/src/monorepo/package-hygiene.ts +26 -4
  112. package/src/monorepo/package-policy.test.ts +902 -0
  113. package/src/monorepo/package-policy.ts +1527 -25
  114. package/src/monorepo/packed-manifest.ts +67 -0
  115. package/src/monorepo/packed-package.ts +90 -31
  116. package/src/monorepo/packs/index.test.ts +189 -0
  117. package/src/monorepo/packs/index.ts +314 -19
  118. package/src/monorepo/publish-workflow.ts +422 -0
  119. package/src/monorepo/setup-test-tracing.test.ts +120 -0
  120. package/src/monorepo/tool-validation.test.ts +261 -0
  121. package/src/monorepo/tool-validation.ts +462 -0
  122. package/src/nx/index.test.ts +64 -0
  123. package/src/nx/index.ts +119 -0
  124. package/src/nx-version-actions.cts +36 -0
  125. package/src/release/__tests__/bootstrap-npm-packages.test.ts +127 -0
  126. package/src/release/__tests__/candidates.test.ts +217 -0
  127. package/src/release/__tests__/core-properties.test.ts +149 -0
  128. package/src/release/__tests__/core-scenarios.test.ts +187 -0
  129. package/src/release/__tests__/core.test.ts +73 -0
  130. package/src/release/__tests__/fixture-repo.test.ts +314 -0
  131. package/src/release/__tests__/github-release.test.ts +149 -0
  132. package/src/release/__tests__/helpers/fixture-repo.ts +114 -0
  133. package/src/release/__tests__/npm-auth.test.ts +120 -0
  134. package/src/release/__tests__/orchestration.test.ts +246 -0
  135. package/src/release/__tests__/publish-plan.test.ts +67 -0
  136. package/src/release/__tests__/retag-unpublished.test.ts +160 -0
  137. package/src/release/__tests__/trust-publisher.test.ts +133 -0
  138. package/src/release/bootstrap-npm-packages.ts +88 -0
  139. package/src/release/candidates.ts +225 -0
  140. package/src/release/core.ts +171 -0
  141. package/src/release/github-release.ts +134 -0
  142. package/src/release/index.ts +974 -138
  143. package/src/release/npm-auth.ts +93 -0
  144. package/src/release/orchestration.ts +196 -0
  145. package/src/release/publish-plan.ts +37 -0
  146. package/src/release/retag-unpublished.ts +122 -0
  147. package/managed/raw/.git-format-staged.yml +0 -47
  148. package/managed/templates/github/workflows/publish.yml +0 -136
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ groupReleaseTargets,
4
+ npmDistTagForVersion,
5
+ pendingReleaseTargets,
6
+ type ReleasePackageInfo,
7
+ type ReleaseTagRecord,
8
+ releasePackageForTag,
9
+ } from '../core.js';
10
+
11
+ const a: ReleasePackageInfo = { name: '@scope/a', projectName: 'a', path: 'packages/a', version: '1.0.0' };
12
+ const b: ReleasePackageInfo = { name: '@scope/b', projectName: 'b', path: 'packages/b', version: '2.0.0-beta.1' };
13
+
14
+ function record(
15
+ pkg: ReleasePackageInfo,
16
+ sha: string,
17
+ timestamp: number,
18
+ needs: { npm?: boolean; github?: boolean },
19
+ ): ReleaseTagRecord {
20
+ return {
21
+ tag: `${pkg.name}@${pkg.version}`,
22
+ sha,
23
+ timestamp,
24
+ pkg,
25
+ needsNpmPublish: needs.npm === true,
26
+ needsGithubRelease: needs.github === true,
27
+ };
28
+ }
29
+
30
+ describe('release core planning', () => {
31
+ it('matches owned release tags without semantic version parsing', () => {
32
+ expect(releasePackageForTag([a], '@scope/a@1.2.3')?.version).toBe('1.2.3');
33
+ expect(releasePackageForTag([a], '@scope/c@1.2.3')).toBeNull();
34
+ });
35
+
36
+ it('groups only repair-needed tags by commit', () => {
37
+ const targets = groupReleaseTargets([record(a, 'aaa', 10, {}), record(b, 'aaa', 11, { npm: true })]);
38
+
39
+ expect(targets).toHaveLength(1);
40
+ expect(targets[0]?.packages.map((pkg) => pkg.name)).toEqual(['@scope/b']);
41
+ expect(targets[0]?.npmPackages.map((pkg) => pkg.name)).toEqual(['@scope/b']);
42
+ expect(targets[0]?.githubPackages).toEqual([]);
43
+ expect(targets[0]?.timestamp).toBe(11);
44
+ });
45
+
46
+ it('repairs the newest incomplete suffix oldest-to-newest and stops at the first complete target', () => {
47
+ const pending = pendingReleaseTargets(
48
+ [
49
+ record(a, 'newest', 30, { github: true }),
50
+ record(a, 'middle', 20, { npm: true }),
51
+ record(a, 'complete', 10, {}),
52
+ record(a, 'ignored-gap', 1, { npm: true, github: true }),
53
+ ],
54
+ 'head',
55
+ );
56
+
57
+ expect(pending.map((target) => target.sha)).toEqual(['middle', 'newest']);
58
+ });
59
+
60
+ it('excludes HEAD from older pending repairs', () => {
61
+ const pending = pendingReleaseTargets(
62
+ [record(a, 'head', 30, { npm: true }), record(a, 'older', 20, { github: true })],
63
+ 'head',
64
+ );
65
+
66
+ expect(pending.map((target) => target.sha)).toEqual(['older']);
67
+ });
68
+
69
+ it('uses package-version dist-tags instead of commit-level repair tags', () => {
70
+ expect(npmDistTagForVersion('1.0.0')).toBe('latest');
71
+ expect(npmDistTagForVersion('1.0.0-beta.1')).toBe('next');
72
+ });
73
+ });
@@ -0,0 +1,314 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { $ } from 'bun';
5
+ import { collectOwnedReleaseTagRecords, pendingReleaseTargets, type ReleasePackageInfo, releaseTag } from '../core.js';
6
+ import { completeReleaseAtHead, type ReleaseRepairShell, repairPendingTargets } from '../orchestration.js';
7
+ import {
8
+ git,
9
+ gitIsAncestor,
10
+ gitOutput,
11
+ gitReleaseTagsByCreatorDate,
12
+ packageVersionAtRef,
13
+ tag,
14
+ withFixtureRepo,
15
+ writeBuildablePackage,
16
+ writePackage,
17
+ writeWorkspace,
18
+ } from './helpers/fixture-repo.js';
19
+
20
+ describe('release planning with fixture git repositories', () => {
21
+ it('plans repairs from real annotated release tags and fake durable npm/GitHub state', async () => {
22
+ await withFixtureRepo(async (root) => {
23
+ await writePackage(root, '@scope/a', 'packages/a', '1.0.0');
24
+ await writePackage(root, '@scope/b', 'packages/b', '1.0.0');
25
+ await git(root, ['add', '.']);
26
+ await git(root, ['commit', '-m', 'initial release']);
27
+ const first = await gitOutput(root, ['rev-parse', 'HEAD']);
28
+ await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
29
+ await tag(root, '@scope/b@1.0.0', '2025-01-01T00:00:01Z');
30
+
31
+ await writePackage(root, '@scope/a', 'packages/a', '1.1.0');
32
+ await git(root, ['add', '.']);
33
+ await git(root, ['commit', '-m', 'second release']);
34
+ const second = await gitOutput(root, ['rev-parse', 'HEAD']);
35
+ await tag(root, '@scope/a@1.1.0', '2025-01-02T00:00:00Z');
36
+
37
+ await writeFile(join(root, 'readme.md'), 'not a release\n');
38
+ await git(root, ['add', '.']);
39
+ await git(root, ['commit', '-m', 'work after release']);
40
+ const head = await gitOutput(root, ['rev-parse', 'HEAD']);
41
+ await tag(root, 'not-owned@9.9.9', '2025-01-03T00:00:00Z');
42
+
43
+ const npmPublished = new Set(['@scope/a@1.0.0', '@scope/b@1.0.0']);
44
+ const githubReleases = new Set(['@scope/a@1.0.0']);
45
+ const records = await collectOwnedReleaseTagRecords(
46
+ [
47
+ { name: '@scope/a', projectName: 'a', path: 'packages/a' },
48
+ { name: '@scope/b', projectName: 'b', path: 'packages/b' },
49
+ ],
50
+ head,
51
+ {
52
+ listReleaseTagsByCreatorDate: () => gitReleaseTagsByCreatorDate(root),
53
+ isAncestor: (ancestor, descendant) => gitIsAncestor(root, ancestor, descendant),
54
+ packageVersionAtRef: (packagePath, ref) => packageVersionAtRef(root, packagePath, ref),
55
+ durableTagState: async (pkg, tagName) => ({
56
+ npmPublished: npmPublished.has(`${pkg.name}@${pkg.version}`),
57
+ githubReleaseExists: githubReleases.has(tagName),
58
+ }),
59
+ },
60
+ );
61
+
62
+ expect(records.map((record) => record.tag)).toEqual(['@scope/a@1.1.0', '@scope/b@1.0.0', '@scope/a@1.0.0']);
63
+ const pending = pendingReleaseTargets(records, head);
64
+ expect(pending.map((target) => target.sha)).toEqual([first, second]);
65
+ expect(pending[0]?.packages.map((pkg) => pkg.name)).toEqual(['@scope/b']);
66
+ expect(pending[0]?.npmPackages).toEqual([]);
67
+ expect(pending[0]?.githubPackages.map((pkg) => pkg.name)).toEqual(['@scope/b']);
68
+ expect(pending[1]?.npmPackages.map((pkg) => pkg.name)).toEqual(['@scope/a']);
69
+ });
70
+ });
71
+
72
+ it('collects release tags from a fetched remote checkout instead of walking local commit history', async () => {
73
+ await withFixtureRepo(async (root) => {
74
+ await writePackage(root, '@scope/a', 'packages/a', '1.0.0');
75
+ await git(root, ['add', '.']);
76
+ await git(root, ['commit', '-m', 'release a']);
77
+ await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
78
+ await git(root, ['init', '--bare', 'remote.git']);
79
+ await git(root, ['remote', 'add', 'origin', join(root, 'remote.git')]);
80
+ await git(root, ['push', 'origin', 'main', '--tags']);
81
+ await $`git clone --branch main ${join(root, 'remote.git')} checkout`.cwd(root).quiet();
82
+ const checkout = join(root, 'checkout');
83
+ await git(checkout, ['fetch', '--tags', 'origin', 'main']);
84
+ const head = await gitOutput(checkout, ['rev-parse', 'HEAD']);
85
+
86
+ const records = await collectOwnedReleaseTagRecords(
87
+ [{ name: '@scope/a', projectName: 'a', path: 'packages/a' }],
88
+ head,
89
+ {
90
+ listReleaseTagsByCreatorDate: () => gitReleaseTagsByCreatorDate(checkout),
91
+ isAncestor: (ancestor, descendant) => gitIsAncestor(checkout, ancestor, descendant),
92
+ packageVersionAtRef: (packagePath, ref) => packageVersionAtRef(checkout, packagePath, ref),
93
+ durableTagState: async () => ({ npmPublished: false, githubReleaseExists: false }),
94
+ },
95
+ );
96
+
97
+ expect(records.map((record) => record.tag)).toEqual(['@scope/a@1.0.0']);
98
+ expect(pendingReleaseTargets(records, 'not-head').map((target) => target.sha)).toEqual([head]);
99
+ });
100
+ });
101
+
102
+ it('runs real Nx builds for comma-separated npm-missing package projects', async () => {
103
+ await withFixtureRepo(async (root) => {
104
+ await writeWorkspace(root);
105
+ await writeBuildablePackage(root, '@scope/a', 'packages/a');
106
+ await writeBuildablePackage(root, '@scope/b', 'packages/b');
107
+
108
+ await $`nx run-many -t build --projects=${'a,b'}`.cwd(root).quiet();
109
+
110
+ await expect(readFile(join(root, 'packages/a/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
111
+ await expect(readFile(join(root, 'packages/b/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
112
+ });
113
+ });
114
+
115
+ it('repairs multiple fetched remote targets from a runner clone with real git checkout and Nx build', async () => {
116
+ await withFixtureRepo(async (author) => {
117
+ await writeWorkspace(author);
118
+ await writeBuildablePackage(author, '@scope/a', 'packages/a', '1.0.0');
119
+ await writeBuildablePackage(author, '@scope/b', 'packages/b', '1.0.0');
120
+ await git(author, ['add', '.']);
121
+ await git(author, ['commit', '-m', 'initial release']);
122
+ await tag(author, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
123
+ await tag(author, '@scope/b@1.0.0', '2025-01-01T00:00:01Z');
124
+
125
+ await writeBuildablePackage(author, '@scope/a', 'packages/a', '1.1.0');
126
+ await git(author, ['add', 'packages/a/package.json']);
127
+ await git(author, ['commit', '-m', 'release a 1.1.0']);
128
+ const githubOnlySha = await gitOutput(author, ['rev-parse', 'HEAD']);
129
+ await tag(author, '@scope/a@1.1.0', '2025-01-02T00:00:00Z');
130
+
131
+ await writeBuildablePackage(author, '@scope/b', 'packages/b', '2.0.0-beta.1');
132
+ await git(author, ['add', 'packages/b/package.json']);
133
+ await git(author, ['commit', '-m', 'release b prerelease']);
134
+ const npmAndGithubSha = await gitOutput(author, ['rev-parse', 'HEAD']);
135
+ await tag(author, '@scope/b@2.0.0-beta.1', '2025-01-03T00:00:00Z');
136
+
137
+ await writeBuildablePackage(author, '@scope/a', 'packages/a', '1.2.0');
138
+ await git(author, ['add', 'packages/a/package.json']);
139
+ await git(author, ['commit', '-m', 'head release a 1.2.0']);
140
+ const headSha = await gitOutput(author, ['rev-parse', 'HEAD']);
141
+ await tag(author, '@scope/a@1.2.0', '2025-01-04T00:00:00Z');
142
+
143
+ await git(author, ['init', '--bare', 'remote.git']);
144
+ await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
145
+ await git(author, ['push', 'origin', 'main', '--tags']);
146
+ await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
147
+ const runner = join(author, 'runner');
148
+ await git(runner, ['config', 'user.name', 'Test User']);
149
+ await git(runner, ['config', 'user.email', 'test@example.com']);
150
+ await git(runner, ['fetch', '--tags', 'origin', 'main']);
151
+ const restoreRef = 'origin/main';
152
+ const packages = releaseFixturePackages();
153
+ const npmPublished = new Set(['@scope/a@1.0.0', '@scope/b@1.0.0', '@scope/a@1.1.0']);
154
+ const githubReleases = new Set(['@scope/a@1.0.0', '@scope/b@1.0.0']);
155
+
156
+ const records = await collectOwnedReleaseTagRecords(packages, restoreRef, {
157
+ listReleaseTagsByCreatorDate: () => gitReleaseTagsByCreatorDate(runner),
158
+ isAncestor: (ancestor, descendant) => gitIsAncestor(runner, ancestor, descendant),
159
+ packageVersionAtRef: (packagePath, ref) => packageVersionAtRef(runner, packagePath, ref),
160
+ durableTagState: async (pkg, tagName) => ({
161
+ npmPublished: npmPublished.has(`${pkg.name}@${pkg.version}`),
162
+ githubReleaseExists: githubReleases.has(tagName),
163
+ }),
164
+ });
165
+ const pending = pendingReleaseTargets(records, headSha);
166
+
167
+ expect(pending.map((target) => target.sha)).toEqual([githubOnlySha, npmAndGithubSha]);
168
+ expect(pending[0]?.npmPackages).toEqual([]);
169
+ expect(pending[0]?.githubPackages.map((pkg) => `${pkg.name}@${pkg.version}`)).toEqual(['@scope/a@1.1.0']);
170
+ expect(pending[1]?.npmPackages.map((pkg) => `${pkg.name}@${pkg.version}`)).toEqual(['@scope/b@2.0.0-beta.1']);
171
+ expect(pending[1]?.githubPackages.map((pkg) => `${pkg.name}@${pkg.version}`)).toEqual(['@scope/b@2.0.0-beta.1']);
172
+
173
+ const shell = new LocalGitRepairShell(runner);
174
+ const summaries = await repairPendingTargets(shell, pending, restoreRef, false);
175
+
176
+ expect(shell.checkouts).toEqual([githubOnlySha, npmAndGithubSha, restoreRef]);
177
+ expect(shell.devenvLoads).toBe(2);
178
+ expect(shell.builds).toEqual([['@scope/b']]);
179
+ expect(shell.publishes).toEqual([{ name: '@scope/b', version: '2.0.0-beta.1', distTag: 'next', dryRun: false }]);
180
+ expect(shell.githubCreates).toEqual([
181
+ { name: '@scope/a', version: '1.1.0', dryRun: false },
182
+ { name: '@scope/b', version: '2.0.0-beta.1', dryRun: false },
183
+ ]);
184
+ expect(shell.pushes).toEqual([['@scope/a@1.1.0'], ['@scope/b@2.0.0-beta.1']]);
185
+ expect(summaries.map((summary) => summary.sha)).toEqual([githubOnlySha, npmAndGithubSha]);
186
+ await expect(readFile(join(runner, 'packages/b/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
187
+ await expect(readFile(join(runner, 'packages/a/dist/index.js'), 'utf8')).rejects.toThrow();
188
+ });
189
+ });
190
+
191
+ it('pushes current release refs to a local bare remote and another clone can fetch them', async () => {
192
+ await withFixtureRepo(async (author) => {
193
+ await writeWorkspace(author);
194
+ await writeBuildablePackage(author, '@scope/pushed', 'packages/pushed', '1.0.0');
195
+ await git(author, ['add', '.']);
196
+ await git(author, ['commit', '-m', 'release pushed package']);
197
+ await git(author, ['init', '--bare', 'remote.git']);
198
+ await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
199
+ await git(author, ['push', 'origin', 'main']);
200
+ await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
201
+ const runner = join(author, 'runner');
202
+ await git(runner, ['config', 'user.name', 'Test User']);
203
+ await git(runner, ['config', 'user.email', 'test@example.com']);
204
+ const pkg: ReleasePackageInfo = {
205
+ name: '@scope/pushed',
206
+ projectName: 'pushed',
207
+ path: 'packages/pushed',
208
+ version: '1.0.0',
209
+ };
210
+ const shell = new LocalGitRepairShell(runner);
211
+
212
+ const summary = await completeReleaseAtHead(shell, [pkg], false, false);
213
+
214
+ expect(summary.pushed).toBe(true);
215
+ expect(shell.pushes).toEqual([['@scope/pushed@1.0.0']]);
216
+ await $`git clone --branch main ${join(author, 'remote.git')} auditor`.cwd(author).quiet();
217
+ const auditor = join(author, 'auditor');
218
+ await git(auditor, ['fetch', '--tags', 'origin', 'main']);
219
+ await expect(gitOutput(auditor, ['rev-parse', 'refs/tags/@scope/pushed@1.0.0^{}'])).resolves.toBe(
220
+ await gitOutput(runner, ['rev-parse', 'HEAD']),
221
+ );
222
+ });
223
+ });
224
+ });
225
+
226
+ function releaseFixturePackages(): ReleasePackageInfo[] {
227
+ return [
228
+ { name: '@scope/a', projectName: 'a', path: 'packages/a', version: '0.0.0' },
229
+ { name: '@scope/b', projectName: 'b', path: 'packages/b', version: '0.0.0' },
230
+ ];
231
+ }
232
+
233
+ class LocalGitRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
234
+ readonly checkouts: string[] = [];
235
+ readonly pushes: string[][] = [];
236
+ readonly builds: string[][] = [];
237
+ readonly publishes: Array<{ name: string; version: string; distTag: string; dryRun: boolean }> = [];
238
+ readonly githubCreates: Array<{ name: string; version: string; dryRun: boolean }> = [];
239
+ devenvLoads = 0;
240
+
241
+ constructor(private readonly root: string) {}
242
+
243
+ async gitHead(): Promise<string> {
244
+ return gitOutput(this.root, ['rev-parse', 'HEAD']);
245
+ }
246
+
247
+ async pushReleaseRefs(packages: ReleasePackageInfo[]): Promise<boolean> {
248
+ this.pushes.push(packages.map((pkg) => `${pkg.name}@${pkg.version}`));
249
+ await git(this.root, ['fetch', '--tags', 'origin', 'main']);
250
+ for (const pkg of packages) {
251
+ await this.ensureLocalReleaseTag(pkg);
252
+ }
253
+ const refspecs: string[] = [];
254
+ const remoteRefExists = await gitSucceeds(this.root, ['rev-parse', '--verify', 'origin/main']);
255
+ if (!remoteRefExists || !(await gitIsAncestor(this.root, await this.gitHead(), 'origin/main'))) {
256
+ refspecs.push('HEAD:refs/heads/main');
257
+ }
258
+ for (const pkg of packages) {
259
+ const tagRef = `refs/tags/${releaseTag(pkg)}`;
260
+ if (!(await gitSucceeds(this.root, ['ls-remote', '--exit-code', '--tags', 'origin', tagRef]))) {
261
+ refspecs.push(`${tagRef}:${tagRef}`);
262
+ }
263
+ }
264
+ if (refspecs.length === 0) {
265
+ return false;
266
+ }
267
+ await git(this.root, ['push', '--atomic', 'origin', ...refspecs]);
268
+ return true;
269
+ }
270
+
271
+ async listNpmMissingPackages(): Promise<ReleasePackageInfo[]> {
272
+ return [];
273
+ }
274
+
275
+ async buildReleaseCandidate(packages: ReleasePackageInfo[]): Promise<void> {
276
+ this.builds.push(packages.map((pkg) => pkg.name));
277
+ await $`nx run-many -t build --projects=${packages.map((pkg) => pkg.projectName).join(',')}`.cwd(this.root).quiet();
278
+ }
279
+
280
+ async publishPackage(pkg: ReleasePackageInfo, distTag: string, dryRun: boolean): Promise<void> {
281
+ this.publishes.push({ name: pkg.name, version: pkg.version, distTag, dryRun });
282
+ }
283
+
284
+ async listGithubMissingPackages(): Promise<ReleasePackageInfo[]> {
285
+ return [];
286
+ }
287
+
288
+ async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<void> {
289
+ this.githubCreates.push({ name: pkg.name, version: pkg.version, dryRun });
290
+ }
291
+
292
+ async checkout(ref: string): Promise<void> {
293
+ await git(this.root, ['switch', '--detach', ref]);
294
+ this.checkouts.push(ref);
295
+ }
296
+
297
+ async withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T> {
298
+ this.devenvLoads += 1;
299
+ return runWithEnv();
300
+ }
301
+
302
+ private async ensureLocalReleaseTag(pkg: ReleasePackageInfo): Promise<void> {
303
+ const tagName = releaseTag(pkg);
304
+ if (await gitSucceeds(this.root, ['rev-parse', '--verify', `refs/tags/${tagName}`])) {
305
+ return;
306
+ }
307
+ await git(this.root, ['tag', '-a', tagName, '-m', tagName, 'HEAD']);
308
+ }
309
+ }
310
+
311
+ async function gitSucceeds(root: string, args: string[]): Promise<boolean> {
312
+ const result = await $`git ${args}`.cwd(root).quiet().nothrow();
313
+ return result.exitCode === 0;
314
+ }
@@ -0,0 +1,149 @@
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', projectName: 'pkg', path: 'packages/pkg', version: '1.2.3' };
12
+ const prerelease: ReleasePackageInfo = {
13
+ name: '@scope/pkg',
14
+ projectName: 'pkg',
15
+ path: 'packages/pkg',
16
+ version: '2.0.0-beta.1',
17
+ };
18
+
19
+ describe('GitHub release helpers', () => {
20
+ it('configures the Nx changelog API for render-only project changelogs', () => {
21
+ expect(nxProjectChangelogArgs(stable, '@scope/pkg@1.2.2', false)).toEqual({
22
+ version: '1.2.3',
23
+ projects: ['pkg'],
24
+ gitCommit: false,
25
+ gitTag: false,
26
+ gitPush: false,
27
+ stageChanges: false,
28
+ createRelease: false,
29
+ deleteVersionPlans: false,
30
+ dryRun: false,
31
+ from: '@scope/pkg@1.2.2',
32
+ });
33
+ });
34
+
35
+ it('marks the Nx changelog request as a first release when there is no previous tag', () => {
36
+ expect(nxProjectChangelogArgs(stable, null, true)).toEqual({
37
+ version: '1.2.3',
38
+ projects: ['pkg'],
39
+ gitCommit: false,
40
+ gitTag: false,
41
+ gitPush: false,
42
+ stageChanges: false,
43
+ createRelease: false,
44
+ deleteVersionPlans: false,
45
+ dryRun: true,
46
+ firstRelease: true,
47
+ });
48
+ });
49
+
50
+ it('extracts the generated Nx project changelog body', () => {
51
+ expect(
52
+ projectChangelogContents(
53
+ {
54
+ projectChangelogs: {
55
+ pkg: { contents: 'generated release notes' },
56
+ },
57
+ },
58
+ 'pkg',
59
+ ),
60
+ ).toBe('generated release notes');
61
+ });
62
+
63
+ it('fails when Nx omits the requested project changelog', () => {
64
+ expect(() => projectChangelogContents({ projectChangelogs: {} }, 'pkg')).toThrow(
65
+ 'Nx did not generate a project changelog for pkg.',
66
+ );
67
+ });
68
+
69
+ it('creates a missing stable GitHub Release from the generated notes file', async () => {
70
+ const shell = new RecordingGithubReleaseShell(false);
71
+
72
+ await createOrUpdateGithubRelease(stable, 'stable notes', shell);
73
+
74
+ expect(shell.existsQueries).toEqual(['@scope/pkg@1.2.3']);
75
+ expect(shell.notes).toEqual(['stable notes']);
76
+ expect(shell.logs).toEqual(['@scope/pkg@1.2.3: creating GitHub Release for @scope/pkg@1.2.3.']);
77
+ const command = onlyCommand(shell.commands);
78
+ expect(command.slice(0, 6)).toEqual([
79
+ 'release',
80
+ 'create',
81
+ '@scope/pkg@1.2.3',
82
+ '--title',
83
+ '@scope/pkg@1.2.3',
84
+ '--notes-file',
85
+ ]);
86
+ expect(command).toContain('--verify-tag');
87
+ expect(command).toContain('--latest=true');
88
+ expect(command).not.toContain('--prerelease');
89
+ });
90
+
91
+ it('edits an existing prerelease and prevents it from becoming latest', async () => {
92
+ const shell = new RecordingGithubReleaseShell(true);
93
+
94
+ await createOrUpdateGithubRelease(prerelease, 'prerelease notes', shell);
95
+
96
+ expect(shell.existsQueries).toEqual(['@scope/pkg@2.0.0-beta.1']);
97
+ expect(shell.notes).toEqual(['prerelease notes']);
98
+ expect(shell.logs).toEqual(['@scope/pkg@2.0.0-beta.1: updating GitHub Release for @scope/pkg@2.0.0-beta.1.']);
99
+ const command = onlyCommand(shell.commands);
100
+ expect(command.slice(0, 6)).toEqual([
101
+ 'release',
102
+ 'edit',
103
+ '@scope/pkg@2.0.0-beta.1',
104
+ '--title',
105
+ '@scope/pkg@2.0.0-beta.1',
106
+ '--notes-file',
107
+ ]);
108
+ expect(command).toContain('--verify-tag');
109
+ expect(command).toContain('--latest=false');
110
+ expect(command).toContain('--prerelease');
111
+ });
112
+ });
113
+
114
+ class RecordingGithubReleaseShell implements GithubReleaseWriteShell {
115
+ readonly existsQueries: string[] = [];
116
+ readonly commands: string[][] = [];
117
+ readonly notes: string[] = [];
118
+ readonly logs: string[] = [];
119
+
120
+ constructor(private readonly releaseExists: boolean) {}
121
+
122
+ async githubReleaseExists(tag: string): Promise<boolean> {
123
+ this.existsQueries.push(tag);
124
+ return this.releaseExists;
125
+ }
126
+
127
+ async runGhRelease(args: string[]): Promise<void> {
128
+ this.commands.push(args);
129
+ const notesFileIndex = args.indexOf('--notes-file') + 1;
130
+ const notesFile = args[notesFileIndex];
131
+ if (!notesFile) {
132
+ throw new Error(`Missing --notes-file in gh release command: ${args.join(' ')}`);
133
+ }
134
+ this.notes.push(await readFile(notesFile, 'utf8'));
135
+ }
136
+
137
+ log(message: string): void {
138
+ this.logs.push(message);
139
+ }
140
+ }
141
+
142
+ function onlyCommand(commands: string[][]): string[] {
143
+ expect(commands).toHaveLength(1);
144
+ const command = commands[0];
145
+ if (!command) {
146
+ throw new Error('Expected one gh command.');
147
+ }
148
+ return command;
149
+ }
@@ -0,0 +1,114 @@
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
+ name: name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name,
51
+ targets: {
52
+ build: {
53
+ executor: 'nx:run-commands',
54
+ options: { command: "mkdir -p dist && echo '{}' > dist/index.js", cwd: packagePath },
55
+ },
56
+ },
57
+ },
58
+ },
59
+ null,
60
+ 2,
61
+ )}\n`,
62
+ );
63
+ }
64
+
65
+ export async function git(root: string, args: string[], env?: Record<string, string>): Promise<void> {
66
+ await $`git ${args}`
67
+ .cwd(root)
68
+ .env(env ?? {})
69
+ .quiet();
70
+ }
71
+
72
+ export async function gitOutput(root: string, args: string[]): Promise<string> {
73
+ const result = await $`git ${args}`.cwd(root).quiet();
74
+ return new TextDecoder().decode(result.stdout).trim();
75
+ }
76
+
77
+ export async function tag(root: string, tagName: string, date: string): Promise<void> {
78
+ await git(root, ['tag', '-a', tagName, '-m', tagName], { GIT_COMMITTER_DATE: date });
79
+ }
80
+
81
+ export async function gitReleaseTagsByCreatorDate(root: string): Promise<GitReleaseTagInfo[]> {
82
+ const result =
83
+ await $`git for-each-ref --sort=-creatordate --format=${'%(refname:short)%09%(creatordate:unix)%09%(*objectname)%09%(objectname)'} refs/tags`
84
+ .cwd(root)
85
+ .quiet();
86
+ return new TextDecoder()
87
+ .decode(result.stdout)
88
+ .split('\n')
89
+ .map((line) => line.trim())
90
+ .filter(Boolean)
91
+ .map((line) => {
92
+ const [name, timestampText, peeledSha, objectSha] = line.split('\t');
93
+ const timestamp = Number(timestampText);
94
+ const sha = peeledSha || objectSha;
95
+ if (!name || !sha || !Number.isSafeInteger(timestamp)) {
96
+ throw new Error(`Unable to parse release tag ref: ${line}`);
97
+ }
98
+ return { name, sha, timestamp };
99
+ });
100
+ }
101
+
102
+ export async function gitIsAncestor(root: string, ancestor: string, descendant: string): Promise<boolean> {
103
+ const result = await $`git merge-base --is-ancestor ${ancestor} ${descendant}`.cwd(root).quiet().nothrow();
104
+ return result.exitCode === 0;
105
+ }
106
+
107
+ export async function packageVersionAtRef(root: string, packagePath: string, ref: string): Promise<string | null> {
108
+ const result = await $`git show ${`${ref}:${packagePath}/package.json`}`.cwd(root).quiet().nothrow();
109
+ if (result.exitCode !== 0) {
110
+ return null;
111
+ }
112
+ const parsed = JSON.parse(new TextDecoder().decode(result.stdout));
113
+ return typeof parsed.version === 'string' ? parsed.version : null;
114
+ }