@smoothbricks/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +338 -88
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +136 -22
- package/dist/devenv/index.d.ts +10 -0
- package/dist/devenv/index.d.ts.map +1 -0
- package/dist/devenv/index.js +27 -0
- package/dist/lib/cli-package.d.ts +4 -0
- package/dist/lib/cli-package.d.ts.map +1 -0
- package/dist/lib/cli-package.js +15 -0
- package/dist/lib/devenv.d.ts +6 -0
- package/dist/lib/devenv.d.ts.map +1 -0
- package/dist/lib/devenv.js +82 -0
- package/dist/lib/run.d.ts +5 -0
- package/dist/lib/run.d.ts.map +1 -1
- package/dist/lib/run.js +13 -0
- package/dist/lib/workspace.d.ts +20 -1
- package/dist/lib/workspace.d.ts.map +1 -1
- package/dist/lib/workspace.js +87 -7
- package/dist/monorepo/commit-msg.d.ts +8 -1
- package/dist/monorepo/commit-msg.d.ts.map +1 -1
- package/dist/monorepo/commit-msg.js +85 -7
- package/dist/monorepo/index.d.ts +24 -2
- package/dist/monorepo/index.d.ts.map +1 -1
- package/dist/monorepo/index.js +136 -15
- package/dist/monorepo/lockfile.d.ts +5 -1
- package/dist/monorepo/lockfile.d.ts.map +1 -1
- package/dist/monorepo/lockfile.js +45 -5
- package/dist/monorepo/managed-files.d.ts +1 -1
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +28 -11
- package/dist/monorepo/nx-sync.d.ts +2 -1
- package/dist/monorepo/nx-sync.d.ts.map +1 -1
- package/dist/monorepo/nx-sync.js +8 -2
- package/dist/monorepo/package-hygiene.d.ts +6 -2
- package/dist/monorepo/package-hygiene.d.ts.map +1 -1
- package/dist/monorepo/package-hygiene.js +12 -4
- package/dist/monorepo/package-policy.d.ts +25 -2
- package/dist/monorepo/package-policy.d.ts.map +1 -1
- package/dist/monorepo/package-policy.js +1289 -27
- package/dist/monorepo/packed-manifest.d.ts +4 -0
- package/dist/monorepo/packed-manifest.d.ts.map +1 -0
- package/dist/monorepo/packed-manifest.js +51 -0
- package/dist/monorepo/packed-package.d.ts +3 -0
- package/dist/monorepo/packed-package.d.ts.map +1 -1
- package/dist/monorepo/packed-package.js +69 -31
- package/dist/monorepo/packs/index.d.ts +20 -1
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +240 -18
- package/dist/monorepo/publish-workflow.d.ts +82 -0
- package/dist/monorepo/publish-workflow.d.ts.map +1 -0
- package/dist/monorepo/publish-workflow.js +336 -0
- package/dist/monorepo/tool-validation.d.ts +22 -0
- package/dist/monorepo/tool-validation.d.ts.map +1 -0
- package/dist/monorepo/tool-validation.js +399 -0
- package/dist/nx/index.d.ts +24 -0
- package/dist/nx/index.d.ts.map +1 -0
- package/dist/nx/index.js +94 -0
- package/dist/nx-version-actions.cjs +25 -0
- package/dist/nx-version-actions.d.cts +6 -0
- package/dist/nx-version-actions.d.cts.map +1 -0
- package/dist/release/bootstrap-npm-packages.d.ts +19 -0
- package/dist/release/bootstrap-npm-packages.d.ts.map +1 -0
- package/dist/release/bootstrap-npm-packages.js +58 -0
- package/dist/release/candidates.d.ts +11 -0
- package/dist/release/candidates.d.ts.map +1 -0
- package/dist/release/candidates.js +181 -0
- package/dist/release/core.d.ts +51 -0
- package/dist/release/core.d.ts.map +1 -0
- package/dist/release/core.js +97 -0
- package/dist/release/github-release.d.ts +46 -0
- package/dist/release/github-release.d.ts.map +1 -0
- package/dist/release/github-release.js +97 -0
- package/dist/release/index.d.ts +39 -5
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +763 -130
- package/dist/release/npm-auth.d.ts +16 -0
- package/dist/release/npm-auth.d.ts.map +1 -0
- package/dist/release/npm-auth.js +39 -0
- package/dist/release/orchestration.d.ts +49 -0
- package/dist/release/orchestration.d.ts.map +1 -0
- package/dist/release/orchestration.js +113 -0
- package/dist/release/publish-plan.d.ts +17 -0
- package/dist/release/publish-plan.d.ts.map +1 -0
- package/dist/release/publish-plan.js +15 -0
- package/dist/release/retag-unpublished.d.ts +34 -0
- package/dist/release/retag-unpublished.d.ts.map +1 -0
- package/dist/release/retag-unpublished.js +77 -0
- package/managed/raw/git-format-staged.yml +54 -0
- package/managed/raw/tooling/git-hooks/commit-msg.sh +1 -1
- package/managed/raw/tooling/git-hooks/pre-commit.sh +2 -7
- package/managed/templates/github/actions/setup-devenv/action.yml +1 -7
- package/managed/templates/github/workflows/ci.yml +3 -0
- package/package.json +37 -4
- package/src/cli.ts +193 -40
- package/src/devenv/index.test.ts +49 -0
- package/src/devenv/index.ts +38 -0
- package/src/lib/cli-package.ts +18 -0
- package/src/lib/devenv.test.ts +28 -0
- package/src/lib/devenv.ts +89 -0
- package/src/lib/run.ts +19 -0
- package/src/lib/workspace.ts +117 -7
- package/src/monorepo/__tests__/nx-version-actions.test.ts +75 -0
- package/src/monorepo/__tests__/publish-workflow.test.ts +322 -0
- package/src/monorepo/commit-msg.test.ts +45 -0
- package/src/monorepo/commit-msg.ts +107 -7
- package/src/monorepo/index.ts +181 -15
- package/src/monorepo/lockfile.ts +52 -7
- package/src/monorepo/managed-files.ts +43 -13
- package/src/monorepo/nx-sync.ts +9 -2
- package/src/monorepo/package-hygiene.test.ts +34 -0
- package/src/monorepo/package-hygiene.ts +26 -4
- package/src/monorepo/package-policy.test.ts +902 -0
- package/src/monorepo/package-policy.ts +1527 -25
- package/src/monorepo/packed-manifest.ts +67 -0
- package/src/monorepo/packed-package.ts +90 -31
- package/src/monorepo/packs/index.test.ts +189 -0
- package/src/monorepo/packs/index.ts +314 -19
- package/src/monorepo/publish-workflow.ts +422 -0
- package/src/monorepo/setup-test-tracing.test.ts +120 -0
- package/src/monorepo/tool-validation.test.ts +261 -0
- package/src/monorepo/tool-validation.ts +462 -0
- package/src/nx/index.test.ts +64 -0
- package/src/nx/index.ts +119 -0
- package/src/nx-version-actions.cts +36 -0
- package/src/release/__tests__/bootstrap-npm-packages.test.ts +127 -0
- package/src/release/__tests__/candidates.test.ts +217 -0
- package/src/release/__tests__/core-properties.test.ts +149 -0
- package/src/release/__tests__/core-scenarios.test.ts +187 -0
- package/src/release/__tests__/core.test.ts +73 -0
- package/src/release/__tests__/fixture-repo.test.ts +314 -0
- package/src/release/__tests__/github-release.test.ts +149 -0
- package/src/release/__tests__/helpers/fixture-repo.ts +114 -0
- package/src/release/__tests__/npm-auth.test.ts +120 -0
- package/src/release/__tests__/orchestration.test.ts +246 -0
- package/src/release/__tests__/publish-plan.test.ts +67 -0
- package/src/release/__tests__/retag-unpublished.test.ts +160 -0
- package/src/release/__tests__/trust-publisher.test.ts +133 -0
- package/src/release/bootstrap-npm-packages.ts +88 -0
- package/src/release/candidates.ts +225 -0
- package/src/release/core.ts +171 -0
- package/src/release/github-release.ts +134 -0
- package/src/release/index.ts +974 -138
- package/src/release/npm-auth.ts +93 -0
- package/src/release/orchestration.ts +196 -0
- package/src/release/publish-plan.ts +37 -0
- package/src/release/retag-unpublished.ts +122 -0
- package/managed/raw/.git-format-staged.yml +0 -47
- package/managed/templates/github/workflows/publish.yml +0 -136
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { ReleasePackageInfo } from '../core.js';
|
|
3
|
+
import {
|
|
4
|
+
type NpmPublishDiagnosticShell,
|
|
5
|
+
npmPublishAuthFailureMarkdown,
|
|
6
|
+
npmPublishAuthFailureMessage,
|
|
7
|
+
publishWithAuthDiagnostics,
|
|
8
|
+
} from '../npm-auth.js';
|
|
9
|
+
|
|
10
|
+
const pkg: Pick<ReleasePackageInfo, 'name' | 'version'> = { name: '@scope/pkg', version: '1.2.3' };
|
|
11
|
+
|
|
12
|
+
describe('npm publish auth diagnostics', () => {
|
|
13
|
+
it('explains trusted publishing setup when an existing package publish is unauthenticated', () => {
|
|
14
|
+
const message = npmPublishAuthFailureMessage(pkg, {
|
|
15
|
+
tokenPresent: true,
|
|
16
|
+
repository: 'smoothbricks/codebase',
|
|
17
|
+
});
|
|
18
|
+
const markdown = npmPublishAuthFailureMarkdown(pkg, {
|
|
19
|
+
tokenPresent: true,
|
|
20
|
+
repository: 'smoothbricks/codebase',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(message).toContain('🚨 npm publish authentication failed for @scope/pkg@1.2.3');
|
|
24
|
+
expect(message).toContain('smoo release trust-publisher');
|
|
25
|
+
expect(message).toContain('NODE_AUTH_TOKEN/NPM_TOKEN is set but unused');
|
|
26
|
+
expect(message).toContain('repository: smoothbricks/codebase');
|
|
27
|
+
expect(message).toContain('workflow: publish.yml');
|
|
28
|
+
expect(markdown).toContain('## 🚨 npm Publish Authentication Failed');
|
|
29
|
+
expect(markdown).toContain('Run locally: `smoo release trust-publisher`');
|
|
30
|
+
expect(markdown).toContain('repository `smoothbricks/codebase` and workflow `publish.yml`');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('points first package publishes to local placeholder bootstrap', () => {
|
|
34
|
+
const message = npmPublishAuthFailureMessage(pkg, { tokenPresent: false });
|
|
35
|
+
const markdown = npmPublishAuthFailureMarkdown(pkg, { tokenPresent: false });
|
|
36
|
+
|
|
37
|
+
expect(message).toContain('smoo release trust-publisher --bootstrap');
|
|
38
|
+
expect(markdown).toContain('smoo release trust-publisher --bootstrap');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reports trusted-publishing guidance and writes it to the publish summary after existing package auth failure', async () => {
|
|
42
|
+
const shell = new RecordingPublishShell({ publishFails: true });
|
|
43
|
+
|
|
44
|
+
await expect(
|
|
45
|
+
publishWithAuthDiagnostics(pkg, shell, {
|
|
46
|
+
tokenPresent: true,
|
|
47
|
+
repository: 'smoothbricks/codebase',
|
|
48
|
+
}),
|
|
49
|
+
).rejects.toThrow('@scope/pkg@1.2.3: npm publish authentication failed');
|
|
50
|
+
|
|
51
|
+
expect(shell.errors).toHaveLength(1);
|
|
52
|
+
expect(shell.errors[0]).toContain('🚨 npm publish authentication failed for @scope/pkg@1.2.3');
|
|
53
|
+
expect(shell.errors[0]).toContain('NODE_AUTH_TOKEN/NPM_TOKEN is set but unused');
|
|
54
|
+
expect(shell.errors[0]).toContain('smoo release trust-publisher');
|
|
55
|
+
expect(shell.errors[0]).toContain('repository: smoothbricks/codebase');
|
|
56
|
+
expect(shell.summaries).toHaveLength(1);
|
|
57
|
+
expect(shell.summaries[0]).toContain('## 🚨 npm Publish Authentication Failed');
|
|
58
|
+
expect(shell.summaries[0]).toContain('Run locally: `smoo release trust-publisher`');
|
|
59
|
+
expect(shell.logs).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('reports bootstrap command guidance and writes it to the publish summary after first-publish auth failure', async () => {
|
|
63
|
+
const shell = new RecordingPublishShell({ publishFails: true });
|
|
64
|
+
|
|
65
|
+
await expect(publishWithAuthDiagnostics(pkg, shell, { tokenPresent: false })).rejects.toThrow(
|
|
66
|
+
'@scope/pkg@1.2.3: npm publish authentication failed',
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(shell.errors).toHaveLength(1);
|
|
70
|
+
expect(shell.errors[0]).toContain('smoo release trust-publisher --bootstrap');
|
|
71
|
+
expect(shell.summaries).toHaveLength(1);
|
|
72
|
+
expect(shell.summaries[0]).toContain('smoo release trust-publisher --bootstrap');
|
|
73
|
+
expect(shell.logs).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('continues without auth warning when the package version appears on npm after publish failure', async () => {
|
|
77
|
+
const shell = new RecordingPublishShell({ publishFails: true, versionVisibleAfterFailure: true });
|
|
78
|
+
|
|
79
|
+
await publishWithAuthDiagnostics(pkg, shell, { tokenPresent: true });
|
|
80
|
+
|
|
81
|
+
expect(shell.errors).toEqual([]);
|
|
82
|
+
expect(shell.summaries).toEqual([]);
|
|
83
|
+
expect(shell.logs).toEqual(['@scope/pkg@1.2.3: publish result already visible on npm; continuing.']);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
class RecordingPublishShell implements NpmPublishDiagnosticShell {
|
|
88
|
+
readonly logs: string[] = [];
|
|
89
|
+
readonly errors: string[] = [];
|
|
90
|
+
readonly summaries: string[] = [];
|
|
91
|
+
private readonly publishFails: boolean;
|
|
92
|
+
private readonly versionVisibleAfterFailure: boolean;
|
|
93
|
+
|
|
94
|
+
constructor(options: { publishFails: boolean; versionVisibleAfterFailure?: boolean }) {
|
|
95
|
+
this.publishFails = options.publishFails;
|
|
96
|
+
this.versionVisibleAfterFailure = options.versionVisibleAfterFailure === true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async publish(): Promise<void> {
|
|
100
|
+
if (this.publishFails) {
|
|
101
|
+
throw new Error('ENEEDAUTH');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async versionExists(): Promise<boolean> {
|
|
106
|
+
return this.versionVisibleAfterFailure;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
log(message: string): void {
|
|
110
|
+
this.logs.push(message);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
error(message: string): void {
|
|
114
|
+
this.errors.push(message);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async appendSummary(markdown: string): Promise<void> {
|
|
118
|
+
this.summaries.push(markdown);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { ReleasePackageInfo, ReleaseTarget } from '../core.js';
|
|
3
|
+
import {
|
|
4
|
+
completeReleaseAtHead,
|
|
5
|
+
type ReleaseRepairShell,
|
|
6
|
+
type ReleaseVersionShell,
|
|
7
|
+
repairPendingTargets,
|
|
8
|
+
runReleaseVersion,
|
|
9
|
+
} from '../orchestration.js';
|
|
10
|
+
|
|
11
|
+
const stable: ReleasePackageInfo = {
|
|
12
|
+
name: '@scope/stable',
|
|
13
|
+
projectName: 'stable',
|
|
14
|
+
path: 'packages/stable',
|
|
15
|
+
version: '1.0.0',
|
|
16
|
+
};
|
|
17
|
+
const prerelease: ReleasePackageInfo = {
|
|
18
|
+
name: '@scope/prerelease',
|
|
19
|
+
projectName: 'prerelease',
|
|
20
|
+
path: 'packages/prerelease',
|
|
21
|
+
version: '2.0.0-beta.1',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('release orchestration', () => {
|
|
25
|
+
it('repairs an older target with one checkout, one devenv load, npm-only build, and GitHub create calls', async () => {
|
|
26
|
+
const target = releaseTarget('older-release', [stable, prerelease], [stable], [prerelease]);
|
|
27
|
+
const shell = new RecordingRepairShell();
|
|
28
|
+
|
|
29
|
+
const summaries = await repairPendingTargets(shell, [target], 'restore-ref', false);
|
|
30
|
+
|
|
31
|
+
expect(shell.checkouts).toEqual(['older-release', 'restore-ref']);
|
|
32
|
+
expect(shell.devenvLoads).toBe(1);
|
|
33
|
+
expect(shell.builds).toEqual([['@scope/stable']]);
|
|
34
|
+
expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
|
|
35
|
+
expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
|
|
36
|
+
expect(shell.pushes).toEqual([['@scope/stable', '@scope/prerelease']]);
|
|
37
|
+
expect(summaries).toHaveLength(1);
|
|
38
|
+
expect(summaries[0]?.published.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
|
|
39
|
+
expect(summaries[0]?.githubReleases.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('skips build and publish for GitHub-only repair targets', async () => {
|
|
43
|
+
const target = releaseTarget('github-only', [prerelease], [], [prerelease]);
|
|
44
|
+
const shell = new RecordingRepairShell();
|
|
45
|
+
|
|
46
|
+
await repairPendingTargets(shell, [target], 'restore-ref', false);
|
|
47
|
+
|
|
48
|
+
expect(shell.builds).toEqual([]);
|
|
49
|
+
expect(shell.publishes).toEqual([]);
|
|
50
|
+
expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('publishes a partial HEAD release by building npm-missing packages and creating missing GitHub Releases', async () => {
|
|
54
|
+
const shell = new RecordingRepairShell({ npmMissing: ['@scope/stable'], githubMissing: ['@scope/prerelease'] });
|
|
55
|
+
|
|
56
|
+
const summary = await completeReleaseAtHead(shell, [stable, prerelease], false, true);
|
|
57
|
+
|
|
58
|
+
expect(shell.checkouts).toEqual([]);
|
|
59
|
+
expect(shell.devenvLoads).toBe(0);
|
|
60
|
+
expect(shell.builds).toEqual([['@scope/stable']]);
|
|
61
|
+
expect(shell.publishes).toEqual([{ name: '@scope/stable', distTag: 'latest', dryRun: false }]);
|
|
62
|
+
expect(shell.githubCreates).toEqual([{ name: '@scope/prerelease', dryRun: false }]);
|
|
63
|
+
expect(summary.published.map((pkg) => pkg.name)).toEqual(['@scope/stable']);
|
|
64
|
+
expect(summary.alreadyPublished.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
|
|
65
|
+
expect(summary.githubReleases.map((pkg) => pkg.name)).toEqual(['@scope/prerelease']);
|
|
66
|
+
expect(summary.rerunRequired).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('leaves a complete HEAD release idempotent', async () => {
|
|
70
|
+
const shell = new RecordingRepairShell();
|
|
71
|
+
|
|
72
|
+
const summary = await completeReleaseAtHead(shell, [stable, prerelease], false, false);
|
|
73
|
+
|
|
74
|
+
expect(shell.builds).toEqual([]);
|
|
75
|
+
expect(shell.publishes).toEqual([]);
|
|
76
|
+
expect(shell.githubCreates).toEqual([]);
|
|
77
|
+
expect(summary.published).toEqual([]);
|
|
78
|
+
expect(summary.alreadyPublished.map((pkg) => pkg.name)).toEqual(['@scope/stable', '@scope/prerelease']);
|
|
79
|
+
expect(summary.githubReleases).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not run Nx versioning when HEAD is already a release target', async () => {
|
|
83
|
+
const shell = new RecordingVersionShell({ releasePackagesAtHead: [[stable]] });
|
|
84
|
+
|
|
85
|
+
const result = await runReleaseVersion(shell, { bump: 'patch', dryRun: false });
|
|
86
|
+
|
|
87
|
+
expect(result).toEqual({ mode: 'none', packages: [stable], status: 'already-release-target' });
|
|
88
|
+
expect(shell.nxRuns).toEqual([]);
|
|
89
|
+
expect(shell.ensureCalls).toEqual([['@scope/stable']]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('runs forced Nx versioning for an untagged HEAD and reports a new release commit', async () => {
|
|
93
|
+
const shell = new RecordingVersionShell({
|
|
94
|
+
releasePackagesAtHead: [[], [stable]],
|
|
95
|
+
releaseVersionPackages: [stable, prerelease],
|
|
96
|
+
heads: ['before', 'after'],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = await runReleaseVersion(shell, { bump: 'patch', dryRun: false });
|
|
100
|
+
|
|
101
|
+
expect(result).toEqual({ mode: 'new', packages: [stable], status: 'new-release' });
|
|
102
|
+
expect(shell.nxRuns).toEqual([{ packages: ['@scope/stable', '@scope/prerelease'], bump: 'patch', dryRun: false }]);
|
|
103
|
+
expect(shell.cleanChecks).toBe(1);
|
|
104
|
+
expect(shell.ensureCalls).toEqual([['@scope/stable']]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('skips auto Nx versioning when no package-local candidates exist', async () => {
|
|
108
|
+
const shell = new RecordingVersionShell({ releasePackagesAtHead: [[]], releaseVersionPackages: [] });
|
|
109
|
+
|
|
110
|
+
const result = await runReleaseVersion(shell, { bump: 'auto', dryRun: false });
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual({ mode: 'none', packages: [], status: 'no-release-needed' });
|
|
113
|
+
expect(shell.nxRuns).toEqual([]);
|
|
114
|
+
expect(shell.cleanChecks).toBe(0);
|
|
115
|
+
expect(shell.ensureCalls).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('runs auto Nx versioning only for selected package-local candidates', async () => {
|
|
119
|
+
const shell = new RecordingVersionShell({
|
|
120
|
+
releasePackagesAtHead: [[], [stable]],
|
|
121
|
+
releaseVersionPackages: [stable],
|
|
122
|
+
heads: ['before', 'after'],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await runReleaseVersion(shell, { bump: 'auto', dryRun: false });
|
|
126
|
+
|
|
127
|
+
expect(result).toEqual({ mode: 'new', packages: [stable], status: 'new-release' });
|
|
128
|
+
expect(shell.nxRuns).toEqual([{ packages: ['@scope/stable'], bump: 'auto', dryRun: false }]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function releaseTarget(
|
|
133
|
+
sha: string,
|
|
134
|
+
packages: ReleasePackageInfo[],
|
|
135
|
+
npmPackages: ReleasePackageInfo[],
|
|
136
|
+
githubPackages: ReleasePackageInfo[],
|
|
137
|
+
): ReleaseTarget<ReleasePackageInfo> {
|
|
138
|
+
return { sha, timestamp: 1, packages, npmPackages, githubPackages };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
class RecordingRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
|
|
142
|
+
readonly checkouts: string[] = [];
|
|
143
|
+
readonly pushes: string[][] = [];
|
|
144
|
+
readonly builds: string[][] = [];
|
|
145
|
+
readonly publishes: Array<{ name: string; distTag: string; dryRun: boolean }> = [];
|
|
146
|
+
readonly githubCreates: Array<{ name: string; dryRun: boolean }> = [];
|
|
147
|
+
readonly npmQueries: string[][] = [];
|
|
148
|
+
readonly githubQueries: string[][] = [];
|
|
149
|
+
devenvLoads = 0;
|
|
150
|
+
currentRef = 'head';
|
|
151
|
+
private readonly npmMissing: Set<string>;
|
|
152
|
+
private readonly githubMissing: Set<string>;
|
|
153
|
+
|
|
154
|
+
constructor(options: { npmMissing?: string[]; githubMissing?: string[] } = {}) {
|
|
155
|
+
this.npmMissing = new Set(options.npmMissing ?? []);
|
|
156
|
+
this.githubMissing = new Set(options.githubMissing ?? []);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async gitHead(): Promise<string> {
|
|
160
|
+
return this.currentRef;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async pushReleaseRefs(packages: ReleasePackageInfo[]): Promise<boolean> {
|
|
164
|
+
this.pushes.push(packageNames(packages));
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async listNpmMissingPackages(packages: ReleasePackageInfo[]): Promise<ReleasePackageInfo[]> {
|
|
169
|
+
this.npmQueries.push(packageNames(packages));
|
|
170
|
+
return packages.filter((pkg) => this.npmMissing.has(pkg.name));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async buildReleaseCandidate(packages: ReleasePackageInfo[]): Promise<void> {
|
|
174
|
+
this.builds.push(packageNames(packages));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async publishPackage(pkg: ReleasePackageInfo, distTag: string, dryRun: boolean): Promise<void> {
|
|
178
|
+
this.publishes.push({ name: pkg.name, distTag, dryRun });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async listGithubMissingPackages(packages: ReleasePackageInfo[]): Promise<ReleasePackageInfo[]> {
|
|
182
|
+
this.githubQueries.push(packageNames(packages));
|
|
183
|
+
return packages.filter((pkg) => this.githubMissing.has(pkg.name));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async createGithubRelease(pkg: ReleasePackageInfo, dryRun: boolean): Promise<void> {
|
|
187
|
+
this.githubCreates.push({ name: pkg.name, dryRun });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async checkout(ref: string): Promise<void> {
|
|
191
|
+
this.currentRef = ref;
|
|
192
|
+
this.checkouts.push(ref);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async withDevenvEnv<T>(runWithEnv: () => Promise<T>): Promise<T> {
|
|
196
|
+
this.devenvLoads += 1;
|
|
197
|
+
return runWithEnv();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
class RecordingVersionShell implements ReleaseVersionShell<ReleasePackageInfo> {
|
|
202
|
+
readonly ensureCalls: string[][] = [];
|
|
203
|
+
readonly nxRuns: Array<{ packages: string[]; bump: string; dryRun: boolean }> = [];
|
|
204
|
+
cleanChecks = 0;
|
|
205
|
+
private readonly releaseBatches: ReleasePackageInfo[][];
|
|
206
|
+
private readonly versionPackages: ReleasePackageInfo[];
|
|
207
|
+
private readonly heads: string[];
|
|
208
|
+
|
|
209
|
+
constructor(options: {
|
|
210
|
+
releasePackagesAtHead: ReleasePackageInfo[][];
|
|
211
|
+
releaseVersionPackages?: ReleasePackageInfo[];
|
|
212
|
+
heads?: string[];
|
|
213
|
+
}) {
|
|
214
|
+
this.releaseBatches = [...options.releasePackagesAtHead];
|
|
215
|
+
this.versionPackages = options.releaseVersionPackages ?? [stable, prerelease];
|
|
216
|
+
this.heads = [...(options.heads ?? [])];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async releasePackagesAtHead(): Promise<ReleasePackageInfo[]> {
|
|
220
|
+
return this.releaseBatches.shift() ?? [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async releaseVersionPackages(): Promise<ReleasePackageInfo[]> {
|
|
224
|
+
return this.versionPackages;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async ensureLocalReleaseTags(packages: ReleasePackageInfo[]): Promise<void> {
|
|
228
|
+
this.ensureCalls.push(packageNames(packages));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async gitHead(): Promise<string> {
|
|
232
|
+
return this.heads.shift() ?? 'head';
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async runNxReleaseVersion(packages: ReleasePackageInfo[], bump: string, dryRun: boolean): Promise<void> {
|
|
236
|
+
this.nxRuns.push({ packages: packageNames(packages), bump, dryRun });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async assertCleanGitTree(): Promise<void> {
|
|
240
|
+
this.cleanChecks += 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function packageNames(packages: ReleasePackageInfo[]): string[] {
|
|
245
|
+
return packages.map((pkg) => pkg.name);
|
|
246
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { ReleasePackageInfo } from '../core.js';
|
|
3
|
+
import { planPublishActions } from '../publish-plan.js';
|
|
4
|
+
|
|
5
|
+
const stable: ReleasePackageInfo = {
|
|
6
|
+
name: '@scope/stable',
|
|
7
|
+
projectName: 'stable',
|
|
8
|
+
path: 'packages/stable',
|
|
9
|
+
version: '1.0.0',
|
|
10
|
+
};
|
|
11
|
+
const prerelease: ReleasePackageInfo = {
|
|
12
|
+
name: '@scope/prerelease',
|
|
13
|
+
projectName: 'prerelease',
|
|
14
|
+
path: 'packages/prerelease',
|
|
15
|
+
version: '2.0.0-beta.1',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('publish plan', () => {
|
|
19
|
+
it('returns empty actions when durable release state is complete', () => {
|
|
20
|
+
expect(
|
|
21
|
+
planPublishActions({
|
|
22
|
+
releasePackages: [stable, prerelease],
|
|
23
|
+
npmMissingPackages: [],
|
|
24
|
+
githubMissingPackages: [],
|
|
25
|
+
}),
|
|
26
|
+
).toEqual({ buildProjects: [], publishPackages: [], githubReleasePackages: [] });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('builds and publishes exactly npm-missing packages', () => {
|
|
30
|
+
const plan = planPublishActions({
|
|
31
|
+
releasePackages: [stable, prerelease],
|
|
32
|
+
npmMissingPackages: [stable],
|
|
33
|
+
githubMissingPackages: [],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(plan.buildProjects).toEqual(['stable']);
|
|
37
|
+
expect(plan.publishPackages).toEqual([{ pkg: stable, distTag: 'latest' }]);
|
|
38
|
+
expect(plan.githubReleasePackages).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not build for GitHub-only repair actions', () => {
|
|
42
|
+
const plan = planPublishActions({
|
|
43
|
+
releasePackages: [stable, prerelease],
|
|
44
|
+
npmMissingPackages: [],
|
|
45
|
+
githubMissingPackages: [prerelease],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(plan.buildProjects).toEqual([]);
|
|
49
|
+
expect(plan.publishPackages).toEqual([]);
|
|
50
|
+
expect(plan.githubReleasePackages).toEqual([prerelease]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('uses latest for stable packages and next for prerelease packages', () => {
|
|
54
|
+
const plan = planPublishActions({
|
|
55
|
+
releasePackages: [stable, prerelease],
|
|
56
|
+
npmMissingPackages: [stable, prerelease],
|
|
57
|
+
githubMissingPackages: [],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(plan.buildProjects).toEqual(['stable', 'prerelease']);
|
|
61
|
+
expect(plan.publishPackages).toEqual([
|
|
62
|
+
{ pkg: stable, distTag: 'latest' },
|
|
63
|
+
{ pkg: prerelease, distTag: 'next' },
|
|
64
|
+
]);
|
|
65
|
+
expect(plan.githubReleasePackages).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { ReleasePackageInfo } from '../core.js';
|
|
3
|
+
import {
|
|
4
|
+
type RetagUnpublishedOptions,
|
|
5
|
+
type RetagUnpublishedShell,
|
|
6
|
+
type RetagUnpublishedTagUpdate,
|
|
7
|
+
retagUnpublished,
|
|
8
|
+
} from '../retag-unpublished.js';
|
|
9
|
+
|
|
10
|
+
const pkg: ReleasePackageInfo = { name: '@scope/pkg', projectName: 'pkg', path: 'packages/pkg', version: '1.2.3' };
|
|
11
|
+
|
|
12
|
+
describe('retag unpublished releases', () => {
|
|
13
|
+
it('moves unpublished owned tags, pushes with a remote lease, and dispatches publish auto', async () => {
|
|
14
|
+
const shell = new RecordingRetagShell({
|
|
15
|
+
versionAtRef: '1.2.3',
|
|
16
|
+
remoteTagObjects: new Map([['@scope/pkg@1.2.3', 'old-tag-object']]),
|
|
17
|
+
dispatchSha: 'target-sha',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const updates = await retagUnpublished(shell, retagOptions({ push: true, dispatch: true }));
|
|
21
|
+
|
|
22
|
+
expect(updates).toEqual([{ tag: '@scope/pkg@1.2.3', pkg, expectedRemoteObject: 'old-tag-object' }]);
|
|
23
|
+
expect(shell.movedTags).toEqual([{ tag: '@scope/pkg@1.2.3', ref: 'HEAD' }]);
|
|
24
|
+
expect(shell.pushed).toEqual([[{ tag: '@scope/pkg@1.2.3', expectedRemoteObject: 'old-tag-object' }]]);
|
|
25
|
+
expect(shell.dispatched).toEqual([{ workflow: 'publish.yml', branch: 'main' }]);
|
|
26
|
+
expect(shell.dispatchRefLookups).toEqual(['main']);
|
|
27
|
+
expect(shell.remoteTagLookups).toEqual(['@scope/pkg@1.2.3']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects tags whose package version already exists on npm', async () => {
|
|
31
|
+
const shell = new RecordingRetagShell({ versionAtRef: '1.2.3', npmPublished: true });
|
|
32
|
+
|
|
33
|
+
await expect(retagUnpublished(shell, retagOptions())).rejects.toThrow(
|
|
34
|
+
'Cannot retag @scope/pkg@1.2.3: @scope/pkg@1.2.3 already exists on npm.',
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(shell.movedTags).toEqual([]);
|
|
38
|
+
expect(shell.pushed).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects tags whose version does not match the target ref package manifest', async () => {
|
|
42
|
+
const shell = new RecordingRetagShell({ versionAtRef: '1.2.4' });
|
|
43
|
+
|
|
44
|
+
await expect(retagUnpublished(shell, retagOptions())).rejects.toThrow(
|
|
45
|
+
'Release tag @scope/pkg@1.2.3 cannot move to HEAD: packages/pkg/package.json has version 1.2.4, expected 1.2.3.',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects workflow dispatch when the target ref is not the remote branch head', async () => {
|
|
50
|
+
const shell = new RecordingRetagShell({ versionAtRef: '1.2.3', dispatchSha: 'other-sha' });
|
|
51
|
+
|
|
52
|
+
await expect(retagUnpublished(shell, retagOptions({ push: true, dispatch: true }))).rejects.toThrow(
|
|
53
|
+
'Cannot dispatch publish.yml: HEAD resolves to target-sha, but main resolves to other-sha.',
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(shell.movedTags).toEqual([]);
|
|
57
|
+
expect(shell.pushed).toEqual([]);
|
|
58
|
+
expect(shell.dispatched).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('dry-runs without moving, pushing, or dispatching tags', async () => {
|
|
62
|
+
const shell = new RecordingRetagShell({ versionAtRef: '1.2.3', dispatchSha: 'target-sha' });
|
|
63
|
+
|
|
64
|
+
await retagUnpublished(shell, retagOptions({ push: true, dispatch: true, dryRun: true }));
|
|
65
|
+
|
|
66
|
+
expect(shell.movedTags).toEqual([]);
|
|
67
|
+
expect(shell.pushed).toEqual([]);
|
|
68
|
+
expect(shell.dispatched).toEqual([]);
|
|
69
|
+
expect(shell.logs).toContain('Would move @scope/pkg@1.2.3 to HEAD (target-sha).');
|
|
70
|
+
expect(shell.logs).toContain('Would push 1 retagged release tag.');
|
|
71
|
+
expect(shell.logs).toContain('Would dispatch publish.yml on main with bump=auto.');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function retagOptions(overrides: Partial<RetagUnpublishedOptions> = {}): RetagUnpublishedOptions {
|
|
76
|
+
return {
|
|
77
|
+
tags: ['@scope/pkg@1.2.3'],
|
|
78
|
+
toRef: 'HEAD',
|
|
79
|
+
push: false,
|
|
80
|
+
dispatch: false,
|
|
81
|
+
dryRun: false,
|
|
82
|
+
branch: 'main',
|
|
83
|
+
workflow: 'publish.yml',
|
|
84
|
+
...overrides,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class RecordingRetagShell implements RetagUnpublishedShell<ReleasePackageInfo> {
|
|
89
|
+
readonly movedTags: Array<{ tag: string; ref: string }> = [];
|
|
90
|
+
readonly pushed: Array<Array<{ tag: string; expectedRemoteObject: string | null }>> = [];
|
|
91
|
+
readonly dispatched: Array<{ workflow: string; branch: string }> = [];
|
|
92
|
+
readonly dispatchRefLookups: string[] = [];
|
|
93
|
+
readonly remoteTagLookups: string[] = [];
|
|
94
|
+
readonly logs: string[] = [];
|
|
95
|
+
private readonly versionAtRef: string | null;
|
|
96
|
+
private readonly npmPublished: boolean;
|
|
97
|
+
private readonly githubReleaseCreated: boolean;
|
|
98
|
+
private readonly remoteTagObjects: Map<string, string>;
|
|
99
|
+
private readonly dispatchSha: string | null;
|
|
100
|
+
|
|
101
|
+
constructor(options: {
|
|
102
|
+
versionAtRef: string | null;
|
|
103
|
+
npmPublished?: boolean;
|
|
104
|
+
githubReleaseCreated?: boolean;
|
|
105
|
+
remoteTagObjects?: Map<string, string>;
|
|
106
|
+
dispatchSha?: string | null;
|
|
107
|
+
}) {
|
|
108
|
+
this.versionAtRef = options.versionAtRef;
|
|
109
|
+
this.npmPublished = options.npmPublished === true;
|
|
110
|
+
this.githubReleaseCreated = options.githubReleaseCreated === true;
|
|
111
|
+
this.remoteTagObjects = options.remoteTagObjects ?? new Map();
|
|
112
|
+
this.dispatchSha = options.dispatchSha ?? null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
listReleasePackages(): ReleasePackageInfo[] {
|
|
116
|
+
return [pkg];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async resolveRef(): Promise<string> {
|
|
120
|
+
return 'target-sha';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async resolveDispatchRef(branch: string): Promise<string | null> {
|
|
124
|
+
this.dispatchRefLookups.push(branch);
|
|
125
|
+
return this.dispatchSha;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async packageVersionAtRef(): Promise<string | null> {
|
|
129
|
+
return this.versionAtRef;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async npmVersionExists(): Promise<boolean> {
|
|
133
|
+
return this.npmPublished;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async githubReleaseExists(): Promise<boolean> {
|
|
137
|
+
return this.githubReleaseCreated;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async remoteTagObject(tag: string): Promise<string | null> {
|
|
141
|
+
this.remoteTagLookups.push(tag);
|
|
142
|
+
return this.remoteTagObjects.get(tag) ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async createOrMoveTag(tag: string, ref: string): Promise<void> {
|
|
146
|
+
this.movedTags.push({ tag, ref });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async pushTags(updates: Array<RetagUnpublishedTagUpdate<ReleasePackageInfo>>): Promise<void> {
|
|
150
|
+
this.pushed.push(updates.map((update) => ({ tag: update.tag, expectedRemoteObject: update.expectedRemoteObject })));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async dispatchPublishWorkflow(workflow: string, branch: string): Promise<void> {
|
|
154
|
+
this.dispatched.push({ workflow, branch });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
log(message: string): void {
|
|
158
|
+
this.logs.push(message);
|
|
159
|
+
}
|
|
160
|
+
}
|