@smoothbricks/cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -23
- 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.js +1 -1
- 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 +265 -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/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 +250 -22
- 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 +53 -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 +1 -1
- package/src/release/core.ts +95 -10
- package/src/release/github-release.ts +1 -1
- package/src/release/index.ts +343 -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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
|
-
import type
|
|
2
|
+
import { type ReleasePackageInfo, type ReleaseTarget, releaseTag } from '../core.js';
|
|
3
3
|
import {
|
|
4
|
+
bumpStableReleaseToNext,
|
|
4
5
|
completeReleaseAtHead,
|
|
5
6
|
type ReleaseRepairShell,
|
|
6
7
|
type ReleaseVersionShell,
|
|
@@ -33,7 +34,7 @@ describe('release orchestration', () => {
|
|
|
33
34
|
expect(shell.builds).toEqual([['@scope/stable']]);
|
|
34
35
|
expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
|
|
35
36
|
expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
|
|
36
|
-
expect(shell.pushes).toEqual([['@
|
|
37
|
+
expect(shell.pushes).toEqual([['stable@1.0.0', 'prerelease@2.0.0-beta.1']]);
|
|
37
38
|
expect(summaries).toHaveLength(1);
|
|
38
39
|
expect(summaries[0]?.published.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
|
|
39
40
|
expect(summaries[0]?.githubReleases.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
|
|
@@ -50,6 +51,33 @@ describe('release orchestration', () => {
|
|
|
50
51
|
expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
it('dry-run repair reports planned targets without checkout, devenv, build, publish, or GitHub calls', async () => {
|
|
55
|
+
const target = releaseTarget('github-only', [prerelease], [], [prerelease]);
|
|
56
|
+
const shell = new RecordingRepairShell();
|
|
57
|
+
|
|
58
|
+
const summaries = await repairPendingTargets(shell, [target], 'restore-ref', true);
|
|
59
|
+
|
|
60
|
+
expect(shell.checkouts).toEqual([]);
|
|
61
|
+
expect(shell.devenvLoads).toBe(0);
|
|
62
|
+
expect(shell.builds).toEqual([]);
|
|
63
|
+
expect(shell.publishes).toEqual([]);
|
|
64
|
+
expect(shell.githubCreates).toEqual([]);
|
|
65
|
+
expect(summaries).toEqual([
|
|
66
|
+
{
|
|
67
|
+
sha: 'github-only',
|
|
68
|
+
dryRun: true,
|
|
69
|
+
packages: [prerelease],
|
|
70
|
+
pushed: false,
|
|
71
|
+
published: [],
|
|
72
|
+
alreadyPublished: [prerelease],
|
|
73
|
+
githubReleases: [],
|
|
74
|
+
githubReleaseLinks: [],
|
|
75
|
+
rerunRequired: false,
|
|
76
|
+
noRelease: false,
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
53
81
|
it('publishes a partial HEAD release by building npm-missing packages and creating missing GitHub Releases', async () => {
|
|
54
82
|
const shell = new RecordingRepairShell({ npmMissing: ['@scope/stable'], githubMissing: ['@scope/prerelease'] });
|
|
55
83
|
|
|
@@ -60,9 +88,13 @@ describe('release orchestration', () => {
|
|
|
60
88
|
expect(shell.builds).toEqual([['@scope/stable']]);
|
|
61
89
|
expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
|
|
62
90
|
expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
|
|
91
|
+
expect(shell.pushes).toEqual([['stable@1.0.0', 'prerelease@2.0.0-beta.1']]);
|
|
63
92
|
expect(summary.published.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
|
|
64
93
|
expect(summary.alreadyPublished.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
|
|
65
94
|
expect(summary.githubReleases.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
|
|
95
|
+
expect(summary.githubReleaseLinks).toEqual([
|
|
96
|
+
{ pkg: prerelease, url: 'https://github.test/prerelease@2.0.0-beta.1' },
|
|
97
|
+
]);
|
|
66
98
|
expect(summary.rerunRequired).toBe(true);
|
|
67
99
|
});
|
|
68
100
|
|
|
@@ -77,6 +109,7 @@ describe('release orchestration', () => {
|
|
|
77
109
|
expect(summary.published).toEqual([]);
|
|
78
110
|
expect(summary.alreadyPublished.map((pkg) => pkg.name)).toEqual(['@scope/stable', '@scope/prerelease']);
|
|
79
111
|
expect(summary.githubReleases).toEqual([]);
|
|
112
|
+
expect(summary.githubReleaseLinks).toEqual([]);
|
|
80
113
|
});
|
|
81
114
|
|
|
82
115
|
it('does not run Nx versioning when HEAD is already a release target', async () => {
|
|
@@ -127,6 +160,42 @@ describe('release orchestration', () => {
|
|
|
127
160
|
expect(result).toEqual({ mode: 'new', packages: [stable], status: 'new-release' });
|
|
128
161
|
expect(shell.nxRuns).toEqual([{ packages: ['@scope/stable'], bump: 'auto', dryRun: false }]);
|
|
129
162
|
});
|
|
163
|
+
|
|
164
|
+
it('reports dry-run version packages resolved by Nx without creating a release mode', async () => {
|
|
165
|
+
const preview = { ...stable, version: '1.1.0' };
|
|
166
|
+
const shell = new RecordingVersionShell({ releasePackagesAtHead: [[]], releaseVersionPackages: [stable], preview });
|
|
167
|
+
|
|
168
|
+
const result = await runReleaseVersion(shell, { bump: 'auto', dryRun: true });
|
|
169
|
+
|
|
170
|
+
expect(result).toEqual({ mode: 'none', packages: [preview], status: 'dry-run' });
|
|
171
|
+
expect(shell.nxRuns).toEqual([{ packages: ['@scope/stable'], bump: 'auto', dryRun: true }]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('bumps stable release packages to next after publish completion', async () => {
|
|
175
|
+
const shell = new RecordingNextShell();
|
|
176
|
+
|
|
177
|
+
const bumped = await bumpStableReleaseToNext(shell, [stable, prerelease], false, false);
|
|
178
|
+
|
|
179
|
+
expect(bumped.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
|
|
180
|
+
expect(shell.bumped).toEqual([['@scope/stable']]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('does not bump prerelease-only or dry-run publishes to next', async () => {
|
|
184
|
+
const shell = new RecordingNextShell();
|
|
185
|
+
|
|
186
|
+
await expect(bumpStableReleaseToNext(shell, [prerelease], false, false)).resolves.toEqual([]);
|
|
187
|
+
await expect(bumpStableReleaseToNext(shell, [stable], true, false)).resolves.toEqual([]);
|
|
188
|
+
|
|
189
|
+
expect(shell.bumped).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('does not bump stable packages to next when newer branch commits remain', async () => {
|
|
193
|
+
const shell = new RecordingNextShell();
|
|
194
|
+
|
|
195
|
+
await expect(bumpStableReleaseToNext(shell, [stable], false, true)).resolves.toEqual([]);
|
|
196
|
+
|
|
197
|
+
expect(shell.bumped).toEqual([]);
|
|
198
|
+
});
|
|
130
199
|
});
|
|
131
200
|
|
|
132
201
|
function releaseTarget(
|
|
@@ -161,7 +230,7 @@ class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
|
|
|
161
230
|
}
|
|
162
231
|
|
|
163
232
|
async pushReleaseRefs(packages: ReleasePackageInfo[]): Promise<boolean> {
|
|
164
|
-
this.pushes.push(
|
|
233
|
+
this.pushes.push(packages.map((pkg) => releaseTag(pkg)));
|
|
165
234
|
return true;
|
|
166
235
|
}
|
|
167
236
|
|
|
@@ -183,8 +252,9 @@ class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
|
|
|
183
252
|
return packages.filter((pkg) => this.githubMissing.has(pkg.name));
|
|
184
253
|
}
|
|
185
254
|
|
|
186
|
-
async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<
|
|
255
|
+
async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<string | null> {
|
|
187
256
|
this.githubCreates.push({ name: pkg.name, dryRun });
|
|
257
|
+
return dryRun ? null : `https://github.test/${releaseTag(pkg)}`;
|
|
188
258
|
}
|
|
189
259
|
|
|
190
260
|
async checkout(ref: string): Promise<void> {
|
|
@@ -205,15 +275,18 @@ class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
|
|
|
205
275
|
private readonly releaseBatches: ReleasePackageInfo[][];
|
|
206
276
|
private readonly versionPackages: ReleasePackageInfo[];
|
|
207
277
|
private readonly heads: string[];
|
|
278
|
+
private readonly preview: ReleasePackageInfo[];
|
|
208
279
|
|
|
209
280
|
constructor(options: {
|
|
210
281
|
releasePackagesAtHead: ReleasePackageInfo[][];
|
|
211
282
|
releaseVersionPackages?: ReleasePackageInfo[];
|
|
212
283
|
heads?: string[];
|
|
284
|
+
preview?: ReleasePackageInfo | ReleasePackageInfo[];
|
|
213
285
|
}) {
|
|
214
286
|
this.releaseBatches = [...options.releasePackagesAtHead];
|
|
215
287
|
this.versionPackages = options.releaseVersionPackages ?? [stable, prerelease];
|
|
216
288
|
this.heads = [...(options.heads ?? [])];
|
|
289
|
+
this.preview = Array.isArray(options.preview) ? options.preview : options.preview ? [options.preview] : [];
|
|
217
290
|
}
|
|
218
291
|
|
|
219
292
|
async releasePackagesAtHead(): Promise<ReleasePackageInfo[]> {
|
|
@@ -232,8 +305,13 @@ class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
|
|
|
232
305
|
return this.heads.shift() ?? 'head';
|
|
233
306
|
}
|
|
234
307
|
|
|
235
|
-
async runNxReleaseVersion(
|
|
308
|
+
async runNxReleaseVersion(
|
|
309
|
+
packages: ReleasePackageInfo[],
|
|
310
|
+
bump: string,
|
|
311
|
+
dryRun: boolean,
|
|
312
|
+
): Promise<ReleasePackageInfo[]> {
|
|
236
313
|
this.nxRuns.push({ packages: packageNames(packages), bump, dryRun });
|
|
314
|
+
return this.preview;
|
|
237
315
|
}
|
|
238
316
|
|
|
239
317
|
async assertCleanGitTree(): Promise<void> {
|
|
@@ -241,6 +319,14 @@ class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
|
|
|
241
319
|
}
|
|
242
320
|
}
|
|
243
321
|
|
|
322
|
+
class RecordingNextShell {
|
|
323
|
+
readonly bumped: string[][] = [];
|
|
324
|
+
|
|
325
|
+
async bumpStablePackagesToNext(packages: ReleasePackageInfo[]): Promise<void> {
|
|
326
|
+
this.bumped.push(packageNames(packages));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
244
330
|
function packageNames(packages: ReleasePackageInfo[]): string[] {
|
|
245
331
|
return packages.map((pkg) => pkg.name);
|
|
246
332
|
}
|
|
@@ -13,25 +13,25 @@ describe('retag unpublished releases', () => {
|
|
|
13
13
|
it('moves unpublished owned tags, pushes with a remote lease, and dispatches publish auto', async () => {
|
|
14
14
|
const shell = new RecordingRetagShell({
|
|
15
15
|
versionAtRef: '1.2.3',
|
|
16
|
-
remoteTagObjects: new Map([['
|
|
16
|
+
remoteTagObjects: new Map([['pkg@1.2.3', 'old-tag-object']]),
|
|
17
17
|
dispatchSha: 'target-sha',
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
const updates = await retagUnpublished(shell, retagOptions({ push: true, dispatch: true }));
|
|
21
21
|
|
|
22
|
-
expect(updates).toEqual([{ tag: '
|
|
23
|
-
expect(shell.movedTags).toEqual([{ tag: '
|
|
24
|
-
expect(shell.pushed).toEqual([[{ tag: '
|
|
22
|
+
expect(updates).toEqual([{ tag: 'pkg@1.2.3', pkg, expectedRemoteObject: 'old-tag-object' }]);
|
|
23
|
+
expect(shell.movedTags).toEqual([{ tag: 'pkg@1.2.3', ref: 'HEAD' }]);
|
|
24
|
+
expect(shell.pushed).toEqual([[{ tag: 'pkg@1.2.3', expectedRemoteObject: 'old-tag-object' }]]);
|
|
25
25
|
expect(shell.dispatched).toEqual([{ workflow: 'publish.yml', branch: 'main' }]);
|
|
26
26
|
expect(shell.dispatchRefLookups).toEqual(['main']);
|
|
27
|
-
expect(shell.remoteTagLookups).toEqual(['
|
|
27
|
+
expect(shell.remoteTagLookups).toEqual(['pkg@1.2.3']);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it('rejects tags whose package version already exists on npm', async () => {
|
|
31
31
|
const shell = new RecordingRetagShell({ versionAtRef: '1.2.3', npmPublished: true });
|
|
32
32
|
|
|
33
33
|
await expect(retagUnpublished(shell, retagOptions())).rejects.toThrow(
|
|
34
|
-
'Cannot retag
|
|
34
|
+
'Cannot retag pkg@1.2.3: @scope/pkg@1.2.3 already exists on npm.',
|
|
35
35
|
);
|
|
36
36
|
|
|
37
37
|
expect(shell.movedTags).toEqual([]);
|
|
@@ -42,7 +42,7 @@ describe('retag unpublished releases', () => {
|
|
|
42
42
|
const shell = new RecordingRetagShell({ versionAtRef: '1.2.4' });
|
|
43
43
|
|
|
44
44
|
await expect(retagUnpublished(shell, retagOptions())).rejects.toThrow(
|
|
45
|
-
'Release tag
|
|
45
|
+
'Release tag pkg@1.2.3 cannot move to HEAD: packages/pkg/package.json has version 1.2.4, expected 1.2.3.',
|
|
46
46
|
);
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -66,7 +66,7 @@ describe('retag unpublished releases', () => {
|
|
|
66
66
|
expect(shell.movedTags).toEqual([]);
|
|
67
67
|
expect(shell.pushed).toEqual([]);
|
|
68
68
|
expect(shell.dispatched).toEqual([]);
|
|
69
|
-
expect(shell.logs).toContain('Would move
|
|
69
|
+
expect(shell.logs).toContain('Would move pkg@1.2.3 to HEAD (target-sha).');
|
|
70
70
|
expect(shell.logs).toContain('Would push 1 retagged release tag.');
|
|
71
71
|
expect(shell.logs).toContain('Would dispatch publish.yml on main with bump=auto.');
|
|
72
72
|
});
|
|
@@ -74,7 +74,7 @@ describe('retag unpublished releases', () => {
|
|
|
74
74
|
|
|
75
75
|
function retagOptions(overrides: Partial<RetagUnpublishedOptions> = {}): RetagUnpublishedOptions {
|
|
76
76
|
return {
|
|
77
|
-
tags: ['
|
|
77
|
+
tags: ['pkg@1.2.3'],
|
|
78
78
|
toRef: 'HEAD',
|
|
79
79
|
push: false,
|
|
80
80
|
dispatch: false,
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
2
|
import type { BootstrapNpmPackagesOptions } from '../bootstrap-npm-packages.js';
|
|
3
3
|
import type { ReleasePackageInfo } from '../core.js';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
configureTrustedPublishers,
|
|
6
|
+
parseTrustedPublishers,
|
|
7
|
+
type TrustedPublisher,
|
|
8
|
+
type TrustPublisherShell,
|
|
9
|
+
} from '../index.js';
|
|
5
10
|
|
|
6
11
|
const stable: ReleasePackageInfo = {
|
|
7
12
|
name: '@scope/stable',
|
|
@@ -20,52 +25,86 @@ describe('trusted publisher setup', () => {
|
|
|
20
25
|
it('bootstraps missing npm packages before configuring trust', async () => {
|
|
21
26
|
const shell = new RecordingTrustPublisherShell({ packages: [stable, missing], existing: [stable.name] });
|
|
22
27
|
|
|
23
|
-
await configureTrustedPublishers(shell, { bootstrap: true, skipLogin: false
|
|
28
|
+
await configureTrustedPublishers(shell, { bootstrap: true, skipLogin: false });
|
|
24
29
|
|
|
25
30
|
expect(shell.events).toEqual([
|
|
26
31
|
'bootstrap:false:false',
|
|
27
32
|
`exists:${stable.name}`,
|
|
28
33
|
`exists:${missing.name}`,
|
|
29
|
-
`
|
|
30
|
-
`trust:${
|
|
34
|
+
`list:${stable.name}`,
|
|
35
|
+
`trust:${stable.name}:false`,
|
|
36
|
+
`list:${missing.name}`,
|
|
37
|
+
`trust:${missing.name}:false`,
|
|
31
38
|
]);
|
|
32
|
-
expect(shell.logins).toBe(0);
|
|
33
39
|
});
|
|
34
40
|
|
|
35
41
|
it('directs missing packages to trust-publisher --bootstrap', async () => {
|
|
36
42
|
const shell = new RecordingTrustPublisherShell({ packages: [missing], existing: [] });
|
|
37
43
|
|
|
38
|
-
await expect(configureTrustedPublishers(shell, {
|
|
44
|
+
await expect(configureTrustedPublishers(shell, {})).rejects.toThrow(
|
|
39
45
|
'Run smoo release trust-publisher --bootstrap locally',
|
|
40
46
|
);
|
|
41
47
|
expect(shell.events).toEqual([`exists:${missing.name}`]);
|
|
42
48
|
});
|
|
43
49
|
|
|
44
|
-
it('
|
|
50
|
+
it('does not pre-login before trust operations', async () => {
|
|
45
51
|
const shell = new RecordingTrustPublisherShell({ packages: [stable], existing: [stable.name] });
|
|
46
52
|
|
|
47
|
-
await configureTrustedPublishers(shell, { bootstrap: true
|
|
53
|
+
await configureTrustedPublishers(shell, { bootstrap: true });
|
|
48
54
|
|
|
49
55
|
expect(shell.events).toEqual([
|
|
50
56
|
'bootstrap:false:false',
|
|
51
57
|
`exists:${stable.name}`,
|
|
52
|
-
`
|
|
58
|
+
`list:${stable.name}`,
|
|
59
|
+
`trust:${stable.name}:false`,
|
|
53
60
|
]);
|
|
54
|
-
expect(shell.logins).toBe(1);
|
|
55
61
|
});
|
|
56
62
|
|
|
57
|
-
it('
|
|
63
|
+
it('skips packages with matching trusted publishers', async () => {
|
|
58
64
|
const shell = new RecordingTrustPublisherShell({
|
|
59
65
|
packages: [stable],
|
|
60
66
|
existing: [stable.name],
|
|
61
|
-
|
|
67
|
+
trustedPublishers: {
|
|
68
|
+
[stable.name]: [{ id: 'trusted-1', type: 'github', file: 'publish.yml', repository: 'scope/repo' }],
|
|
69
|
+
},
|
|
62
70
|
});
|
|
63
71
|
|
|
64
|
-
await configureTrustedPublishers(shell, { skipLogin: true
|
|
72
|
+
await configureTrustedPublishers(shell, { skipLogin: true });
|
|
65
73
|
|
|
66
|
-
expect(shell.events).toEqual([`exists:${stable.name}`, `
|
|
74
|
+
expect(shell.events).toEqual([`exists:${stable.name}`, `list:${stable.name}`]);
|
|
67
75
|
expect(shell.logs).toContain(`${stable.name}: npm trusted publisher is already configured; skipping.`);
|
|
68
76
|
});
|
|
77
|
+
|
|
78
|
+
it('rejects mismatched trusted publishers', async () => {
|
|
79
|
+
const shell = new RecordingTrustPublisherShell({
|
|
80
|
+
packages: [stable],
|
|
81
|
+
existing: [stable.name],
|
|
82
|
+
trustedPublishers: {
|
|
83
|
+
[stable.name]: [{ id: 'trusted-1', type: 'github', file: 'other.yml', repository: 'scope/repo' }],
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await expect(configureTrustedPublishers(shell, { skipLogin: true })).rejects.toThrow(
|
|
88
|
+
'npm trusted publisher exists but does not match',
|
|
89
|
+
);
|
|
90
|
+
expect(shell.events).toEqual([`exists:${stable.name}`, `list:${stable.name}`]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('configures only selected packages', async () => {
|
|
94
|
+
const shell = new RecordingTrustPublisherShell({
|
|
95
|
+
packages: [stable, missing],
|
|
96
|
+
existing: [stable.name, missing.name],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await configureTrustedPublishers(shell, { skipLogin: true, packages: [missing.name] });
|
|
100
|
+
|
|
101
|
+
expect(shell.events).toEqual([`exists:${missing.name}`, `list:${missing.name}`, `trust:${missing.name}:false`]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('parses empty npm trust list output as no trusted publishers', () => {
|
|
105
|
+
expect(parseTrustedPublishers('', stable.name)).toEqual([]);
|
|
106
|
+
expect(parseTrustedPublishers('null', stable.name)).toEqual([]);
|
|
107
|
+
});
|
|
69
108
|
});
|
|
70
109
|
|
|
71
110
|
class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackageInfo> {
|
|
@@ -74,15 +113,18 @@ class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackage
|
|
|
74
113
|
readonly events: string[] = [];
|
|
75
114
|
readonly logs: string[] = [];
|
|
76
115
|
readonly errors: string[] = [];
|
|
77
|
-
logins = 0;
|
|
78
116
|
private readonly packages: ReleasePackageInfo[];
|
|
79
117
|
private readonly existing: Set<string>;
|
|
80
|
-
private readonly
|
|
118
|
+
private readonly trustedPublisherByPackage: Record<string, TrustedPublisher[]>;
|
|
81
119
|
|
|
82
|
-
constructor(options: {
|
|
120
|
+
constructor(options: {
|
|
121
|
+
packages: ReleasePackageInfo[];
|
|
122
|
+
existing: string[];
|
|
123
|
+
trustedPublishers?: Record<string, TrustedPublisher[]>;
|
|
124
|
+
}) {
|
|
83
125
|
this.packages = options.packages;
|
|
84
126
|
this.existing = new Set(options.existing);
|
|
85
|
-
this.
|
|
127
|
+
this.trustedPublisherByPackage = options.trustedPublishers ?? {};
|
|
86
128
|
}
|
|
87
129
|
|
|
88
130
|
listReleasePackages(): ReleasePackageInfo[] {
|
|
@@ -103,24 +145,14 @@ class RecordingTrustPublisherShell implements TrustPublisherShell<ReleasePackage
|
|
|
103
145
|
return missingPackages;
|
|
104
146
|
}
|
|
105
147
|
|
|
106
|
-
async
|
|
107
|
-
this.
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async trustPublisher(
|
|
111
|
-
pkg: ReleasePackageInfo,
|
|
112
|
-
dryRun: boolean,
|
|
113
|
-
env?: Record<string, string>,
|
|
114
|
-
): Promise<'configured' | 'already-configured'> {
|
|
115
|
-
this.events.push(`trust:${pkg.name}:${dryRun}:${env?.NPM_CONFIG_OTP ?? ''}`);
|
|
116
|
-
if (this.alreadyTrusted.has(pkg.name)) {
|
|
117
|
-
return 'already-configured';
|
|
118
|
-
}
|
|
148
|
+
async trustPublisher(pkg: ReleasePackageInfo, dryRun: boolean): Promise<'configured' | 'already-configured'> {
|
|
149
|
+
this.events.push(`trust:${pkg.name}:${dryRun}`);
|
|
119
150
|
return 'configured';
|
|
120
151
|
}
|
|
121
152
|
|
|
122
|
-
async
|
|
123
|
-
|
|
153
|
+
async trustedPublishers(pkg: ReleasePackageInfo): Promise<TrustedPublisher[]> {
|
|
154
|
+
this.events.push(`list:${pkg.name}`);
|
|
155
|
+
return this.trustedPublisherByPackage[pkg.name] ?? [];
|
|
124
156
|
}
|
|
125
157
|
|
|
126
158
|
log(message: string): void {
|
|
@@ -62,7 +62,7 @@ async function isAutoReleaseCandidate<Package extends ReleasePackageInfo>(
|
|
|
62
62
|
if (await shell.gitRefExists(tagRef)) {
|
|
63
63
|
return packageHasReleasableChangesSince(shell, tagRef, pkg);
|
|
64
64
|
}
|
|
65
|
-
return shell.packageHasHistory(pkg.path);
|
|
65
|
+
return !pkg.version.includes('-') && shell.packageHasHistory(pkg.path);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
async function packageHasReleasableChangesSince<Package extends ReleasePackageInfo>(
|
package/src/release/core.ts
CHANGED
|
@@ -44,10 +44,18 @@ export interface ReleasePlanningShell {
|
|
|
44
44
|
durableTagState(pkg: ReleasePackageInfo, tag: string): Promise<DurableTagState>;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function releaseTag(pkg: Pick<ReleasePackageInfo, '
|
|
47
|
+
export function releaseTag(pkg: Pick<ReleasePackageInfo, 'projectName' | 'version'>): string {
|
|
48
|
+
return `${pkg.projectName}@${pkg.version}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function legacyReleaseTag(pkg: Pick<ReleasePackageInfo, 'name' | 'version'>): string {
|
|
48
52
|
return `${pkg.name}@${pkg.version}`;
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
export function releaseTagAliases(pkg: Pick<ReleasePackageInfo, 'name' | 'projectName' | 'version'>): string[] {
|
|
56
|
+
return [...new Set([releaseTag(pkg), legacyReleaseTag(pkg)])];
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
export function npmDistTagForVersion(version: string): string {
|
|
52
60
|
return version.includes('-') ? 'next' : 'latest';
|
|
53
61
|
}
|
|
@@ -57,7 +65,7 @@ export function releasePackageForTag<Package extends Omit<ReleasePackageInfo, 'v
|
|
|
57
65
|
tag: string,
|
|
58
66
|
): { pkg: Package; version: string } | null {
|
|
59
67
|
for (const pkg of packages) {
|
|
60
|
-
const prefix = `${pkg.
|
|
68
|
+
const prefix = `${pkg.projectName}@`;
|
|
61
69
|
if (tag.startsWith(prefix)) {
|
|
62
70
|
const version = tag.slice(prefix.length);
|
|
63
71
|
if (!version) {
|
|
@@ -85,13 +93,14 @@ export async function collectOwnedReleaseTagRecords<Package extends Omit<Release
|
|
|
85
93
|
ref: string,
|
|
86
94
|
shell: ReleasePlanningShell,
|
|
87
95
|
): Promise<Array<ReleaseTagRecord<Package & { version: string }>>> {
|
|
88
|
-
const
|
|
89
|
-
|
|
96
|
+
const candidates: Array<{
|
|
97
|
+
tag: GitReleaseTagInfo;
|
|
98
|
+
pkg: Package & { version: string };
|
|
99
|
+
}> = [];
|
|
100
|
+
const durableStates = new BoundedPromiseRunner(8);
|
|
101
|
+
|
|
90
102
|
for (const tag of await shell.listReleaseTagsByCreatorDate()) {
|
|
91
103
|
const match = releasePackageForTag(packages, tag.name);
|
|
92
|
-
if (match && completedPackages.has(match.pkg.name)) {
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
104
|
if (!match || !(await shell.isAncestor(tag.sha, ref))) {
|
|
96
105
|
continue;
|
|
97
106
|
}
|
|
@@ -104,15 +113,91 @@ export async function collectOwnedReleaseTagRecords<Package extends Omit<Release
|
|
|
104
113
|
);
|
|
105
114
|
}
|
|
106
115
|
const pkg = { ...match.pkg, version: match.version };
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
candidates.push({ tag, pkg });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const records: Array<ReleaseTagRecord<Package & { version: string }>> = [];
|
|
120
|
+
const completedPackages = new Set<string>();
|
|
121
|
+
const activePackages = new Set<string>();
|
|
122
|
+
const states = new Map<number, Promise<DurableTagState>>();
|
|
123
|
+
|
|
124
|
+
// Run durable-state checks concurrently across packages, but do not query an
|
|
125
|
+
// older tag for the same package until its newer tag proves incomplete.
|
|
126
|
+
const scheduleFrom = (startIndex: number) => {
|
|
127
|
+
for (let index = startIndex; index < candidates.length; index += 1) {
|
|
128
|
+
if (states.size >= 8) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const candidate = candidates[index];
|
|
132
|
+
if (!candidate || states.has(index) || completedPackages.has(candidate.pkg.name)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (activePackages.has(candidate.pkg.name)) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
activePackages.add(candidate.pkg.name);
|
|
139
|
+
states.set(
|
|
140
|
+
index,
|
|
141
|
+
durableStates.run(() => shell.durableTagState(candidate.pkg, candidate.tag.name)),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
for (const candidate of candidates) {
|
|
147
|
+
const index = candidates.indexOf(candidate);
|
|
148
|
+
scheduleFrom(index);
|
|
149
|
+
if (completedPackages.has(candidate.pkg.name)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const state = await states.get(index);
|
|
153
|
+
if (!state) {
|
|
154
|
+
throw new Error(`Release tag ${candidate.tag.name} lost durable state planning.`);
|
|
155
|
+
}
|
|
156
|
+
activePackages.delete(candidate.pkg.name);
|
|
157
|
+
states.delete(index);
|
|
158
|
+
records.push(
|
|
159
|
+
classifyReleaseTag(
|
|
160
|
+
{ tag: candidate.tag.name, sha: candidate.tag.sha, timestamp: candidate.tag.timestamp, pkg: candidate.pkg },
|
|
161
|
+
state,
|
|
162
|
+
),
|
|
163
|
+
);
|
|
109
164
|
if (state.npmPublished && state.githubReleaseExists) {
|
|
110
|
-
completedPackages.add(pkg.name);
|
|
165
|
+
completedPackages.add(candidate.pkg.name);
|
|
111
166
|
}
|
|
112
167
|
}
|
|
113
168
|
return records;
|
|
114
169
|
}
|
|
115
170
|
|
|
171
|
+
class BoundedPromiseRunner {
|
|
172
|
+
private active = 0;
|
|
173
|
+
private readonly waiting: Array<() => void> = [];
|
|
174
|
+
|
|
175
|
+
constructor(private readonly limit: number) {}
|
|
176
|
+
|
|
177
|
+
async run<T>(operation: () => Promise<T>): Promise<T> {
|
|
178
|
+
await this.acquire();
|
|
179
|
+
try {
|
|
180
|
+
return await operation();
|
|
181
|
+
} finally {
|
|
182
|
+
this.release();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async acquire(): Promise<void> {
|
|
187
|
+
if (this.active < this.limit) {
|
|
188
|
+
this.active += 1;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await new Promise<void>((resolve) => this.waiting.push(resolve));
|
|
192
|
+
this.active += 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private release(): void {
|
|
196
|
+
this.active -= 1;
|
|
197
|
+
this.waiting.shift()?.();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
116
201
|
export function groupReleaseTargets<Package extends ReleasePackageInfo>(
|
|
117
202
|
records: ReleaseTagRecord<Package>[],
|
|
118
203
|
): ReleaseTarget<Package>[] {
|
|
@@ -112,7 +112,7 @@ function isPrereleaseVersion(version: string): boolean {
|
|
|
112
112
|
return version.includes('-');
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
async function withNxWorkspaceRoot<T>(root: string, run: () => Promise<T>): Promise<T> {
|
|
115
|
+
export async function withNxWorkspaceRoot<T>(root: string, run: () => Promise<T>): Promise<T> {
|
|
116
116
|
const workspaceRootModule = await import('nx/src/utils/workspace-root.js');
|
|
117
117
|
const previousWorkspaceRoot = workspaceRootModule.workspaceRoot;
|
|
118
118
|
const previousEnvWorkspaceRoot = process.env.NX_WORKSPACE_ROOT_PATH;
|