@smoothbricks/cli 0.2.0 → 0.3.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 (68) hide show
  1. package/README.md +29 -24
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +19 -3
  4. package/dist/generate/index.d.ts +20 -0
  5. package/dist/generate/index.d.ts.map +1 -0
  6. package/dist/generate/index.js +47 -0
  7. package/dist/lib/run.d.ts +1 -0
  8. package/dist/lib/run.d.ts.map +1 -1
  9. package/dist/lib/run.js +19 -0
  10. package/dist/lib/workspace.d.ts +10 -0
  11. package/dist/lib/workspace.d.ts.map +1 -1
  12. package/dist/lib/workspace.js +55 -16
  13. package/dist/monorepo/lockfile.d.ts.map +1 -1
  14. package/dist/monorepo/lockfile.js +9 -0
  15. package/dist/monorepo/package-policy.d.ts +6 -17
  16. package/dist/monorepo/package-policy.d.ts.map +1 -1
  17. package/dist/monorepo/package-policy.js +63 -1127
  18. package/dist/monorepo/publish-workflow.js +2 -3
  19. package/dist/nx/index.d.ts +1 -0
  20. package/dist/nx/index.d.ts.map +1 -1
  21. package/dist/nx/index.js +23 -5
  22. package/dist/release/candidates.d.ts +1 -0
  23. package/dist/release/candidates.d.ts.map +1 -1
  24. package/dist/release/candidates.js +12 -0
  25. package/dist/release/core.d.ts +3 -1
  26. package/dist/release/core.d.ts.map +1 -1
  27. package/dist/release/core.js +76 -9
  28. package/dist/release/github-release.d.ts +1 -0
  29. package/dist/release/github-release.d.ts.map +1 -1
  30. package/dist/release/github-release.js +1 -1
  31. package/dist/release/index.d.ts +9 -3
  32. package/dist/release/index.d.ts.map +1 -1
  33. package/dist/release/index.js +287 -39
  34. package/dist/release/orchestration.d.ts +10 -2
  35. package/dist/release/orchestration.d.ts.map +1 -1
  36. package/dist/release/orchestration.js +33 -3
  37. package/managed/raw/tooling/git-hooks/commit-msg.sh +7 -1
  38. package/managed/raw/tooling/git-hooks/pre-commit.sh +7 -1
  39. package/managed/templates/github/workflows/ci.yml +3 -2
  40. package/package.json +7 -12
  41. package/src/cli.ts +21 -4
  42. package/src/generate/index.ts +92 -0
  43. package/src/lib/run.ts +25 -0
  44. package/src/lib/workspace.ts +72 -16
  45. package/src/monorepo/lockfile.ts +9 -0
  46. package/src/monorepo/package-policy.test.ts +254 -26
  47. package/src/monorepo/package-policy.ts +88 -1351
  48. package/src/monorepo/publish-workflow.ts +2 -3
  49. package/src/nx/index.test.ts +9 -0
  50. package/src/nx/index.ts +25 -5
  51. package/src/release/__tests__/candidates.test.ts +109 -8
  52. package/src/release/__tests__/core-properties.test.ts +2 -2
  53. package/src/release/__tests__/core-scenarios.test.ts +12 -15
  54. package/src/release/__tests__/core.test.ts +50 -3
  55. package/src/release/__tests__/fixture-repo.test.ts +68 -18
  56. package/src/release/__tests__/github-release.test.ts +9 -16
  57. package/src/release/__tests__/orchestration.test.ts +91 -5
  58. package/src/release/__tests__/retag-unpublished.test.ts +9 -9
  59. package/src/release/__tests__/trust-publisher.test.ts +65 -33
  60. package/src/release/candidates.ts +13 -0
  61. package/src/release/core.ts +95 -10
  62. package/src/release/github-release.ts +1 -1
  63. package/src/release/index.ts +366 -49
  64. package/src/release/orchestration.ts +50 -5
  65. package/dist/nx-version-actions.cjs +0 -25
  66. package/dist/nx-version-actions.d.cts +0 -6
  67. package/dist/nx-version-actions.d.cts.map +0 -1
  68. package/src/nx-version-actions.cts +0 -36
