@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.
- package/README.md +29 -24
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +19 -3
- package/dist/generate/index.d.ts +20 -0
- package/dist/generate/index.d.ts.map +1 -0
- package/dist/generate/index.js +47 -0
- package/dist/lib/run.d.ts +1 -0
- package/dist/lib/run.d.ts.map +1 -1
- package/dist/lib/run.js +19 -0
- package/dist/lib/workspace.d.ts +10 -0
- package/dist/lib/workspace.d.ts.map +1 -1
- package/dist/lib/workspace.js +55 -16
- package/dist/monorepo/lockfile.d.ts.map +1 -1
- package/dist/monorepo/lockfile.js +9 -0
- package/dist/monorepo/package-policy.d.ts +6 -17
- package/dist/monorepo/package-policy.d.ts.map +1 -1
- package/dist/monorepo/package-policy.js +63 -1127
- package/dist/monorepo/publish-workflow.js +2 -3
- package/dist/nx/index.d.ts +1 -0
- package/dist/nx/index.d.ts.map +1 -1
- package/dist/nx/index.js +23 -5
- package/dist/release/candidates.d.ts +1 -0
- package/dist/release/candidates.d.ts.map +1 -1
- package/dist/release/candidates.js +12 -0
- package/dist/release/core.d.ts +3 -1
- package/dist/release/core.d.ts.map +1 -1
- package/dist/release/core.js +76 -9
- package/dist/release/github-release.d.ts +1 -0
- package/dist/release/github-release.d.ts.map +1 -1
- package/dist/release/github-release.js +1 -1
- package/dist/release/index.d.ts +9 -3
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +287 -39
- package/dist/release/orchestration.d.ts +10 -2
- package/dist/release/orchestration.d.ts.map +1 -1
- package/dist/release/orchestration.js +33 -3
- package/managed/raw/tooling/git-hooks/commit-msg.sh +7 -1
- package/managed/raw/tooling/git-hooks/pre-commit.sh +7 -1
- package/managed/templates/github/workflows/ci.yml +3 -2
- package/package.json +7 -12
- package/src/cli.ts +21 -4
- package/src/generate/index.ts +92 -0
- package/src/lib/run.ts +25 -0
- package/src/lib/workspace.ts +72 -16
- package/src/monorepo/lockfile.ts +9 -0
- package/src/monorepo/package-policy.test.ts +254 -26
- package/src/monorepo/package-policy.ts +88 -1351
- package/src/monorepo/publish-workflow.ts +2 -3
- package/src/nx/index.test.ts +9 -0
- package/src/nx/index.ts +25 -5
- package/src/release/__tests__/candidates.test.ts +109 -8
- package/src/release/__tests__/core-properties.test.ts +2 -2
- package/src/release/__tests__/core-scenarios.test.ts +12 -15
- package/src/release/__tests__/core.test.ts +50 -3
- package/src/release/__tests__/fixture-repo.test.ts +68 -18
- package/src/release/__tests__/github-release.test.ts +9 -16
- package/src/release/__tests__/orchestration.test.ts +91 -5
- package/src/release/__tests__/retag-unpublished.test.ts +9 -9
- package/src/release/__tests__/trust-publisher.test.ts +65 -33
- package/src/release/candidates.ts +13 -0
- package/src/release/core.ts +95 -10
- package/src/release/github-release.ts +1 -1
- package/src/release/index.ts +366 -49
- package/src/release/orchestration.ts +50 -5
- package/dist/nx-version-actions.cjs +0 -25
- package/dist/nx-version-actions.d.cts +0 -6
- package/dist/nx-version-actions.d.cts.map +0 -1
- 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
|
|
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@
|
|
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',
|
package/src/nx/index.test.ts
CHANGED
|
@@ -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, '
|
|
23
|
-
await tag(root, '
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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('
|
|
102
|
+
tag('stable@1.0.0', 'reachable', 30),
|
|
103
103
|
tag('@other/pkg@9.9.9', 'reachable', 20),
|
|
104
|
-
tag('
|
|
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(['
|
|
123
|
+
expect(records.map((releaseRecord) => releaseRecord.tag)).toEqual(['stable@1.0.0']);
|
|
124
124
|
expect(packageVersionRefs).toEqual(['packages/stable:reachable']);
|
|
125
|
-
expect(durableTags).toEqual(['
|
|
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('
|
|
138
|
-
tag('
|
|
139
|
-
tag('
|
|
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
|
-
|
|
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('
|
|
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
|
|
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.
|
|
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], '
|
|
33
|
-
expect(releasePackageForTag([a], '@scope/
|
|
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, '
|
|
29
|
-
await tag(root, '
|
|
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, '
|
|
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(['
|
|
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(['
|
|
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, '
|
|
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(['
|
|
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, '
|
|
123
|
-
await tag(author, '
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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(['
|
|
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([['
|
|
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([['
|
|
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
|
|
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) =>
|
|
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<
|
|
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> {
|