@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
package/src/lib/workspace.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, relative } from 'node:path';
|
|
3
|
+
import { isSmoothBricksCodebasePackageName } from './cli-package.js';
|
|
3
4
|
import { hasOwn, hasOwnString, isRecord, readJson, readJsonObject, stringProperty } from './json.js';
|
|
4
5
|
|
|
5
6
|
export interface PackageInfo {
|
|
6
7
|
name: string;
|
|
8
|
+
projectName: string;
|
|
7
9
|
version: string;
|
|
8
10
|
private: boolean;
|
|
9
11
|
tags: string[];
|
|
@@ -12,22 +14,81 @@ export interface PackageInfo {
|
|
|
12
14
|
json: Record<string, unknown>;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
export interface PackageJson extends Record<string, unknown> {
|
|
18
|
+
name?: string;
|
|
19
|
+
version?: string;
|
|
20
|
+
private?: boolean;
|
|
21
|
+
workspaces?: unknown;
|
|
22
|
+
devDependencies?: unknown;
|
|
23
|
+
dependencies?: unknown;
|
|
24
|
+
nx?: unknown;
|
|
25
|
+
repository?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
export interface RepositoryInfo {
|
|
16
29
|
type: string;
|
|
17
30
|
url: string;
|
|
18
31
|
}
|
|
19
32
|
|
|
20
|
-
export const workspaceDependencyFields = [
|
|
33
|
+
export const workspaceDependencyFields = [
|
|
34
|
+
'dependencies',
|
|
35
|
+
'devDependencies',
|
|
36
|
+
'peerDependencies',
|
|
37
|
+
'optionalDependencies',
|
|
38
|
+
] as const;
|
|
21
39
|
|
|
22
40
|
export function listPublicPackages(root: string): PackageInfo[] {
|
|
23
41
|
return getWorkspacePackages(root).filter((pkg) => !pkg.private && pkg.tags.includes('npm:public'));
|
|
24
42
|
}
|
|
25
43
|
|
|
44
|
+
export function listReleasePackages(
|
|
45
|
+
root: string,
|
|
46
|
+
rootPackage = readPackageJson(join(root, 'package.json')),
|
|
47
|
+
): PackageInfo[] {
|
|
48
|
+
if (!rootPackage) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const rootRepository = repositoryInfo(rootPackage.json);
|
|
52
|
+
if (!rootRepository) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return getWorkspacePackagesForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage.json)).filter(
|
|
56
|
+
(pkg) => !pkg.private && pkg.tags.includes('npm:public') && isOwnedPackage(rootRepository, pkg),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function rootRepositoryInfo(root: string): RepositoryInfo | null {
|
|
61
|
+
const rootPackage = readJsonObject(join(root, 'package.json'));
|
|
62
|
+
return rootPackage ? repositoryInfo(rootPackage) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function rootPackageName(root: string): string | null {
|
|
66
|
+
const rootPackage = readPackageJsonObject(join(root, 'package.json'));
|
|
67
|
+
return rootPackage ? stringProperty(rootPackage, 'name') : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isSmoothBricksCodebase(root: string): boolean {
|
|
71
|
+
return isSmoothBricksCodebasePackageName(rootPackageName(root) ?? undefined);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function packageRepositoryInfo(pkg: PackageInfo): RepositoryInfo | null {
|
|
75
|
+
return repositoryInfo(pkg.json);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isOwnedPackage(rootRepository: RepositoryInfo, pkg: PackageInfo): boolean {
|
|
79
|
+
const packageRepository = packageRepositoryInfo(pkg);
|
|
80
|
+
return packageRepository !== null && packageRepository.url === rootRepository.url;
|
|
81
|
+
}
|
|
82
|
+
|
|
26
83
|
export function getWorkspacePackages(root: string): PackageInfo[] {
|
|
27
|
-
|
|
84
|
+
const rootPackage = readPackageJson(join(root, 'package.json'));
|
|
85
|
+
if (!rootPackage) {
|
|
28
86
|
throw new Error('package.json not found or invalid');
|
|
29
87
|
}
|
|
30
|
-
|
|
88
|
+
return getWorkspacePackagesForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage.json));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getWorkspacePackagesForPatterns(root: string, workspacePatterns: string[]): PackageInfo[] {
|
|
31
92
|
const packages: PackageInfo[] = [];
|
|
32
93
|
for (const pattern of workspacePatterns) {
|
|
33
94
|
if (!pattern.endsWith('/*')) {
|
|
@@ -45,6 +106,7 @@ export function getWorkspacePackages(root: string): PackageInfo[] {
|
|
|
45
106
|
}
|
|
46
107
|
packages.push({
|
|
47
108
|
name: pkg.name,
|
|
109
|
+
projectName: pkg.projectName,
|
|
48
110
|
version: pkg.version,
|
|
49
111
|
private: pkg.private,
|
|
50
112
|
tags: pkg.tags,
|
|
@@ -62,15 +124,22 @@ export function listPackageJsonRecords(root: string): PackageInfo[] {
|
|
|
62
124
|
if (!rootPackage) {
|
|
63
125
|
throw new Error('package.json not found or invalid');
|
|
64
126
|
}
|
|
65
|
-
return [
|
|
127
|
+
return [
|
|
128
|
+
{ ...rootPackage, path: '.' },
|
|
129
|
+
...getWorkspacePackagesForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage.json)),
|
|
130
|
+
];
|
|
66
131
|
}
|
|
67
132
|
|
|
68
133
|
export function getWorkspacePatterns(root: string): string[] {
|
|
69
134
|
const raw = readJson(join(root, 'package.json'));
|
|
70
|
-
|
|
135
|
+
return isRecord(raw) ? getWorkspacePatternsFromPackageJson(raw) : ['packages/*'];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getWorkspacePatternsFromPackageJson(pkg: Record<string, unknown>): string[] {
|
|
139
|
+
if (!hasOwn(pkg, 'workspaces')) {
|
|
71
140
|
return ['packages/*'];
|
|
72
141
|
}
|
|
73
|
-
const workspaces =
|
|
142
|
+
const workspaces = pkg.workspaces;
|
|
74
143
|
if (Array.isArray(workspaces)) {
|
|
75
144
|
return workspaces.filter((entry): entry is string => typeof entry === 'string');
|
|
76
145
|
}
|
|
@@ -81,7 +150,7 @@ export function getWorkspacePatterns(root: string): string[] {
|
|
|
81
150
|
}
|
|
82
151
|
|
|
83
152
|
export function readPackageJson(path: string): PackageInfo | null {
|
|
84
|
-
const parsed =
|
|
153
|
+
const parsed = readPackageJsonObject(path);
|
|
85
154
|
if (!isRecord(parsed) || !hasOwnString(parsed, 'name') || !hasOwnString(parsed, 'version')) {
|
|
86
155
|
return null;
|
|
87
156
|
}
|
|
@@ -89,6 +158,7 @@ export function readPackageJson(path: string): PackageInfo | null {
|
|
|
89
158
|
const tags = getNxTags(parsed);
|
|
90
159
|
return {
|
|
91
160
|
name: parsed.name,
|
|
161
|
+
projectName: packageNxProjectName(parsed),
|
|
92
162
|
version: parsed.version,
|
|
93
163
|
private: privateValue,
|
|
94
164
|
tags,
|
|
@@ -98,6 +168,16 @@ export function readPackageJson(path: string): PackageInfo | null {
|
|
|
98
168
|
};
|
|
99
169
|
}
|
|
100
170
|
|
|
171
|
+
function packageNxProjectName(pkg: Record<string, unknown>): string {
|
|
172
|
+
const nx = isRecord(pkg.nx) ? pkg.nx : null;
|
|
173
|
+
return (nx ? stringProperty(nx, 'name') : null) ?? stringProperty(pkg, 'name') ?? '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function readPackageJsonObject(path: string): PackageJson | null {
|
|
177
|
+
const parsed = readJsonObject(path);
|
|
178
|
+
return parsed;
|
|
179
|
+
}
|
|
180
|
+
|
|
101
181
|
export function getNxTags(pkg: Record<string, unknown>): string[] {
|
|
102
182
|
if (!hasOwn(pkg, 'nx') || !isRecord(pkg.nx) || !hasOwn(pkg.nx, 'tags') || !Array.isArray(pkg.nx.tags)) {
|
|
103
183
|
return [];
|
|
@@ -120,6 +200,36 @@ export function repositoryInfo(pkg: Record<string, unknown>): RepositoryInfo | n
|
|
|
120
200
|
return { type: stringProperty(repository, 'type') ?? 'git', url };
|
|
121
201
|
}
|
|
122
202
|
|
|
203
|
+
export function sameRepositoryAfterNormalization(left: string, right: string): boolean {
|
|
204
|
+
return normalizedRepositoryUrl(left) === normalizedRepositoryUrl(right);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizedRepositoryUrl(url: string): string {
|
|
208
|
+
const trimmed = url
|
|
209
|
+
.trim()
|
|
210
|
+
.replace(/^git\+/, '')
|
|
211
|
+
.replace(/#.*$/, '')
|
|
212
|
+
.replace(/\/$/, '')
|
|
213
|
+
.replace(/\.git$/, '');
|
|
214
|
+
const https = /^https:\/\/github\.com\/([^/]+)\/([^/]+)$/i.exec(trimmed);
|
|
215
|
+
if (https?.[1] && https[2]) {
|
|
216
|
+
return githubRepositoryKey(https[1], https[2]);
|
|
217
|
+
}
|
|
218
|
+
const ssh = /^git@github\.com:([^/]+)\/([^/]+)$/i.exec(trimmed);
|
|
219
|
+
if (ssh?.[1] && ssh[2]) {
|
|
220
|
+
return githubRepositoryKey(ssh[1], ssh[2]);
|
|
221
|
+
}
|
|
222
|
+
const shorthand = /^github:([^/]+)\/([^/]+)$/i.exec(trimmed);
|
|
223
|
+
if (shorthand?.[1] && shorthand[2]) {
|
|
224
|
+
return githubRepositoryKey(shorthand[1], shorthand[2]);
|
|
225
|
+
}
|
|
226
|
+
return trimmed;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function githubRepositoryKey(owner: string, repo: string): string {
|
|
230
|
+
return `github:${owner.toLowerCase()}/${repo.toLowerCase()}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
123
233
|
export function escapeRegex(value: string): string {
|
|
124
234
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
125
235
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { $ } from 'bun';
|
|
6
|
+
import { syncBunLockfileVersions } from '../lockfile.js';
|
|
7
|
+
|
|
8
|
+
describe('nx-version-actions Bun lockfile workaround', () => {
|
|
9
|
+
it('repairs stale workspace versions that Bun still uses when packing workspace:* dependencies', async () => {
|
|
10
|
+
await withBunWorkspace(async (root) => {
|
|
11
|
+
await writePackage(root, 'a', {
|
|
12
|
+
name: '@fixture/a',
|
|
13
|
+
version: '1.0.0',
|
|
14
|
+
dependencies: { '@fixture/b': 'workspace:*' },
|
|
15
|
+
});
|
|
16
|
+
await writePackage(root, 'b', { name: '@fixture/b', version: '1.0.0' });
|
|
17
|
+
await $`bun install --lockfile-only`.cwd(root).quiet();
|
|
18
|
+
|
|
19
|
+
await writePackage(root, 'b', { name: '@fixture/b', version: '1.0.1' });
|
|
20
|
+
await $`bun install --lockfile-only`.cwd(root).quiet();
|
|
21
|
+
|
|
22
|
+
const stalePackedVersion = await packedDependencyVersion(root, 1);
|
|
23
|
+
if (stalePackedVersion !== '1.0.0') {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'Hurrah! Bun no longer reproduces the stale workspace lockfile pack bug. ' +
|
|
26
|
+
`Raw bun pm pack resolved @fixture/b to ${stalePackedVersion}; remove the nx-version-actions hook and update the release docs.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
expect(syncBunLockfileVersions(root, { log: false })).toBe(1);
|
|
31
|
+
await expect(packedDependencyVersion(root, 2)).resolves.toBe('1.0.1');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function withBunWorkspace(fn: (root: string) => Promise<void>): Promise<void> {
|
|
37
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-bun-lock-test-'));
|
|
38
|
+
try {
|
|
39
|
+
await writeFile(
|
|
40
|
+
join(root, 'package.json'),
|
|
41
|
+
`${JSON.stringify({ name: 'fixture-root', version: '0.0.0', private: true, packageManager: 'bun@1.3.13', workspaces: ['packages/*'] }, null, 2)}\n`,
|
|
42
|
+
);
|
|
43
|
+
await fn(root);
|
|
44
|
+
} finally {
|
|
45
|
+
await rm(root, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function writePackage(
|
|
50
|
+
root: string,
|
|
51
|
+
directory: string,
|
|
52
|
+
manifest: { name: string; version: string; dependencies?: Record<string, string> },
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const packageRoot = join(root, 'packages', directory);
|
|
55
|
+
await mkdir(packageRoot, { recursive: true });
|
|
56
|
+
await writeFile(join(packageRoot, 'index.js'), 'export {}\n');
|
|
57
|
+
await writeFile(
|
|
58
|
+
join(packageRoot, 'package.json'),
|
|
59
|
+
`${JSON.stringify({ ...manifest, type: 'module', exports: './index.js', files: ['index.js'] }, null, 2)}\n`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function packedDependencyVersion(root: string, index: number): Promise<string> {
|
|
64
|
+
const tarball = join(root, `a-${index}.tgz`);
|
|
65
|
+
const unpacked = join(root, `unpacked-${index}`);
|
|
66
|
+
await mkdir(unpacked);
|
|
67
|
+
await $`bun pm pack --filename ${tarball} --ignore-scripts --quiet`.cwd(join(root, 'packages/a')).quiet();
|
|
68
|
+
await $`tar -xzf ${tarball} -C ${unpacked}`.quiet();
|
|
69
|
+
const manifest = JSON.parse(await readFile(join(unpacked, 'package', 'package.json'), 'utf8'));
|
|
70
|
+
const version = manifest.dependencies?.['@fixture/b'];
|
|
71
|
+
if (typeof version !== 'string') {
|
|
72
|
+
throw new Error('Packed @fixture/a manifest did not include @fixture/b dependency.');
|
|
73
|
+
}
|
|
74
|
+
return version;
|
|
75
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
definePublishWorkflow,
|
|
6
|
+
type PublishWorkflowBump,
|
|
7
|
+
type PublishWorkflowCallbacks,
|
|
8
|
+
type PublishWorkflowNxTarget,
|
|
9
|
+
type PublishWorkflowVersionOutputs,
|
|
10
|
+
renderPublishWorkflowYaml,
|
|
11
|
+
runPublishWorkflow,
|
|
12
|
+
} from '../publish-workflow.js';
|
|
13
|
+
|
|
14
|
+
describe('publish workflow definition', () => {
|
|
15
|
+
it('renders the checked-in local publish workflow copy', async () => {
|
|
16
|
+
const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
|
|
17
|
+
const packageRoot = join(import.meta.dir, '..', '..', '..');
|
|
18
|
+
await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/publish.yml'), 'utf8')).resolves.toBe(
|
|
19
|
+
rendered,
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('does not wire npm token secrets into repair or publish steps', () => {
|
|
24
|
+
const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
|
|
25
|
+
|
|
26
|
+
expect(rendered).not.toContain('NODE_AUTH_TOKEN');
|
|
27
|
+
expect(rendered).not.toContain('secrets.NPM_TOKEN');
|
|
28
|
+
expect(rendered).toContain('packages must already exist on npm and use trusted publishing/OIDC');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('bootstraps the self-hosted CLI before release commands only for the SmoothBricks repo', async () => {
|
|
32
|
+
const smoothbricks = await publishWorkflowScenario({
|
|
33
|
+
repoName: '@smoothbricks/codebase',
|
|
34
|
+
repairs: [],
|
|
35
|
+
current: [],
|
|
36
|
+
bump: 'patch',
|
|
37
|
+
dryRun: false,
|
|
38
|
+
version: { mode: 'none', projects: [] },
|
|
39
|
+
}).run();
|
|
40
|
+
const downstream = await publishWorkflowScenario({
|
|
41
|
+
repoName: '@other/repo',
|
|
42
|
+
repairs: [],
|
|
43
|
+
current: [],
|
|
44
|
+
bump: 'patch',
|
|
45
|
+
dryRun: false,
|
|
46
|
+
version: { mode: 'none', projects: [] },
|
|
47
|
+
}).run();
|
|
48
|
+
|
|
49
|
+
expect(smoothbricks.selfHostedCliBuilt).toBe(true);
|
|
50
|
+
expect(smoothbricks.repairSawSelfHostedCli).toBe(true);
|
|
51
|
+
expect(smoothbricks.versionSawSelfHostedCli).toBe(true);
|
|
52
|
+
expect(downstream.selfHostedCliBuilt).toBe(false);
|
|
53
|
+
expect(downstream.repairSawSelfHostedCli).toBe(false);
|
|
54
|
+
expect(downstream.versionSawSelfHostedCli).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('repairs older gaps, skips validation for mode none, and still completes current HEAD publish', async () => {
|
|
58
|
+
const scenario = publishWorkflowScenario({
|
|
59
|
+
repairs: [
|
|
60
|
+
{ tag: '@scope/a@1.1.0', npmMissing: false, githubMissing: true },
|
|
61
|
+
{ tag: '@scope/b@2.0.0-beta.1', npmMissing: true, githubMissing: true },
|
|
62
|
+
],
|
|
63
|
+
current: [{ tag: '@scope/a@1.2.0', npmMissing: true, githubMissing: false }],
|
|
64
|
+
bump: 'auto',
|
|
65
|
+
dryRun: false,
|
|
66
|
+
version: { mode: 'none', projects: [] },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const outcome = await scenario.run();
|
|
70
|
+
|
|
71
|
+
expect(outcome.fixtureRepoSetup).toBe(true);
|
|
72
|
+
expect(outcome.releaseAuthorConfigured).toBe(true);
|
|
73
|
+
expect(outcome.repairedTags).toEqual(['@scope/a@1.1.0', '@scope/b@2.0.0-beta.1']);
|
|
74
|
+
expect(outcome.repairBuildArtifacts).toEqual(['@scope/b']);
|
|
75
|
+
expect(outcome.validation).toEqual({ checks: 0, builds: [], lints: [], tests: [], validates: 0 });
|
|
76
|
+
expect(outcome.publishRan).toBe(true);
|
|
77
|
+
expect(outcome.publishCompletedTags).toEqual(['@scope/a@1.2.0']);
|
|
78
|
+
expect(outcome.remainingDurableGaps).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('validates a new release before publishing current HEAD gaps', async () => {
|
|
82
|
+
const scenario = publishWorkflowScenario({
|
|
83
|
+
repairs: [],
|
|
84
|
+
current: [
|
|
85
|
+
{ tag: '@scope/a@1.2.0', npmMissing: true, githubMissing: true },
|
|
86
|
+
{ tag: '@scope/b@2.0.0', npmMissing: false, githubMissing: true },
|
|
87
|
+
],
|
|
88
|
+
bump: 'patch',
|
|
89
|
+
dryRun: false,
|
|
90
|
+
version: { mode: 'new', projects: ['a', 'b'] },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const outcome = await scenario.run();
|
|
94
|
+
|
|
95
|
+
expect(outcome.validation).toEqual({
|
|
96
|
+
checks: 1,
|
|
97
|
+
builds: ['a', 'b'],
|
|
98
|
+
lints: ['a', 'b'],
|
|
99
|
+
tests: ['a', 'b'],
|
|
100
|
+
validates: 1,
|
|
101
|
+
});
|
|
102
|
+
expect(outcome.publishSawValidatedRelease).toBe(true);
|
|
103
|
+
expect(outcome.publishCompletedTags).toEqual(['@scope/a@1.2.0', '@scope/b@2.0.0']);
|
|
104
|
+
expect(outcome.remainingDurableGaps).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('dry-run walks the workflow outcomes without mutating durable release state', async () => {
|
|
108
|
+
const scenario = publishWorkflowScenario({
|
|
109
|
+
repairs: [{ tag: '@scope/a@1.1.0', npmMissing: true, githubMissing: true }],
|
|
110
|
+
current: [{ tag: '@scope/a@1.2.0', npmMissing: true, githubMissing: true }],
|
|
111
|
+
bump: 'prerelease',
|
|
112
|
+
dryRun: true,
|
|
113
|
+
version: { mode: 'new', projects: ['a'] },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const outcome = await scenario.run();
|
|
117
|
+
|
|
118
|
+
expect(outcome.fixtureRepoSetup).toBe(true);
|
|
119
|
+
expect(outcome.repairedTags).toEqual([]);
|
|
120
|
+
expect(outcome.repairBuildArtifacts).toEqual([]);
|
|
121
|
+
expect(outcome.validation).toEqual({
|
|
122
|
+
checks: 1,
|
|
123
|
+
builds: ['a'],
|
|
124
|
+
lints: ['a'],
|
|
125
|
+
tests: ['a'],
|
|
126
|
+
validates: 1,
|
|
127
|
+
});
|
|
128
|
+
expect(outcome.publishRan).toBe(true);
|
|
129
|
+
expect(outcome.publishCompletedTags).toEqual([]);
|
|
130
|
+
expect(outcome.remainingDurableGaps).toEqual([
|
|
131
|
+
'@scope/a@1.1.0:github',
|
|
132
|
+
'@scope/a@1.1.0:npm',
|
|
133
|
+
'@scope/a@1.2.0:github',
|
|
134
|
+
'@scope/a@1.2.0:npm',
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
interface ReleaseGap {
|
|
140
|
+
tag: string;
|
|
141
|
+
npmMissing: boolean;
|
|
142
|
+
githubMissing: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface WorkflowScenarioConfig {
|
|
146
|
+
repoName?: string;
|
|
147
|
+
repairs: ReleaseGap[];
|
|
148
|
+
current: ReleaseGap[];
|
|
149
|
+
bump: PublishWorkflowBump;
|
|
150
|
+
dryRun: boolean;
|
|
151
|
+
version: PublishWorkflowVersionOutputs;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface WorkflowOutcome {
|
|
155
|
+
fixtureRepoSetup: boolean;
|
|
156
|
+
releaseAuthorConfigured: boolean;
|
|
157
|
+
selfHostedCliBuilt: boolean;
|
|
158
|
+
repairSawSelfHostedCli: boolean;
|
|
159
|
+
versionSawSelfHostedCli: boolean;
|
|
160
|
+
repairedTags: string[];
|
|
161
|
+
repairBuildArtifacts: string[];
|
|
162
|
+
validation: { checks: number; builds: string[]; lints: string[]; tests: string[]; validates: number };
|
|
163
|
+
publishRan: boolean;
|
|
164
|
+
publishSawValidatedRelease: boolean;
|
|
165
|
+
publishCompletedTags: string[];
|
|
166
|
+
remainingDurableGaps: string[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function publishWorkflowScenario(config: WorkflowScenarioConfig): { run(): Promise<WorkflowOutcome> } {
|
|
170
|
+
return {
|
|
171
|
+
async run() {
|
|
172
|
+
const state = new WorkflowScenarioState(config);
|
|
173
|
+
await runPublishWorkflow(definePublishWorkflow({ repoName: config.repoName }), {
|
|
174
|
+
inputs: { bump: config.bump, dryRun: config.dryRun },
|
|
175
|
+
callbacks: state.callbacks(),
|
|
176
|
+
});
|
|
177
|
+
return state.outcome();
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
class WorkflowScenarioState {
|
|
183
|
+
private fixtureSetup = false;
|
|
184
|
+
private authorConfigured = false;
|
|
185
|
+
private selfHostedCli = false;
|
|
186
|
+
private repairObservedSelfHostedCli = false;
|
|
187
|
+
private versionObservedSelfHostedCli = false;
|
|
188
|
+
private publishReleaseRan = false;
|
|
189
|
+
private publishSawValidation = false;
|
|
190
|
+
private readonly repaired = new Set<string>();
|
|
191
|
+
private readonly repairBuilds = new Set<string>();
|
|
192
|
+
private readonly publishedCurrent = new Set<string>();
|
|
193
|
+
private readonly durableGaps = new Set<string>();
|
|
194
|
+
private readonly validationState = {
|
|
195
|
+
checks: 0,
|
|
196
|
+
builds: [] as string[],
|
|
197
|
+
lints: [] as string[],
|
|
198
|
+
tests: [] as string[],
|
|
199
|
+
validates: 0,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
constructor(private readonly config: WorkflowScenarioConfig) {
|
|
203
|
+
for (const gap of [...config.repairs, ...config.current]) {
|
|
204
|
+
if (gap.githubMissing) {
|
|
205
|
+
this.durableGaps.add(`${gap.tag}:github`);
|
|
206
|
+
}
|
|
207
|
+
if (gap.npmMissing) {
|
|
208
|
+
this.durableGaps.add(`${gap.tag}:npm`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
callbacks(): PublishWorkflowCallbacks {
|
|
214
|
+
return {
|
|
215
|
+
checkout: async () => {},
|
|
216
|
+
setupDevenv: async () => {
|
|
217
|
+
this.fixtureSetup = true;
|
|
218
|
+
return { nixCacheHit: 'false', devenvCacheHit: 'false' };
|
|
219
|
+
},
|
|
220
|
+
configureReleaseAuthor: async () => {
|
|
221
|
+
this.authorConfigured = true;
|
|
222
|
+
},
|
|
223
|
+
buildSelfHostedCli: async () => {
|
|
224
|
+
this.selfHostedCli = true;
|
|
225
|
+
},
|
|
226
|
+
repairPendingReleases: async ({ dryRun }) => {
|
|
227
|
+
this.repairObservedSelfHostedCli = this.selfHostedCli;
|
|
228
|
+
if (dryRun) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const gap of this.config.repairs) {
|
|
232
|
+
if (gap.npmMissing) {
|
|
233
|
+
this.repairBuilds.add(packageNameFromTag(gap.tag));
|
|
234
|
+
this.durableGaps.delete(`${gap.tag}:npm`);
|
|
235
|
+
}
|
|
236
|
+
if (gap.githubMissing) {
|
|
237
|
+
this.durableGaps.delete(`${gap.tag}:github`);
|
|
238
|
+
}
|
|
239
|
+
this.repaired.add(gap.tag);
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
versionRelease: async ({ bump, dryRun }) => {
|
|
243
|
+
this.versionObservedSelfHostedCli = this.selfHostedCli;
|
|
244
|
+
expect(bump).toBe(this.config.bump);
|
|
245
|
+
expect(dryRun).toBe(this.config.dryRun);
|
|
246
|
+
return this.config.version;
|
|
247
|
+
},
|
|
248
|
+
checkManagedMonorepoFiles: async () => {
|
|
249
|
+
this.validationState.checks += 1;
|
|
250
|
+
},
|
|
251
|
+
nxRunMany: async ({ target, projects }) => {
|
|
252
|
+
this.validationProjects(target).push(...projects);
|
|
253
|
+
},
|
|
254
|
+
uploadTraceDbs: async () => {},
|
|
255
|
+
validateMonorepoConfig: async () => {
|
|
256
|
+
this.validationState.validates += 1;
|
|
257
|
+
},
|
|
258
|
+
publishRelease: async ({ bump, dryRun }) => {
|
|
259
|
+
expect(bump).toBe(this.config.bump);
|
|
260
|
+
expect(dryRun).toBe(this.config.dryRun);
|
|
261
|
+
this.publishReleaseRan = true;
|
|
262
|
+
this.publishSawValidation = this.config.version.mode === 'none' || this.validationState.validates > 0;
|
|
263
|
+
if (dryRun) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
for (const gap of this.config.current) {
|
|
267
|
+
if (gap.npmMissing) {
|
|
268
|
+
this.durableGaps.delete(`${gap.tag}:npm`);
|
|
269
|
+
}
|
|
270
|
+
if (gap.githubMissing) {
|
|
271
|
+
this.durableGaps.delete(`${gap.tag}:github`);
|
|
272
|
+
}
|
|
273
|
+
if (gap.npmMissing || gap.githubMissing) {
|
|
274
|
+
this.publishedCurrent.add(gap.tag);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
saveNixDevenv: async () => {},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
outcome(): WorkflowOutcome {
|
|
283
|
+
return {
|
|
284
|
+
fixtureRepoSetup: this.fixtureSetup,
|
|
285
|
+
releaseAuthorConfigured: this.authorConfigured,
|
|
286
|
+
selfHostedCliBuilt: this.selfHostedCli,
|
|
287
|
+
repairSawSelfHostedCli: this.repairObservedSelfHostedCli,
|
|
288
|
+
versionSawSelfHostedCli: this.versionObservedSelfHostedCli,
|
|
289
|
+
repairedTags: [...this.repaired].sort(),
|
|
290
|
+
repairBuildArtifacts: [...this.repairBuilds].sort(),
|
|
291
|
+
validation: {
|
|
292
|
+
checks: this.validationState.checks,
|
|
293
|
+
builds: [...this.validationState.builds],
|
|
294
|
+
lints: [...this.validationState.lints],
|
|
295
|
+
tests: [...this.validationState.tests],
|
|
296
|
+
validates: this.validationState.validates,
|
|
297
|
+
},
|
|
298
|
+
publishRan: this.publishReleaseRan,
|
|
299
|
+
publishSawValidatedRelease: this.publishSawValidation,
|
|
300
|
+
publishCompletedTags: [...this.publishedCurrent].sort(),
|
|
301
|
+
remainingDurableGaps: [...this.durableGaps].sort(),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private validationProjects(target: PublishWorkflowNxTarget): string[] {
|
|
306
|
+
if (target === 'build') {
|
|
307
|
+
return this.validationState.builds;
|
|
308
|
+
}
|
|
309
|
+
if (target === 'lint') {
|
|
310
|
+
return this.validationState.lints;
|
|
311
|
+
}
|
|
312
|
+
return this.validationState.tests;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function packageNameFromTag(tag: string): string {
|
|
317
|
+
const versionSeparator = tag.lastIndexOf('@');
|
|
318
|
+
if (versionSeparator <= 0) {
|
|
319
|
+
throw new Error(`Invalid release tag fixture ${tag}.`);
|
|
320
|
+
}
|
|
321
|
+
return tag.slice(0, versionSeparator);
|
|
322
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { formatCommitMessage, validateCommitMessage } from './commit-msg.js';
|
|
3
|
+
|
|
4
|
+
describe('commit message validation', () => {
|
|
5
|
+
it('accepts Nx conventional commit types and configured scopes', () => {
|
|
6
|
+
expect(validateCommitMessage('types(cli): expose public API\n', { validScopes: new Set(['cli']) })).toBeNull();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('rejects scopes outside configured Nx names', () => {
|
|
10
|
+
expect(
|
|
11
|
+
validateCommitMessage('fix(@smoothbricks/cli): repair release\n', { validScopes: new Set(['cli']) }),
|
|
12
|
+
).toContain('Invalid conventional commit scope');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('commit message formatting', () => {
|
|
17
|
+
it('wraps prose and preserves markdown blocks', () => {
|
|
18
|
+
const message = formatCommitMessage(
|
|
19
|
+
[
|
|
20
|
+
'fix(cli): wrap commit bodies ',
|
|
21
|
+
'',
|
|
22
|
+
'This paragraph is intentionally long enough to be wrapped by the injected formatter callback while preserving non-prose markdown sections.',
|
|
23
|
+
'',
|
|
24
|
+
'```',
|
|
25
|
+
'long log errors should stay exactly as they are because this is a fenced block',
|
|
26
|
+
'```',
|
|
27
|
+
'',
|
|
28
|
+
'> quoted markdown should also stay as a single untouched line even when it is long enough to otherwise need wrapping',
|
|
29
|
+
].join('\n'),
|
|
30
|
+
{ wrapBody: (paragraph) => paragraph.replace(' while ', '\nwhile ') },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(message).toBe(`fix(cli): wrap commit bodies
|
|
34
|
+
|
|
35
|
+
This paragraph is intentionally long enough to be wrapped by the injected formatter callback
|
|
36
|
+
while preserving non-prose markdown sections.
|
|
37
|
+
|
|
38
|
+
\`\`\`
|
|
39
|
+
long log errors should stay exactly as they are because this is a fenced block
|
|
40
|
+
\`\`\`
|
|
41
|
+
|
|
42
|
+
> quoted markdown should also stay as a single untouched line even when it is long enough to otherwise need wrapping
|
|
43
|
+
`);
|
|
44
|
+
});
|
|
45
|
+
});
|