@@ -341,8 +341,7 @@ function yamlLinesForStep(step: PublishWorkflowStep): string[] {
341
341
  case PublishWorkflowStepKind.BuildSelfHostedCli:
342
342
  return [
343
343
  ` - name: ${step.name}`,
344
- ' # SmoothBricks self-hosts smoo from source, but Nx release loads',
345
- ' # @smoothbricks/cli/nx-version-actions through the built package export.',
344
+ ' # SmoothBricks self-hosts smoo from source for release commands.',
346
345
  ' run: nx build cli',
347
346
  ];
348
347
  case PublishWorkflowStepKind.RepairPendingReleases:
@@ -379,7 +378,7 @@ function yamlLinesForStep(step: PublishWorkflowStep): string[] {
379
378
  return [
380
379
  ` - name: ${step.name}`,
381
380
  ` if: ${githubExpression('failure()')}`,
382
- ' uses: actions/upload-artifact@v4',
381
+ ' uses: actions/upload-artifact@v7.0.1',
383
382
  ' with:',
384
383
  ` name: trace-results-${githubExpression('github.run_id')}`,
385
384
  ' path: packages/*/.trace-results.db',
@@ -5,6 +5,7 @@ import {
5
5
  nxCacheDirectories,
6
6
  nxResetCommand,
7
7
  nxShowProjectCommand,
8
+ projectNamesFromNxShowProjectsOutput,
8
9
  projectNamesWithTarget,
9
10
  targetNamesFromNxProjectJson,
10
11
  } from './index.js';
@@ -50,6 +51,14 @@ describe('Nx helper output formatting', () => {
50
51
  ).toEqual(['cli', 'web']);
51
52
  });
52
53
 
54
+ it('parses JSON project lists from nx show projects', () => {
55
+ expect(projectNamesFromNxShowProjectsOutput('["web","cli"]\n')).toEqual(['cli', 'web']);
56
+ });
57
+
58
+ it('parses legacy newline project lists from nx show projects', () => {
59
+ expect(projectNamesFromNxShowProjectsOutput('web\ncli\n')).toEqual(['cli', 'web']);
60
+ });
61
+
53
62
  it('extracts sorted target names from Nx project JSON', () => {
54
63
  expect(targetNamesFromNxProjectJson({ targets: { test: {}, build: {}, lint: {} } })).toEqual([
55
64
  'build',
package/src/nx/index.ts CHANGED
@@ -56,6 +56,30 @@ export function projectNamesWithTarget(projects: ProjectTargets[], target: strin
56
56
  .sort((a, b) => a.localeCompare(b));
57
57
  }
58
58
 
59
+ export function projectNamesFromNxShowProjectsOutput(output: string): string[] {
60
+ const trimmed = output.trim();
61
+ if (!trimmed) {
62
+ return [];
63
+ }
64
+
65
+ if (trimmed.startsWith('[')) {
66
+ try {
67
+ const parsed: unknown = JSON.parse(trimmed);
68
+ if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
69
+ return parsed.sort((a, b) => a.localeCompare(b));
70
+ }
71
+ } catch {
72
+ // Fall through to legacy newline parsing for older Nx output shapes.
73
+ }
74
+ }
75
+
76
+ return trimmed
77
+ .split('\n')
78
+ .map((line) => line.trim())
79
+ .filter(Boolean)
80
+ .sort((a, b) => a.localeCompare(b));
81
+ }
82
+
59
83
  export async function listTargets(root: string): Promise<void> {
60
84
  const output = formatProjectTargetLines(await readProjectTargets(root));
61
85
  if (output) {
@@ -100,11 +124,7 @@ export async function readProjectTargets(root: string): Promise<ProjectTargets[]
100
124
 
101
125
  async function readNxProjectNames(root: string): Promise<string[]> {
102
126
  const result = await $`nx show projects`.cwd(root).quiet();
103
- return decode(result.stdout)
104
- .split('\n')
105
- .map((line) => line.trim())
106
- .filter(Boolean)
107
- .sort((a, b) => a.localeCompare(b));
127
+ return projectNamesFromNxShowProjectsOutput(decode(result.stdout));
108
128
  }
109
129
 
110
130
  async function readProjectTarget(root: string, project: string): Promise<ProjectTargets> {
@@ -11,6 +11,13 @@ const a: ReleasePackageInfo = { name: '@scope/a', projectName: 'a', path: 'packa
11
11
  const b: ReleasePackageInfo = { name: '@scope/b', projectName: 'b', path: 'packages/b', version: '1.0.0' };
12
12
  const c: ReleasePackageInfo = { name: '@scope/c', projectName: 'c', path: 'packages/c', version: '0.1.0' };
13
13
  const d: ReleasePackageInfo = { name: '@scope/d', projectName: 'd', path: 'packages/d', version: '0.1.0' };
14
+ const cli: ReleasePackageInfo = { name: '@scope/cli', projectName: 'cli', path: 'packages/cli', version: '0.2.0' };
15
+ const next: ReleasePackageInfo = {
16
+ name: '@scope/next',
17
+ projectName: 'next',
18
+ path: 'packages/next',
19
+ version: '1.1.0-next.0',
20
+ };
14
21
 
15
22
  describe('auto release candidate filtering', () => {
16
23
  it('selects tagged packages only when their package path changed', async () => {
@@ -19,8 +26,8 @@ describe('auto release candidate filtering', () => {
19
26
  await writePackage(root, b.name, b.path, b.version);
20
27
  await git(root, ['add', '.']);
21
28
  await git(root, ['commit', '-m', 'initial packages']);
22
- await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
23
- await tag(root, '@scope/b@1.0.0', '2025-01-01T00:00:01Z');
29
+ await tag(root, 'a@1.0.0', '2025-01-01T00:00:00Z');
30
+ await tag(root, 'b@1.0.0', '2025-01-01T00:00:01Z');
24
31
 
25
32
  await writeFile(join(root, 'README.md'), 'root-only change\n');
26
33
  await git(root, ['add', 'README.md']);
@@ -42,7 +49,7 @@ describe('auto release candidate filtering', () => {
42
49
  await writePackage(root, a.name, a.path, a.version);
43
50
  await git(root, ['add', '.']);
44
51
  await git(root, ['commit', '-m', 'initial package']);
45
- await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
52
+ await tag(root, 'a@1.0.0', '2025-01-01T00:00:00Z');
46
53
 
47
54
  await mkdir(join(root, a.path), { recursive: true });
48
55
  await writeFile(
@@ -72,7 +79,7 @@ describe('auto release candidate filtering', () => {
72
79
  await writePackage(root, a.name, a.path, a.version);
73
80
  await git(root, ['add', '.']);
74
81
  await git(root, ['commit', '-m', 'initial package']);
75
- await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
82
+ await tag(root, 'a@1.0.0', '2025-01-01T00:00:00Z');
76
83
 
77
84
  await writeFile(
78
85
  join(root, a.path, 'package.json'),
@@ -105,7 +112,7 @@ describe('auto release candidate filtering', () => {
105
112
  await writeFile(join(root, a.path, 'schema/index.json'), '{}\n');
106
113
  await git(root, ['add', '.']);
107
114
  await git(root, ['commit', '-m', 'initial package']);
108
- await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
115
+ await tag(root, 'a@1.0.0', '2025-01-01T00:00:00Z');
109
116
 
110
117
  await writeFile(join(root, a.path, 'schema/index.json'), '{"changed":true}\n');
111
118
  await git(root, ['add', join(a.path, 'schema/index.json')]);
@@ -120,7 +127,7 @@ describe('auto release candidate filtering', () => {
120
127
  await writePackage(root, a.name, a.path, a.version);
121
128
  await git(root, ['add', '.']);
122
129
  await git(root, ['commit', '-m', 'initial package']);
123
- await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
130
+ await tag(root, 'a@1.0.0', '2025-01-01T00:00:00Z');
124
131
 
125
132
  await mkdir(join(root, a.path, 'generated'), { recursive: true });
126
133
  await writeFile(join(root, a.path, 'generated/schema.ts'), 'export const schema = 1;\n');
@@ -138,7 +145,7 @@ describe('auto release candidate filtering', () => {
138
145
  await writePackage(root, a.name, a.path, a.version);
139
146
  await git(root, ['add', '.']);
140
147
  await git(root, ['commit', '-m', 'initial package']);
141
- await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
148
+ await tag(root, 'a@1.0.0', '2025-01-01T00:00:00Z');
142
149
 
143
150
  const shell = gitCandidateShell(root, { buildInputPatterns: ['src/**/*.ts', '!src/**/*.test.ts'] });
144
151
 
@@ -167,14 +174,108 @@ describe('auto release candidate filtering', () => {
167
174
  await expect(autoReleaseCandidatePackages(gitCandidateShell(root), [c, d])).resolves.toEqual([c]);
168
175
  });
169
176
  });
177
+
178
+ it('does not auto-release an untagged next prerelease package version with no prior stable release', async () => {
179
+ await withFixtureRepo(async (root) => {
180
+ await writePackage(root, next.name, next.path, next.version);
181
+ await git(root, ['add', '.']);
182
+ await git(root, ['commit', '-m', 'chore(release): prepare next prerelease']);
183
+
184
+ await expect(autoReleaseCandidatePackages(gitCandidateShell(root), [next])).resolves.toEqual([]);
185
+ });
186
+ });
187
+
188
+ it('selects a prerelease package when releasable changes exist since the last stable tag', async () => {
189
+ await withFixtureRepo(async (root) => {
190
+ await writePackage(root, next.name, next.path, '1.0.0');
191
+ await git(root, ['add', '.']);
192
+ await git(root, ['commit', '-m', 'chore(release): publish']);
193
+ await tag(root, 'next@1.0.0', '2025-01-01T00:00:00Z');
194
+
195
+ // Simulate the post-release prerelease bump
196
+ await writePackage(root, next.name, next.path, next.version);
197
+ await git(root, ['add', '.']);
198
+ await git(root, ['commit', '-m', 'chore(release): prepare next prerelease']);
199
+
200
+ // A real fix lands on the prerelease version
201
+ await mkdir(join(root, next.path, 'src'), { recursive: true });
202
+ await writeFile(join(root, next.path, 'src/index.ts'), 'export const fix = true;\n');
203
+ await git(root, ['add', join(next.path, 'src/index.ts')]);
204
+ await git(root, ['commit', '-m', 'fix(next): repair something']);
205
+
206
+ await expect(autoReleaseCandidatePackages(gitCandidateShell(root), [next])).resolves.toEqual([next]);
207
+ });
208
+ });
209
+
210
+ it('does not select a prerelease package when no releasable changes exist since the last stable tag', async () => {
211
+ await withFixtureRepo(async (root) => {
212
+ await writePackage(root, next.name, next.path, '1.0.0');
213
+ await mkdir(join(root, next.path, 'src'), { recursive: true });
214
+ await writeFile(join(root, next.path, 'src/index.ts'), 'export const v1 = true;\n');
215
+ await git(root, ['add', '.']);
216
+ await git(root, ['commit', '-m', 'chore(release): publish']);
217
+ await tag(root, 'next@1.0.0', '2025-01-01T00:00:00Z');
218
+
219
+ // Only the prerelease bump, no releasable source changes
220
+ await writePackage(root, next.name, next.path, next.version);
221
+ await git(root, ['add', '.']);
222
+ await git(root, ['commit', '-m', 'chore(release): prepare next prerelease']);
223
+
224
+ await expect(autoReleaseCandidatePackages(gitCandidateShell(root), [next])).resolves.toEqual([]);
225
+ });
226
+ });
227
+
228
+ it('uses a repaired project-name tag as the follow-up auto release baseline for a scoped package', async () => {
229
+ await withFixtureRepo(async (root) => {
230
+ await writePackage(root, cli.name, cli.path, cli.version);
231
+ await git(root, ['add', '.']);
232
+ await git(root, ['commit', '-m', 'chore(release): publish']);
233
+ await tag(root, 'cli@0.2.0', '2025-01-01T00:00:00Z');
234
+
235
+ await mkdir(join(root, cli.path, 'src'), { recursive: true });
236
+ await writeFile(join(root, cli.path, 'src/index.ts'), 'export const fixed = true;\n');
237
+ await git(root, ['add', join(cli.path, 'src/index.ts')]);
238
+ await git(root, ['commit', '-m', 'fix(cli): repair publish flow']);
239
+
240
+ const queriedRefs: string[] = [];
241
+ const candidates = await autoReleaseCandidatePackages(
242
+ gitCandidateShell(root, { buildInputPatterns: ['src/**/*.ts'], queriedRefs }),
243
+ [cli],
244
+ );
245
+
246
+ expect(candidates).toEqual([cli]);
247
+ expect(queriedRefs).toEqual(['refs/tags/cli@0.2.0']);
248
+ expect(queriedRefs).not.toContain('refs/tags/@scope/cli@0.2.0');
249
+ });
250
+ });
170
251
  });
171
252
 
172
- function gitCandidateShell(root: string, options: { buildInputPatterns?: string[] } = {}): AutoReleaseCandidateShell {
253
+ function gitCandidateShell(
254
+ root: string,
255
+ options: { buildInputPatterns?: string[]; queriedRefs?: string[] } = {},
256
+ ): AutoReleaseCandidateShell {
173
257
  return {
174
258
  gitRefExists: async (ref) => {
259
+ options.queriedRefs?.push(ref);
175
260
  const result = await $`git rev-parse --verify ${ref}`.cwd(root).quiet().nothrow();
176
261
  return result.exitCode === 0;
177
262
  },
263
+ latestStableReleaseRef: async (projectName) => {
264
+ const pattern = `${projectName}@*`;
265
+ const result = await $`git tag --list ${pattern} --sort=-v:refname`.cwd(root).quiet().nothrow();
266
+ if (result.exitCode !== 0) {
267
+ return null;
268
+ }
269
+ const prefix = `${projectName}@`;
270
+ for (const line of new TextDecoder().decode(result.stdout).split('\n')) {
271
+ const tagName = line.trim();
272
+ const version = tagName.slice(prefix.length);
273
+ if (version && !version.includes('-')) {
274
+ return `refs/tags/${tagName}`;
275
+ }
276
+ }
277
+ return null;
278
+ },
178
279
  packageChangedFilesSince: async (ref, packagePath) => {
179
280
  const result = await $`git diff --name-only ${`${ref}..HEAD`} -- ${packagePath}`.cwd(root).quiet().nothrow();
180
281
  if (result.exitCode === 0) {
@@ -122,7 +122,7 @@ function releaseRecords(): fc.Arbitrary<ReleaseTagRecord[]> {
122
122
  }
123
123
  seen.add(key);
124
124
  records.push({
125
- tag: `${pkg.name}@${pkg.version}`,
125
+ tag: `${pkg.projectName}@${pkg.version}`,
126
126
  sha,
127
127
  timestamp: row.timestamp,
128
128
  pkg,
@@ -139,7 +139,7 @@ function record(pkg: ReleasePackageInfo | undefined, sha: string): ReleaseTagRec
139
139
  throw new Error('Missing test package fixture.');
140
140
  }
141
141
  return {
142
- tag: `${pkg.name}@${pkg.version}`,
142
+ tag: `${pkg.projectName}@${pkg.version}`,
143
143
  sha,
144
144
  timestamp: 1,
145
145
  pkg,
@@ -30,7 +30,7 @@ function record(
30
30
  needs: { npm?: boolean; github?: boolean },
31
31
  ): ReleaseTagRecord {
32
32
  return {
33
- tag: `${pkg.name}@${pkg.version}`,
33
+ tag: `${pkg.projectName}@${pkg.version}`,
34
34
  sha,
35
35
  timestamp,
36
36
  pkg,
@@ -99,9 +99,9 @@ describe('release core scenario coverage', () => {
99
99
  ]);
100
100
  const shell: ReleasePlanningShell = {
101
101
  listReleaseTagsByCreatorDate: async () => [
102
- tag('@scope/stable@1.0.0', 'reachable', 30),
102
+ tag('stable@1.0.0', 'reachable', 30),
103
103
  tag('@other/pkg@9.9.9', 'reachable', 20),
104
- tag('@scope/stable@1.0.1', 'unreachable', 10),
104
+ tag('stable@1.0.1', 'unreachable', 10),
105
105
  ],
106
106
  isAncestor: async (ancestor) => ancestor === 'reachable',
107
107
  packageVersionAtRef: async (packagePath, ref) => {
@@ -120,9 +120,9 @@ describe('release core scenario coverage', () => {
120
120
  shell,
121
121
  );
122
122
 
123
- expect(records.map((releaseRecord) => releaseRecord.tag)).toEqual(['@scope/stable@1.0.0']);
123
+ expect(records.map((releaseRecord) => releaseRecord.tag)).toEqual(['stable@1.0.0']);
124
124
  expect(packageVersionRefs).toEqual(['packages/stable:reachable']);
125
- expect(durableTags).toEqual(['@scope/stable@1.0.0']);
125
+ expect(durableTags).toEqual(['stable@1.0.0']);
126
126
  });
127
127
 
128
128
  it('stops querying older package tags after the newest tag for that package is fully durable', async () => {
@@ -134,9 +134,9 @@ describe('release core scenario coverage', () => {
134
134
  ]);
135
135
  const shell: ReleasePlanningShell = {
136
136
  listReleaseTagsByCreatorDate: async () => [
137
- tag('@scope/stable@1.1.0', 'newer-stable', 30),
138
- tag('@scope/prerelease@2.0.0-beta.1', 'older-prerelease', 20),
139
- tag('@scope/stable@1.0.0', 'older-stable', 10),
137
+ tag('stable@1.1.0', 'newer-stable', 30),
138
+ tag('prerelease@2.0.0-beta.1', 'older-prerelease', 20),
139
+ tag('stable@1.0.0', 'older-stable', 10),
140
140
  ],
141
141
  isAncestor: async () => true,
142
142
  packageVersionAtRef: async (packagePath, ref) => versions.get(`${packagePath}:${ref}`) ?? null,
@@ -155,16 +155,13 @@ describe('release core scenario coverage', () => {
155
155
  shell,
156
156
  );
157
157
 
158
- expect(records.map((releaseRecord) => releaseRecord.tag)).toEqual([
159
- '@scope/stable@1.1.0',
160
- '@scope/prerelease@2.0.0-beta.1',
161
- ]);
162
- expect(durableTags).toEqual(['@scope/stable@1.1.0', '@scope/prerelease@2.0.0-beta.1']);
158
+ expect(records.map((releaseRecord) => releaseRecord.tag)).toEqual(['stable@1.1.0', 'prerelease@2.0.0-beta.1']);
159
+ expect(durableTags).toEqual(['stable@1.1.0', 'prerelease@2.0.0-beta.1']);
163
160
  });
164
161
 
165
162
  it('throws when a release tag version does not match package.json at the peeled commit', async () => {
166
163
  const shell: ReleasePlanningShell = {
167
- listReleaseTagsByCreatorDate: async () => [tag('@scope/stable@1.0.0', 'mismatch', 10)],
164
+ listReleaseTagsByCreatorDate: async () => [tag('stable@1.0.0', 'mismatch', 10)],
168
165
  isAncestor: async () => true,
169
166
  packageVersionAtRef: async () => '1.0.1',
170
167
  durableTagState: async () => ({ npmPublished: true, githubReleaseExists: true }),
@@ -177,7 +174,7 @@ describe('release core scenario coverage', () => {
177
174
  shell,
178
175
  ),
179
176
  ).rejects.toThrow(
180
- 'Release tag @scope/stable@1.0.0 points at mismatch, but packages/stable/package.json has version 1.0.1.',
177
+ 'Release tag stable@1.0.0 points at mismatch, but packages/stable/package.json has version 1.0.1.',
181
178
  );
182
179
  });
183
180
  });
@@ -1,11 +1,15 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
  import {
3
+ collectOwnedReleaseTagRecords,
3
4
  groupReleaseTargets,
5
+ legacyReleaseTag,
4
6
  npmDistTagForVersion,
5
7
  pendingReleaseTargets,
6
8
  type ReleasePackageInfo,
7
9
  type ReleaseTagRecord,
8
10
  releasePackageForTag,
11
+ releaseTag,
12
+ releaseTagAliases,
9
13
  } from '../core.js';
10
14
 
11
15
  const a: ReleasePackageInfo = { name: '@scope/a', projectName: 'a', path: 'packages/a', version: '1.0.0' };
@@ -18,7 +22,7 @@ function record(
18
22
  needs: { npm?: boolean; github?: boolean },
19
23
  ): ReleaseTagRecord {
20
24
  return {
21
- tag: `${pkg.name}@${pkg.version}`,
25
+ tag: `${pkg.projectName}@${pkg.version}`,
22
26
  sha,
23
27
  timestamp,
24
28
  pkg,
@@ -29,8 +33,9 @@ function record(
29
33
 
30
34
  describe('release core planning', () => {
31
35
  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();
36
+ expect(releasePackageForTag([a], 'a@1.2.3')?.version).toBe('1.2.3');
37
+ expect(releasePackageForTag([a], '@scope/a@1.2.3')).toBeNull();
38
+ expect(releasePackageForTag([a], 'c@1.2.3')).toBeNull();
34
39
  });
35
40
 
36
41
  it('groups only repair-needed tags by commit', () => {
@@ -70,4 +75,46 @@ describe('release core planning', () => {
70
75
  expect(npmDistTagForVersion('1.0.0')).toBe('latest');
71
76
  expect(npmDistTagForVersion('1.0.0-beta.1')).toBe('next');
72
77
  });
78
+
79
+ it('keeps project-name release tags canonical while recognizing legacy package-name aliases', () => {
80
+ const scoped = { name: '@scope/a', projectName: 'a', version: '1.0.0' };
81
+
82
+ expect(releaseTag(scoped)).toBe('a@1.0.0');
83
+ expect(legacyReleaseTag(scoped)).toBe('@scope/a@1.0.0');
84
+ expect(releaseTagAliases(scoped)).toEqual(['a@1.0.0', '@scope/a@1.0.0']);
85
+ });
86
+
87
+ it('checks durable release state concurrently while preserving tag-order records', async () => {
88
+ const durableCalls: string[] = [];
89
+ let active = 0;
90
+ let maxActive = 0;
91
+ let unblockDurableCalls: (() => void) | undefined;
92
+ const durableCallsBlocked = new Promise<void>((resolve) => {
93
+ unblockDurableCalls = resolve;
94
+ });
95
+
96
+ const records = await collectOwnedReleaseTagRecords([a, b], 'head', {
97
+ listReleaseTagsByCreatorDate: async () => [
98
+ { name: 'a@1.0.0', sha: 'newer', timestamp: 2 },
99
+ { name: 'b@2.0.0-beta.1', sha: 'older', timestamp: 1 },
100
+ ],
101
+ isAncestor: async () => true,
102
+ packageVersionAtRef: async (packagePath) => (packagePath === a.path ? a.version : b.version),
103
+ durableTagState: async (_pkg, tag) => {
104
+ durableCalls.push(tag);
105
+ active += 1;
106
+ maxActive = Math.max(maxActive, active);
107
+ if (durableCalls.length === 2) {
108
+ unblockDurableCalls?.();
109
+ }
110
+ await durableCallsBlocked;
111
+ active -= 1;
112
+ return { npmPublished: false, githubReleaseExists: false };
113
+ },
114
+ });
115
+
116
+ expect(durableCalls).toEqual(['a@1.0.0', 'b@2.0.0-beta.1']);
117
+ expect(maxActive).toBeGreaterThan(1);
118
+ expect(records.map((releaseRecord) => releaseRecord.tag)).toEqual(['a@1.0.0', 'b@2.0.0-beta.1']);
119
+ });
73
120
  });
@@ -25,14 +25,14 @@ describe('release planning with fixture git repositories', () => {
25
25
  await git(root, ['add', '.']);
26
26
  await git(root, ['commit', '-m', 'initial release']);
27
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');
28
+ await tag(root, 'a@1.0.0', '2025-01-01T00:00:00Z');
29
+ await tag(root, 'b@1.0.0', '2025-01-01T00:00:01Z');
30
30
 
31
31
  await writePackage(root, '@scope/a', 'packages/a', '1.1.0');
32
32
  await git(root, ['add', '.']);
33
33
  await git(root, ['commit', '-m', 'second release']);
34
34
  const second = await gitOutput(root, ['rev-parse', 'HEAD']);
35
- await tag(root, '@scope/a@1.1.0', '2025-01-02T00:00:00Z');
35
+ await tag(root, 'a@1.1.0', '2025-01-02T00:00:00Z');
36
36
 
37
37
  await writeFile(join(root, 'readme.md'), 'not a release\n');
38
38
  await git(root, ['add', '.']);
@@ -41,7 +41,7 @@ describe('release planning with fixture git repositories', () => {
41
41
  await tag(root, 'not-owned@9.9.9', '2025-01-03T00:00:00Z');
42
42
 
43
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']);
44
+ const githubReleases = new Set(['a@1.0.0']);
45
45
  const records = await collectOwnedReleaseTagRecords(
46
46
  [
47
47
  { name: '@scope/a', projectName: 'a', path: 'packages/a' },
@@ -59,7 +59,7 @@ describe('release planning with fixture git repositories', () => {
59
59
  },
60
60
  );
61
61
 
62
- expect(records.map((record) => record.tag)).toEqual(['@scope/a@1.1.0', '@scope/b@1.0.0', '@scope/a@1.0.0']);
62
+ expect(records.map((record) => record.tag)).toEqual(['a@1.1.0', 'b@1.0.0', 'a@1.0.0']);
63
63
  const pending = pendingReleaseTargets(records, head);
64
64
  expect(pending.map((target) => target.sha)).toEqual([first, second]);
65
65
  expect(pending[0]?.packages.map((pkg) => pkg.name)).toEqual(['@scope/b']);
@@ -74,7 +74,7 @@ describe('release planning with fixture git repositories', () => {
74
74
  await writePackage(root, '@scope/a', 'packages/a', '1.0.0');
75
75
  await git(root, ['add', '.']);
76
76
  await git(root, ['commit', '-m', 'release a']);
77
- await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
77
+ await tag(root, 'a@1.0.0', '2025-01-01T00:00:00Z');
78
78
  await git(root, ['init', '--bare', 'remote.git']);
79
79
  await git(root, ['remote', 'add', 'origin', join(root, 'remote.git')]);
80
80
  await git(root, ['push', 'origin', 'main', '--tags']);
@@ -94,7 +94,7 @@ describe('release planning with fixture git repositories', () => {
94
94
  },
95
95
  );
96
96
 
97
- expect(records.map((record) => record.tag)).toEqual(['@scope/a@1.0.0']);
97
+ expect(records.map((record) => record.tag)).toEqual(['a@1.0.0']);
98
98
  expect(pendingReleaseTargets(records, 'not-head').map((target) => target.sha)).toEqual([head]);
99
99
  });
100
100
  });
@@ -119,26 +119,26 @@ describe('release planning with fixture git repositories', () => {
119
119
  await writeBuildablePackage(author, '@scope/b', 'packages/b', '1.0.0');
120
120
  await git(author, ['add', '.']);
121
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');
122
+ await tag(author, 'a@1.0.0', '2025-01-01T00:00:00Z');
123
+ await tag(author, 'b@1.0.0', '2025-01-01T00:00:01Z');
124
124
 
125
125
  await writeBuildablePackage(author, '@scope/a', 'packages/a', '1.1.0');
126
126
  await git(author, ['add', 'packages/a/package.json']);
127
127
  await git(author, ['commit', '-m', 'release a 1.1.0']);
128
128
  const githubOnlySha = await gitOutput(author, ['rev-parse', 'HEAD']);
129
- await tag(author, '@scope/a@1.1.0', '2025-01-02T00:00:00Z');
129
+ await tag(author, 'a@1.1.0', '2025-01-02T00:00:00Z');
130
130
 
131
131
  await writeBuildablePackage(author, '@scope/b', 'packages/b', '2.0.0-beta.1');
132
132
  await git(author, ['add', 'packages/b/package.json']);
133
133
  await git(author, ['commit', '-m', 'release b prerelease']);
134
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');
135
+ await tag(author, 'b@2.0.0-beta.1', '2025-01-03T00:00:00Z');
136
136
 
137
137
  await writeBuildablePackage(author, '@scope/a', 'packages/a', '1.2.0');
138
138
  await git(author, ['add', 'packages/a/package.json']);
139
139
  await git(author, ['commit', '-m', 'head release a 1.2.0']);
140
140
  const headSha = await gitOutput(author, ['rev-parse', 'HEAD']);
141
- await tag(author, '@scope/a@1.2.0', '2025-01-04T00:00:00Z');
141
+ await tag(author, 'a@1.2.0', '2025-01-04T00:00:00Z');
142
142
 
143
143
  await git(author, ['init', '--bare', 'remote.git']);
144
144
  await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
@@ -151,7 +151,7 @@ describe('release planning with fixture git repositories', () => {
151
151
  const restoreRef = 'origin/main';
152
152
  const packages = releaseFixturePackages();
153
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']);
154
+ const githubReleases = new Set(['a@1.0.0', 'b@1.0.0']);
155
155
 
156
156
  const records = await collectOwnedReleaseTagRecords(packages, restoreRef, {
157
157
  listReleaseTagsByCreatorDate: () => gitReleaseTagsByCreatorDate(runner),
@@ -181,7 +181,7 @@ describe('release planning with fixture git repositories', () => {
181
181
  { name: '@scope/a', version: '1.1.0', dryRun: false },
182
182
  { name: '@scope/b', version: '2.0.0-beta.1', dryRun: false },
183
183
  ]);
184
- expect(shell.pushes).toEqual([['@scope/a@1.1.0'], ['@scope/b@2.0.0-beta.1']]);
184
+ expect(shell.pushes).toEqual([['a@1.1.0'], ['b@2.0.0-beta.1']]);
185
185
  expect(summaries.map((summary) => summary.sha)).toEqual([githubOnlySha, npmAndGithubSha]);
186
186
  await expect(readFile(join(runner, 'packages/b/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
187
187
  await expect(readFile(join(runner, 'packages/a/dist/index.js'), 'utf8')).rejects.toThrow();
@@ -212,13 +212,62 @@ describe('release planning with fixture git repositories', () => {
212
212
  const summary = await completeReleaseAtHead(shell, [pkg], false, false);
213
213
 
214
214
  expect(summary.pushed).toBe(true);
215
- expect(shell.pushes).toEqual([['@scope/pushed@1.0.0']]);
215
+ expect(shell.pushes).toEqual([['pushed@1.0.0']]);
216
216
  await $`git clone --branch main ${join(author, 'remote.git')} auditor`.cwd(author).quiet();
217
217
  const auditor = join(author, 'auditor');
218
218
  await git(auditor, ['fetch', '--tags', 'origin', 'main']);
219
- await expect(gitOutput(auditor, ['rev-parse', 'refs/tags/@scope/pushed@1.0.0^{}'])).resolves.toBe(
219
+ await expect(gitOutput(auditor, ['rev-parse', 'refs/tags/pushed@1.0.0^{}'])).resolves.toBe(
220
220
  await gitOutput(runner, ['rev-parse', 'HEAD']),
221
221
  );
222
+ await expect(gitSucceeds(auditor, ['rev-parse', '--verify', 'refs/tags/@scope/pushed@1.0.0'])).resolves.toBe(
223
+ false,
224
+ );
225
+ });
226
+ });
227
+
228
+ it('repairs a scoped package from its project-name release tag without creating a package-name tag', async () => {
229
+ await withFixtureRepo(async (author) => {
230
+ await writeWorkspace(author);
231
+ await writeBuildablePackage(author, '@scope/cli', 'packages/cli', '0.2.0');
232
+ await git(author, ['add', '.']);
233
+ await git(author, ['commit', '-m', 'release cli 0.2.0']);
234
+ const releaseSha = await gitOutput(author, ['rev-parse', 'HEAD']);
235
+ await tag(author, 'cli@0.2.0', '2025-01-01T00:00:00Z');
236
+
237
+ await git(author, ['init', '--bare', 'remote.git']);
238
+ await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
239
+ await git(author, ['push', 'origin', 'main', '--tags']);
240
+ await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
241
+ const runner = join(author, 'runner');
242
+ await git(runner, ['config', 'user.name', 'Test User']);
243
+ await git(runner, ['config', 'user.email', 'test@example.com']);
244
+ await git(runner, ['fetch', '--tags', 'origin', 'main']);
245
+
246
+ const pkg: ReleasePackageInfo = {
247
+ name: '@scope/cli',
248
+ projectName: 'cli',
249
+ path: 'packages/cli',
250
+ version: '0.0.0',
251
+ };
252
+ const records = await collectOwnedReleaseTagRecords([pkg], 'origin/main', {
253
+ listReleaseTagsByCreatorDate: () => gitReleaseTagsByCreatorDate(runner),
254
+ isAncestor: (ancestor, descendant) => gitIsAncestor(runner, ancestor, descendant),
255
+ packageVersionAtRef: (packagePath, ref) => packageVersionAtRef(runner, packagePath, ref),
256
+ durableTagState: async () => ({ npmPublished: false, githubReleaseExists: false }),
257
+ });
258
+ const pending = pendingReleaseTargets(records, 'not-head');
259
+
260
+ const shell = new LocalGitRepairShell(runner);
261
+ const summaries = await repairPendingTargets(shell, pending, 'origin/main', false);
262
+
263
+ expect(records.map((record) => record.tag)).toEqual(['cli@0.2.0']);
264
+ expect(pending.map((target) => target.sha)).toEqual([releaseSha]);
265
+ expect(shell.pushes).toEqual([['cli@0.2.0']]);
266
+ expect(shell.publishes).toEqual([{ name: '@scope/cli', version: '0.2.0', distTag: 'latest', dryRun: false }]);
267
+ expect(shell.githubCreates).toEqual([{ name: '@scope/cli', version: '0.2.0', dryRun: false }]);
268
+ expect(summaries[0]?.packages.map((releasePackage) => releasePackage.name)).toEqual(['@scope/cli']);
269
+ await expect(gitSucceeds(runner, ['rev-parse', '--verify', 'refs/tags/cli@0.2.0'])).resolves.toBe(true);
270
+ await expect(gitSucceeds(runner, ['rev-parse', '--verify', 'refs/tags/@scope/cli@0.2.0'])).resolves.toBe(false);
222
271
  });
223
272
  });
224
273
  });
@@ -245,7 +294,7 @@ class LocalGitRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
245
294
  }
246
295
 
247
296
  async pushReleaseRefs(packages: ReleasePackageInfo[]): Promise<boolean> {
248
- this.pushes.push(packages.map((pkg) => `${pkg.name}@${pkg.version}`));
297
+ this.pushes.push(packages.map((pkg) => releaseTag(pkg)));
249
298
  await git(this.root, ['fetch', '--tags', 'origin', 'main']);
250
299
  for (const pkg of packages) {
251
300
  await this.ensureLocalReleaseTag(pkg);
@@ -285,8 +334,9 @@ class LocalGitRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
285
334
  return [];
286
335
  }
287
336
 
288
- async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<void> {
337
+ async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<string | null> {
289
338
  this.githubCreates.push({ name: pkg.name, version: pkg.version, dryRun });
339
+ return dryRun ? null : `https://github.test/${releaseTag(pkg)}`;
290
340
  }
291
341
 
292
342
  async checkout(ref: string): Promise<void> {