@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,177 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ collectOwnedReleaseTagRecords,
4
+ type GitReleaseTagInfo,
5
+ groupReleaseTargets,
6
+ npmDistTagForVersion,
7
+ pendingReleaseTargets,
8
+ type ReleasePackageInfo,
9
+ type ReleasePlanningShell,
10
+ type ReleaseTagRecord,
11
+ } from '../core.js';
12
+
13
+ const stablePackage: ReleasePackageInfo = { name: '@scope/stable', path: 'packages/stable', version: '1.0.0' };
14
+ const prereleasePackage: ReleasePackageInfo = {
15
+ name: '@scope/prerelease',
16
+ path: 'packages/prerelease',
17
+ version: '2.0.0-beta.1',
18
+ };
19
+
20
+ function record(
21
+ pkg: ReleasePackageInfo,
22
+ sha: string,
23
+ timestamp: number,
24
+ needs: { npm?: boolean; github?: boolean },
25
+ ): ReleaseTagRecord {
26
+ return {
27
+ tag: `${pkg.name}@${pkg.version}`,
28
+ sha,
29
+ timestamp,
30
+ pkg,
31
+ needsNpmPublish: needs.npm === true,
32
+ needsGithubRelease: needs.github === true,
33
+ };
34
+ }
35
+
36
+ describe('release core scenario coverage', () => {
37
+ it('fails loudly for duplicate release tags for the same package on one commit', () => {
38
+ expect(() =>
39
+ groupReleaseTargets([
40
+ record(stablePackage, 'same-commit', 10, { npm: true }),
41
+ record({ ...stablePackage, version: '1.0.1' }, 'same-commit', 11, { github: true }),
42
+ ]),
43
+ ).toThrow('Release target same-commit has more than one release tag for @scope/stable.');
44
+ });
45
+
46
+ it('groups mixed stable and prerelease npm packages while dist-tags remain package-version specific', () => {
47
+ const targets = groupReleaseTargets([
48
+ record(stablePackage, 'mixed', 10, { npm: true }),
49
+ record(prereleasePackage, 'mixed', 11, { npm: true }),
50
+ ]);
51
+
52
+ expect(targets).toHaveLength(1);
53
+ expect(targets[0]?.npmPackages.map((pkg) => pkg.name)).toEqual(['@scope/stable', '@scope/prerelease']);
54
+ expect(targets[0]?.npmPackages.map((pkg) => [pkg.name, npmDistTagForVersion(pkg.version)])).toEqual([
55
+ ['@scope/stable', 'latest'],
56
+ ['@scope/prerelease', 'next'],
57
+ ]);
58
+ });
59
+
60
+ it('skips a complete HEAD and stops at the next older complete target so older gaps are ignored', () => {
61
+ const pending = pendingReleaseTargets(
62
+ [
63
+ record(stablePackage, 'head', 30, {}),
64
+ record(stablePackage, 'complete', 20, {}),
65
+ record(stablePackage, 'ignored-gap', 10, { npm: true, github: true }),
66
+ ],
67
+ 'head',
68
+ );
69
+
70
+ expect(pending).toEqual([]);
71
+ });
72
+
73
+ it('sorts targets by newest tag timestamp before pending reverses them oldest-first', () => {
74
+ const records = [
75
+ record(stablePackage, 'older-target', 10, { npm: true }),
76
+ record(prereleasePackage, 'newer-target', 30, { github: true }),
77
+ record({ ...stablePackage, version: '1.0.1' }, 'newer-target', 40, { npm: true }),
78
+ ];
79
+
80
+ expect(groupReleaseTargets(records).map((target) => target.sha)).toEqual(['newer-target', 'older-target']);
81
+ expect(pendingReleaseTargets(records, 'head').map((target) => target.sha)).toEqual([
82
+ 'older-target',
83
+ 'newer-target',
84
+ ]);
85
+ });
86
+
87
+ it('ignores unrelated tags and tags not reachable from the selected ref through shell callbacks', async () => {
88
+ const packageVersionRefs: string[] = [];
89
+ const durableTags: string[] = [];
90
+ const versions = new Map([
91
+ ['packages/stable:reachable', '1.0.0'],
92
+ ['packages/stable:unreachable', '1.0.1'],
93
+ ]);
94
+ const shell: ReleasePlanningShell = {
95
+ listReleaseTagsByCreatorDate: async () => [
96
+ tag('@scope/stable@1.0.0', 'reachable', 30),
97
+ tag('@other/pkg@9.9.9', 'reachable', 20),
98
+ tag('@scope/stable@1.0.1', 'unreachable', 10),
99
+ ],
100
+ isAncestor: async (ancestor) => ancestor === 'reachable',
101
+ packageVersionAtRef: async (packagePath, ref) => {
102
+ packageVersionRefs.push(`${packagePath}:${ref}`);
103
+ return versions.get(`${packagePath}:${ref}`) ?? null;
104
+ },
105
+ durableTagState: async (_pkg, tagName) => {
106
+ durableTags.push(tagName);
107
+ return { npmPublished: false, githubReleaseExists: true };
108
+ },
109
+ };
110
+
111
+ const records = await collectOwnedReleaseTagRecords(
112
+ [{ name: stablePackage.name, path: stablePackage.path }],
113
+ 'selected-ref',
114
+ shell,
115
+ );
116
+
117
+ expect(records.map((releaseRecord) => releaseRecord.tag)).toEqual(['@scope/stable@1.0.0']);
118
+ expect(packageVersionRefs).toEqual(['packages/stable:reachable']);
119
+ expect(durableTags).toEqual(['@scope/stable@1.0.0']);
120
+ });
121
+
122
+ it('stops querying older package tags after the newest tag for that package is fully durable', async () => {
123
+ const durableTags: string[] = [];
124
+ const versions = new Map([
125
+ ['packages/stable:newer-stable', '1.1.0'],
126
+ ['packages/stable:older-stable', '1.0.0'],
127
+ ['packages/prerelease:older-prerelease', '2.0.0-beta.1'],
128
+ ]);
129
+ const shell: ReleasePlanningShell = {
130
+ listReleaseTagsByCreatorDate: async () => [
131
+ tag('@scope/stable@1.1.0', 'newer-stable', 30),
132
+ tag('@scope/prerelease@2.0.0-beta.1', 'older-prerelease', 20),
133
+ tag('@scope/stable@1.0.0', 'older-stable', 10),
134
+ ],
135
+ isAncestor: async () => true,
136
+ packageVersionAtRef: async (packagePath, ref) => versions.get(`${packagePath}:${ref}`) ?? null,
137
+ durableTagState: async (_pkg, tagName) => {
138
+ durableTags.push(tagName);
139
+ return { npmPublished: true, githubReleaseExists: true };
140
+ },
141
+ };
142
+
143
+ const records = await collectOwnedReleaseTagRecords(
144
+ [
145
+ { name: stablePackage.name, path: stablePackage.path },
146
+ { name: prereleasePackage.name, path: prereleasePackage.path },
147
+ ],
148
+ 'selected-ref',
149
+ shell,
150
+ );
151
+
152
+ expect(records.map((releaseRecord) => releaseRecord.tag)).toEqual([
153
+ '@scope/stable@1.1.0',
154
+ '@scope/prerelease@2.0.0-beta.1',
155
+ ]);
156
+ expect(durableTags).toEqual(['@scope/stable@1.1.0', '@scope/prerelease@2.0.0-beta.1']);
157
+ });
158
+
159
+ it('throws when a release tag version does not match package.json at the peeled commit', async () => {
160
+ const shell: ReleasePlanningShell = {
161
+ listReleaseTagsByCreatorDate: async () => [tag('@scope/stable@1.0.0', 'mismatch', 10)],
162
+ isAncestor: async () => true,
163
+ packageVersionAtRef: async () => '1.0.1',
164
+ durableTagState: async () => ({ npmPublished: true, githubReleaseExists: true }),
165
+ };
166
+
167
+ await expect(
168
+ collectOwnedReleaseTagRecords([{ name: stablePackage.name, path: stablePackage.path }], 'selected-ref', shell),
169
+ ).rejects.toThrow(
170
+ 'Release tag @scope/stable@1.0.0 points at mismatch, but packages/stable/package.json has version 1.0.1.',
171
+ );
172
+ });
173
+ });
174
+
175
+ function tag(name: string, sha: string, timestamp: number): GitReleaseTagInfo {
176
+ return { name, sha, timestamp };
177
+ }
@@ -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', path: 'packages/a', version: '1.0.0' };
12
+ const b: ReleasePackageInfo = { name: '@scope/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,305 @@
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', path: 'packages/a' },
48
+ { name: '@scope/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([{ name: '@scope/a', path: 'packages/a' }], head, {
87
+ listReleaseTagsByCreatorDate: () => gitReleaseTagsByCreatorDate(checkout),
88
+ isAncestor: (ancestor, descendant) => gitIsAncestor(checkout, ancestor, descendant),
89
+ packageVersionAtRef: (packagePath, ref) => packageVersionAtRef(checkout, packagePath, ref),
90
+ durableTagState: async () => ({ npmPublished: false, githubReleaseExists: false }),
91
+ });
92
+
93
+ expect(records.map((record) => record.tag)).toEqual(['@scope/a@1.0.0']);
94
+ expect(pendingReleaseTargets(records, 'not-head').map((target) => target.sha)).toEqual([head]);
95
+ });
96
+ });
97
+
98
+ it('runs real Nx builds for comma-separated npm-missing package projects', async () => {
99
+ await withFixtureRepo(async (root) => {
100
+ await writeWorkspace(root);
101
+ await writeBuildablePackage(root, '@scope/a', 'packages/a');
102
+ await writeBuildablePackage(root, '@scope/b', 'packages/b');
103
+
104
+ await $`nx run-many -t build --projects=${'@scope/a,@scope/b'}`.cwd(root).quiet();
105
+
106
+ await expect(readFile(join(root, 'packages/a/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
107
+ await expect(readFile(join(root, 'packages/b/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
108
+ });
109
+ });
110
+
111
+ it('repairs multiple fetched remote targets from a runner clone with real git checkout and Nx build', async () => {
112
+ await withFixtureRepo(async (author) => {
113
+ await writeWorkspace(author);
114
+ await writeBuildablePackage(author, '@scope/a', 'packages/a', '1.0.0');
115
+ await writeBuildablePackage(author, '@scope/b', 'packages/b', '1.0.0');
116
+ await git(author, ['add', '.']);
117
+ await git(author, ['commit', '-m', 'initial release']);
118
+ await tag(author, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
119
+ await tag(author, '@scope/b@1.0.0', '2025-01-01T00:00:01Z');
120
+
121
+ await writeBuildablePackage(author, '@scope/a', 'packages/a', '1.1.0');
122
+ await git(author, ['add', 'packages/a/package.json']);
123
+ await git(author, ['commit', '-m', 'release a 1.1.0']);
124
+ const githubOnlySha = await gitOutput(author, ['rev-parse', 'HEAD']);
125
+ await tag(author, '@scope/a@1.1.0', '2025-01-02T00:00:00Z');
126
+
127
+ await writeBuildablePackage(author, '@scope/b', 'packages/b', '2.0.0-beta.1');
128
+ await git(author, ['add', 'packages/b/package.json']);
129
+ await git(author, ['commit', '-m', 'release b prerelease']);
130
+ const npmAndGithubSha = await gitOutput(author, ['rev-parse', 'HEAD']);
131
+ await tag(author, '@scope/b@2.0.0-beta.1', '2025-01-03T00:00:00Z');
132
+
133
+ await writeBuildablePackage(author, '@scope/a', 'packages/a', '1.2.0');
134
+ await git(author, ['add', 'packages/a/package.json']);
135
+ await git(author, ['commit', '-m', 'head release a 1.2.0']);
136
+ const headSha = await gitOutput(author, ['rev-parse', 'HEAD']);
137
+ await tag(author, '@scope/a@1.2.0', '2025-01-04T00:00:00Z');
138
+
139
+ await git(author, ['init', '--bare', 'remote.git']);
140
+ await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
141
+ await git(author, ['push', 'origin', 'main', '--tags']);
142
+ await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
143
+ const runner = join(author, 'runner');
144
+ await git(runner, ['config', 'user.name', 'Test User']);
145
+ await git(runner, ['config', 'user.email', 'test@example.com']);
146
+ await git(runner, ['fetch', '--tags', 'origin', 'main']);
147
+ const restoreRef = 'origin/main';
148
+ const packages = releaseFixturePackages();
149
+ const npmPublished = new Set(['@scope/a@1.0.0', '@scope/b@1.0.0', '@scope/a@1.1.0']);
150
+ const githubReleases = new Set(['@scope/a@1.0.0', '@scope/b@1.0.0']);
151
+
152
+ const records = await collectOwnedReleaseTagRecords(packages, restoreRef, {
153
+ listReleaseTagsByCreatorDate: () => gitReleaseTagsByCreatorDate(runner),
154
+ isAncestor: (ancestor, descendant) => gitIsAncestor(runner, ancestor, descendant),
155
+ packageVersionAtRef: (packagePath, ref) => packageVersionAtRef(runner, packagePath, ref),
156
+ durableTagState: async (pkg, tagName) => ({
157
+ npmPublished: npmPublished.has(`${pkg.name}@${pkg.version}`),
158
+ githubReleaseExists: githubReleases.has(tagName),
159
+ }),
160
+ });
161
+ const pending = pendingReleaseTargets(records, headSha);
162
+
163
+ expect(pending.map((target) => target.sha)).toEqual([githubOnlySha, npmAndGithubSha]);
164
+ expect(pending[0]?.npmPackages).toEqual([]);
165
+ expect(pending[0]?.githubPackages.map((pkg) => `${pkg.name}@${pkg.version}`)).toEqual(['@scope/a@1.1.0']);
166
+ expect(pending[1]?.npmPackages.map((pkg) => `${pkg.name}@${pkg.version}`)).toEqual(['@scope/b@2.0.0-beta.1']);
167
+ expect(pending[1]?.githubPackages.map((pkg) => `${pkg.name}@${pkg.version}`)).toEqual(['@scope/b@2.0.0-beta.1']);
168
+
169
+ const shell = new LocalGitRepairShell(runner);
170
+ const summaries = await repairPendingTargets(shell, pending, restoreRef, false);
171
+
172
+ expect(shell.checkouts).toEqual([githubOnlySha, npmAndGithubSha, restoreRef]);
173
+ expect(shell.devenvLoads).toBe(2);
174
+ expect(shell.builds).toEqual([['@scope/b']]);
175
+ expect(shell.publishes).toEqual([{ name: '@scope/b', version: '2.0.0-beta.1', distTag: 'next', dryRun: false }]);
176
+ expect(shell.githubCreates).toEqual([
177
+ { name: '@scope/a', version: '1.1.0', dryRun: false },
178
+ { name: '@scope/b', version: '2.0.0-beta.1', dryRun: false },
179
+ ]);
180
+ expect(shell.pushes).toEqual([['@scope/a@1.1.0'], ['@scope/b@2.0.0-beta.1']]);
181
+ expect(summaries.map((summary) => summary.sha)).toEqual([githubOnlySha, npmAndGithubSha]);
182
+ await expect(readFile(join(runner, 'packages/b/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
183
+ await expect(readFile(join(runner, 'packages/a/dist/index.js'), 'utf8')).rejects.toThrow();
184
+ });
185
+ });
186
+
187
+ it('pushes current release refs to a local bare remote and another clone can fetch them', async () => {
188
+ await withFixtureRepo(async (author) => {
189
+ await writeWorkspace(author);
190
+ await writeBuildablePackage(author, '@scope/pushed', 'packages/pushed', '1.0.0');
191
+ await git(author, ['add', '.']);
192
+ await git(author, ['commit', '-m', 'release pushed package']);
193
+ await git(author, ['init', '--bare', 'remote.git']);
194
+ await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
195
+ await git(author, ['push', 'origin', 'main']);
196
+ await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
197
+ const runner = join(author, 'runner');
198
+ await git(runner, ['config', 'user.name', 'Test User']);
199
+ await git(runner, ['config', 'user.email', 'test@example.com']);
200
+ const pkg: ReleasePackageInfo = { name: '@scope/pushed', path: 'packages/pushed', version: '1.0.0' };
201
+ const shell = new LocalGitRepairShell(runner);
202
+
203
+ const summary = await completeReleaseAtHead(shell, [pkg], false, false);
204
+
205
+ expect(summary.pushed).toBe(true);
206
+ expect(shell.pushes).toEqual([['@scope/pushed@1.0.0']]);
207
+ await $`git clone --branch main ${join(author, 'remote.git')} auditor`.cwd(author).quiet();
208
+ const auditor = join(author, 'auditor');
209
+ await git(auditor, ['fetch', '--tags', 'origin', 'main']);
210
+ await expect(gitOutput(auditor, ['rev-parse', 'refs/tags/@scope/pushed@1.0.0^{}'])).resolves.toBe(
211
+ await gitOutput(runner, ['rev-parse', 'HEAD']),
212
+ );
213
+ });
214
+ });
215
+ });
216
+
217
+ function releaseFixturePackages(): ReleasePackageInfo[] {
218
+ return [
219
+ { name: '@scope/a', path: 'packages/a', version: '0.0.0' },
220
+ { name: '@scope/b', path: 'packages/b', version: '0.0.0' },
221
+ ];
222
+ }
223
+
224
+ class LocalGitRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
225
+ readonly checkouts: string[] = [];
226
+ readonly pushes: string[][] = [];
227
+ readonly builds: string[][] = [];
228
+ readonly publishes: Array<{ name: string; version: string; distTag: string; dryRun: boolean }> = [];
229
+ readonly githubCreates: Array<{ name: string; version: string; dryRun: boolean }> = [];
230
+ devenvLoads = 0;
231
+
232
+ constructor(private readonly root: string) {}
233
+
234
+ async gitHead(): Promise<string> {
235
+ return gitOutput(this.root, ['rev-parse', 'HEAD']);
236
+ }
237
+
238
+ async pushReleaseRefs(packages: ReleasePackageInfo[]): Promise<boolean> {
239
+ this.pushes.push(packages.map((pkg) => `${pkg.name}@${pkg.version}`));
240
+ await git(this.root, ['fetch', '--tags', 'origin', 'main']);
241
+ for (const pkg of packages) {
242
+ await this.ensureLocalReleaseTag(pkg);
243
+ }
244
+ const refspecs: string[] = [];
245
+ const remoteRefExists = await gitSucceeds(this.root, ['rev-parse', '--verify', 'origin/main']);
246
+ if (!remoteRefExists || !(await gitIsAncestor(this.root, await this.gitHead(), 'origin/main'))) {
247
+ refspecs.push('HEAD:refs/heads/main');
248
+ }
249
+ for (const pkg of packages) {
250
+ const tagRef = `refs/tags/${releaseTag(pkg)}`;
251
+ if (!(await gitSucceeds(this.root, ['ls-remote', '--exit-code', '--tags', 'origin', tagRef]))) {
252
+ refspecs.push(`${tagRef}:${tagRef}`);
253
+ }
254
+ }
255
+ if (refspecs.length === 0) {
256
+ return false;
257
+ }
258
+ await git(this.root, ['push', '--atomic', 'origin', ...refspecs]);
259
+ return true;
260
+ }
261
+
262
+ async listNpmMissingPackages(): Promise<ReleasePackageInfo[]> {
263
+ return [];
264
+ }
265
+
266
+ async buildReleaseCandidate(packages: ReleasePackageInfo[]): Promise<void> {
267
+ this.builds.push(packages.map((pkg) => pkg.name));
268
+ await $`nx run-many -t build --projects=${packages.map((pkg) => pkg.name).join(',')}`.cwd(this.root).quiet();
269
+ }
270
+
271
+ async publishPackage(pkg: ReleasePackageInfo, distTag: string, dryRun: boolean): Promise<void> {
272
+ this.publishes.push({ name: pkg.name, version: pkg.version, distTag, dryRun });
273
+ }
274
+
275
+ async listGithubMissingPackages(): Promise<ReleasePackageInfo[]> {
276
+ return [];
277
+ }
278
+
279
+ async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<void> {
280
+ this.githubCreates.push({ name: pkg.name, version: pkg.version, dryRun });
281
+ }
282
+
283
+ async checkout(ref: string): Promise<void> {
284
+ await git(this.root, ['switch', '--detach', ref]);
285
+ this.checkouts.push(ref);
286
+ }
287
+
288
+ async withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T> {
289
+ this.devenvLoads += 1;
290
+ return runWithEnv();
291
+ }
292
+
293
+ private async ensureLocalReleaseTag(pkg: ReleasePackageInfo): Promise<void> {
294
+ const tagName = releaseTag(pkg);
295
+ if (await gitSucceeds(this.root, ['rev-parse', '--verify', `refs/tags/${tagName}`])) {
296
+ return;
297
+ }
298
+ await git(this.root, ['tag', '-a', tagName, '-m', tagName, 'HEAD']);
299
+ }
300
+ }
301
+
302
+ async function gitSucceeds(root: string, args: string[]): Promise<boolean> {
303
+ const result = await $`git ${args}`.cwd(root).quiet().nothrow();
304
+ return result.exitCode === 0;
305
+ }