@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.
Files changed (148) hide show
  1. package/README.md +338 -88
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +136 -22
  4. package/dist/devenv/index.d.ts +10 -0
  5. package/dist/devenv/index.d.ts.map +1 -0
  6. package/dist/devenv/index.js +27 -0
  7. package/dist/lib/cli-package.d.ts +4 -0
  8. package/dist/lib/cli-package.d.ts.map +1 -0
  9. package/dist/lib/cli-package.js +15 -0
  10. package/dist/lib/devenv.d.ts +6 -0
  11. package/dist/lib/devenv.d.ts.map +1 -0
  12. package/dist/lib/devenv.js +82 -0
  13. package/dist/lib/run.d.ts +5 -0
  14. package/dist/lib/run.d.ts.map +1 -1
  15. package/dist/lib/run.js +13 -0
  16. package/dist/lib/workspace.d.ts +20 -1
  17. package/dist/lib/workspace.d.ts.map +1 -1
  18. package/dist/lib/workspace.js +87 -7
  19. package/dist/monorepo/commit-msg.d.ts +8 -1
  20. package/dist/monorepo/commit-msg.d.ts.map +1 -1
  21. package/dist/monorepo/commit-msg.js +85 -7
  22. package/dist/monorepo/index.d.ts +24 -2
  23. package/dist/monorepo/index.d.ts.map +1 -1
  24. package/dist/monorepo/index.js +136 -15
  25. package/dist/monorepo/lockfile.d.ts +5 -1
  26. package/dist/monorepo/lockfile.d.ts.map +1 -1
  27. package/dist/monorepo/lockfile.js +45 -5
  28. package/dist/monorepo/managed-files.d.ts +1 -1
  29. package/dist/monorepo/managed-files.d.ts.map +1 -1
  30. package/dist/monorepo/managed-files.js +28 -11
  31. package/dist/monorepo/nx-sync.d.ts +2 -1
  32. package/dist/monorepo/nx-sync.d.ts.map +1 -1
  33. package/dist/monorepo/nx-sync.js +8 -2
  34. package/dist/monorepo/package-hygiene.d.ts +6 -2
  35. package/dist/monorepo/package-hygiene.d.ts.map +1 -1
  36. package/dist/monorepo/package-hygiene.js +12 -4
  37. package/dist/monorepo/package-policy.d.ts +25 -2
  38. package/dist/monorepo/package-policy.d.ts.map +1 -1
  39. package/dist/monorepo/package-policy.js +1289 -27
  40. package/dist/monorepo/packed-manifest.d.ts +4 -0
  41. package/dist/monorepo/packed-manifest.d.ts.map +1 -0
  42. package/dist/monorepo/packed-manifest.js +51 -0
  43. package/dist/monorepo/packed-package.d.ts +3 -0
  44. package/dist/monorepo/packed-package.d.ts.map +1 -1
  45. package/dist/monorepo/packed-package.js +69 -31
  46. package/dist/monorepo/packs/index.d.ts +20 -1
  47. package/dist/monorepo/packs/index.d.ts.map +1 -1
  48. package/dist/monorepo/packs/index.js +240 -18
  49. package/dist/monorepo/publish-workflow.d.ts +82 -0
  50. package/dist/monorepo/publish-workflow.d.ts.map +1 -0
  51. package/dist/monorepo/publish-workflow.js +336 -0
  52. package/dist/monorepo/tool-validation.d.ts +22 -0
  53. package/dist/monorepo/tool-validation.d.ts.map +1 -0
  54. package/dist/monorepo/tool-validation.js +399 -0
  55. package/dist/nx/index.d.ts +24 -0
  56. package/dist/nx/index.d.ts.map +1 -0
  57. package/dist/nx/index.js +94 -0
  58. package/dist/nx-version-actions.cjs +25 -0
  59. package/dist/nx-version-actions.d.cts +6 -0
  60. package/dist/nx-version-actions.d.cts.map +1 -0
  61. package/dist/release/bootstrap-npm-packages.d.ts +19 -0
  62. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -0
  63. package/dist/release/bootstrap-npm-packages.js +58 -0
  64. package/dist/release/candidates.d.ts +11 -0
  65. package/dist/release/candidates.d.ts.map +1 -0
  66. package/dist/release/candidates.js +181 -0
  67. package/dist/release/core.d.ts +51 -0
  68. package/dist/release/core.d.ts.map +1 -0
  69. package/dist/release/core.js +97 -0
  70. package/dist/release/github-release.d.ts +46 -0
  71. package/dist/release/github-release.d.ts.map +1 -0
  72. package/dist/release/github-release.js +97 -0
  73. package/dist/release/index.d.ts +39 -5
  74. package/dist/release/index.d.ts.map +1 -1
  75. package/dist/release/index.js +763 -130
  76. package/dist/release/npm-auth.d.ts +16 -0
  77. package/dist/release/npm-auth.d.ts.map +1 -0
  78. package/dist/release/npm-auth.js +39 -0
  79. package/dist/release/orchestration.d.ts +49 -0
  80. package/dist/release/orchestration.d.ts.map +1 -0
  81. package/dist/release/orchestration.js +113 -0
  82. package/dist/release/publish-plan.d.ts +17 -0
  83. package/dist/release/publish-plan.d.ts.map +1 -0
  84. package/dist/release/publish-plan.js +15 -0
  85. package/dist/release/retag-unpublished.d.ts +34 -0
  86. package/dist/release/retag-unpublished.d.ts.map +1 -0
  87. package/dist/release/retag-unpublished.js +77 -0
  88. package/managed/raw/git-format-staged.yml +54 -0
  89. package/managed/raw/tooling/git-hooks/commit-msg.sh +1 -1
  90. package/managed/raw/tooling/git-hooks/pre-commit.sh +2 -7
  91. package/managed/templates/github/actions/setup-devenv/action.yml +1 -7
  92. package/managed/templates/github/workflows/ci.yml +3 -0
  93. package/package.json +37 -4
  94. package/src/cli.ts +193 -40
  95. package/src/devenv/index.test.ts +49 -0
  96. package/src/devenv/index.ts +38 -0
  97. package/src/lib/cli-package.ts +18 -0
  98. package/src/lib/devenv.test.ts +28 -0
  99. package/src/lib/devenv.ts +89 -0
  100. package/src/lib/run.ts +19 -0
  101. package/src/lib/workspace.ts +117 -7
  102. package/src/monorepo/__tests__/nx-version-actions.test.ts +75 -0
  103. package/src/monorepo/__tests__/publish-workflow.test.ts +322 -0
  104. package/src/monorepo/commit-msg.test.ts +45 -0
  105. package/src/monorepo/commit-msg.ts +107 -7
  106. package/src/monorepo/index.ts +181 -15
  107. package/src/monorepo/lockfile.ts +52 -7
  108. package/src/monorepo/managed-files.ts +43 -13
  109. package/src/monorepo/nx-sync.ts +9 -2
  110. package/src/monorepo/package-hygiene.test.ts +34 -0
  111. package/src/monorepo/package-hygiene.ts +26 -4
  112. package/src/monorepo/package-policy.test.ts +902 -0
  113. package/src/monorepo/package-policy.ts +1527 -25
  114. package/src/monorepo/packed-manifest.ts +67 -0
  115. package/src/monorepo/packed-package.ts +90 -31
  116. package/src/monorepo/packs/index.test.ts +189 -0
  117. package/src/monorepo/packs/index.ts +314 -19
  118. package/src/monorepo/publish-workflow.ts +422 -0
  119. package/src/monorepo/setup-test-tracing.test.ts +120 -0
  120. package/src/monorepo/tool-validation.test.ts +261 -0
  121. package/src/monorepo/tool-validation.ts +462 -0
  122. package/src/nx/index.test.ts +64 -0
  123. package/src/nx/index.ts +119 -0
  124. package/src/nx-version-actions.cts +36 -0
  125. package/src/release/__tests__/bootstrap-npm-packages.test.ts +127 -0
  126. package/src/release/__tests__/candidates.test.ts +217 -0
  127. package/src/release/__tests__/core-properties.test.ts +149 -0
  128. package/src/release/__tests__/core-scenarios.test.ts +187 -0
  129. package/src/release/__tests__/core.test.ts +73 -0
  130. package/src/release/__tests__/fixture-repo.test.ts +314 -0
  131. package/src/release/__tests__/github-release.test.ts +149 -0
  132. package/src/release/__tests__/helpers/fixture-repo.ts +114 -0
  133. package/src/release/__tests__/npm-auth.test.ts +120 -0
  134. package/src/release/__tests__/orchestration.test.ts +246 -0
  135. package/src/release/__tests__/publish-plan.test.ts +67 -0
  136. package/src/release/__tests__/retag-unpublished.test.ts +160 -0
  137. package/src/release/__tests__/trust-publisher.test.ts +133 -0
  138. package/src/release/bootstrap-npm-packages.ts +88 -0
  139. package/src/release/candidates.ts +225 -0
  140. package/src/release/core.ts +171 -0
  141. package/src/release/github-release.ts +134 -0
  142. package/src/release/index.ts +974 -138
  143. package/src/release/npm-auth.ts +93 -0
  144. package/src/release/orchestration.ts +196 -0
  145. package/src/release/publish-plan.ts +37 -0
  146. package/src/release/retag-unpublished.ts +122 -0
  147. package/managed/raw/.git-format-staged.yml +0 -47
  148. package/managed/templates/github/workflows/publish.yml +0 -136
@@ -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 = ['dependencies', 'devDependencies', 'optionalDependencies'] as const;
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
- if (!readPackageJson(join(root, 'package.json'))) {
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
- const workspacePatterns = getWorkspacePatterns(root);
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 [{ ...rootPackage, path: '.' }, ...getWorkspacePackages(root)];
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
- if (!isRecord(raw) || !hasOwn(raw, 'workspaces')) {
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 = raw.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 = readJsonObject(path);
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
+ });