@smoothbricks/cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +208 -83
  2. package/dist/cli.js +50 -20
  3. package/dist/lib/devenv.d.ts +6 -0
  4. package/dist/lib/devenv.d.ts.map +1 -0
  5. package/dist/lib/devenv.js +82 -0
  6. package/dist/lib/workspace.d.ts +5 -0
  7. package/dist/lib/workspace.d.ts.map +1 -1
  8. package/dist/lib/workspace.js +62 -5
  9. package/dist/monorepo/index.d.ts +6 -1
  10. package/dist/monorepo/index.d.ts.map +1 -1
  11. package/dist/monorepo/index.js +38 -5
  12. package/dist/monorepo/lockfile.d.ts +5 -1
  13. package/dist/monorepo/lockfile.d.ts.map +1 -1
  14. package/dist/monorepo/lockfile.js +45 -5
  15. package/dist/monorepo/managed-files.d.ts +1 -1
  16. package/dist/monorepo/managed-files.d.ts.map +1 -1
  17. package/dist/monorepo/managed-files.js +27 -10
  18. package/dist/monorepo/package-hygiene.js +1 -1
  19. package/dist/monorepo/package-policy.d.ts +3 -0
  20. package/dist/monorepo/package-policy.d.ts.map +1 -1
  21. package/dist/monorepo/package-policy.js +122 -19
  22. package/dist/monorepo/packed-manifest.d.ts +4 -0
  23. package/dist/monorepo/packed-manifest.d.ts.map +1 -0
  24. package/dist/monorepo/packed-manifest.js +51 -0
  25. package/dist/monorepo/packed-package.d.ts.map +1 -1
  26. package/dist/monorepo/packed-package.js +6 -0
  27. package/dist/monorepo/packs/index.d.ts.map +1 -1
  28. package/dist/monorepo/packs/index.js +4 -1
  29. package/dist/monorepo/publish-workflow.d.ts +86 -0
  30. package/dist/monorepo/publish-workflow.d.ts.map +1 -0
  31. package/dist/monorepo/publish-workflow.js +343 -0
  32. package/dist/nx-version-actions.cjs +25 -0
  33. package/dist/nx-version-actions.d.cts +6 -0
  34. package/dist/nx-version-actions.d.cts.map +1 -0
  35. package/dist/release/core.d.ts +50 -0
  36. package/dist/release/core.d.ts.map +1 -0
  37. package/dist/release/core.js +97 -0
  38. package/dist/release/github-release.d.ts +46 -0
  39. package/dist/release/github-release.d.ts.map +1 -0
  40. package/dist/release/github-release.js +97 -0
  41. package/dist/release/index.d.ts +14 -5
  42. package/dist/release/index.d.ts.map +1 -1
  43. package/dist/release/index.js +544 -109
  44. package/dist/release/npm-auth.d.ts +17 -0
  45. package/dist/release/npm-auth.d.ts.map +1 -0
  46. package/dist/release/npm-auth.js +51 -0
  47. package/dist/release/orchestration.d.ts +48 -0
  48. package/dist/release/orchestration.d.ts.map +1 -0
  49. package/dist/release/orchestration.js +105 -0
  50. package/dist/release/publish-plan.d.ts +17 -0
  51. package/dist/release/publish-plan.d.ts.map +1 -0
  52. package/dist/release/publish-plan.js +15 -0
  53. package/dist/release/retag-unpublished.d.ts +34 -0
  54. package/dist/release/retag-unpublished.d.ts.map +1 -0
  55. package/dist/release/retag-unpublished.js +77 -0
  56. package/managed/raw/tooling/git-hooks/pre-commit.sh +2 -7
  57. package/managed/templates/github/workflows/ci.yml +2 -0
  58. package/package.json +24 -3
  59. package/src/cli.ts +64 -25
  60. package/src/lib/devenv.test.ts +28 -0
  61. package/src/lib/devenv.ts +89 -0
  62. package/src/lib/workspace.ts +76 -5
  63. package/src/monorepo/__tests__/nx-version-actions.test.ts +75 -0
  64. package/src/monorepo/__tests__/publish-workflow.test.ts +315 -0
  65. package/src/monorepo/index.ts +45 -5
  66. package/src/monorepo/lockfile.ts +52 -7
  67. package/src/monorepo/managed-files.ts +42 -12
  68. package/src/monorepo/package-hygiene.ts +1 -1
  69. package/src/monorepo/package-policy.ts +134 -18
  70. package/src/monorepo/packed-manifest.ts +67 -0
  71. package/src/monorepo/packed-package.ts +7 -0
  72. package/src/monorepo/packs/index.ts +4 -0
  73. package/src/monorepo/publish-workflow.ts +430 -0
  74. package/src/nx-version-actions.cts +36 -0
  75. package/src/release/__tests__/core-properties.test.ts +149 -0
  76. package/src/release/__tests__/core-scenarios.test.ts +177 -0
  77. package/src/release/__tests__/core.test.ts +73 -0
  78. package/src/release/__tests__/fixture-repo.test.ts +305 -0
  79. package/src/release/__tests__/github-release.test.ts +144 -0
  80. package/src/release/__tests__/helpers/fixture-repo.ts +113 -0
  81. package/src/release/__tests__/npm-auth.test.ts +129 -0
  82. package/src/release/__tests__/orchestration.test.ts +202 -0
  83. package/src/release/__tests__/publish-plan.test.ts +61 -0
  84. package/src/release/__tests__/retag-unpublished.test.ts +160 -0
  85. package/src/release/core.ts +170 -0
  86. package/src/release/github-release.ts +134 -0
  87. package/src/release/index.ts +662 -116
  88. package/src/release/npm-auth.ts +122 -0
  89. package/src/release/orchestration.ts +186 -0
  90. package/src/release/publish-plan.ts +37 -0
  91. package/src/release/retag-unpublished.ts +122 -0
  92. package/managed/templates/github/workflows/publish.yml +0 -136
@@ -0,0 +1,89 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { $ } from 'bun';
5
+ import { decode } from './run.js';
6
+
7
+ type EnvSnapshot = Record<string, string>;
8
+
9
+ export async function withDevenvEnv<T>(root: string, runWithEnv: () => Promise<T>): Promise<T> {
10
+ const snapshot = snapshotProcessEnv();
11
+ const env = await loadDevenvEnv(root);
12
+ replaceProcessEnv(mergeDevenvEnv(snapshot, env));
13
+ try {
14
+ return await runWithEnv();
15
+ } finally {
16
+ restoreProcessEnv(snapshot);
17
+ }
18
+ }
19
+
20
+ async function loadDevenvEnv(root: string): Promise<EnvSnapshot> {
21
+ const tempDir = await mkdtemp(join(tmpdir(), 'smoo-devenv-env-'));
22
+ const envPath = join(tempDir, 'env');
23
+ try {
24
+ const result = await $`devenv shell -- bash -lc ${'env -0 > "$1"'} bash ${envPath}`
25
+ .cwd(join(root, 'tooling', 'direnv'))
26
+ .quiet()
27
+ .nothrow();
28
+ if (result.exitCode !== 0) {
29
+ throw new Error('devenv shell failed. Ensure devenv is installed and the tooling/direnv shell is valid.');
30
+ }
31
+ return parseNulEnv(await readFile(envPath));
32
+ } finally {
33
+ await rm(tempDir, { recursive: true, force: true });
34
+ }
35
+ }
36
+
37
+ export function parseNulEnv(bytes: Uint8Array): EnvSnapshot {
38
+ const env: EnvSnapshot = {};
39
+ let entryStart = 0;
40
+ for (let index = 0; index <= bytes.length; index += 1) {
41
+ if (index !== bytes.length && bytes[index] !== 0) {
42
+ continue;
43
+ }
44
+ if (index === entryStart) {
45
+ entryStart = index + 1;
46
+ continue;
47
+ }
48
+ const entry = decode(bytes.subarray(entryStart, index));
49
+ const separator = entry.indexOf('=');
50
+ if (separator <= 0) {
51
+ throw new Error('devenv shell produced an invalid environment entry.');
52
+ }
53
+ env[entry.slice(0, separator)] = entry.slice(separator + 1);
54
+ entryStart = index + 1;
55
+ }
56
+ return env;
57
+ }
58
+
59
+ export function mergeDevenvEnv(baseEnv: EnvSnapshot, devenvEnv: EnvSnapshot): EnvSnapshot {
60
+ return { ...baseEnv, ...devenvEnv };
61
+ }
62
+
63
+ function snapshotProcessEnv(): EnvSnapshot {
64
+ const snapshot: EnvSnapshot = {};
65
+ for (const [key, value] of Object.entries(process.env)) {
66
+ if (value !== undefined) {
67
+ snapshot[key] = value;
68
+ }
69
+ }
70
+ return snapshot;
71
+ }
72
+
73
+ function replaceProcessEnv(env: EnvSnapshot): void {
74
+ for (const key of Object.keys(process.env)) {
75
+ delete process.env[key];
76
+ }
77
+ for (const [key, value] of Object.entries(env)) {
78
+ process.env[key] = value;
79
+ }
80
+ }
81
+
82
+ function restoreProcessEnv(snapshot: EnvSnapshot): void {
83
+ for (const key of Object.keys(process.env)) {
84
+ delete process.env[key];
85
+ }
86
+ for (const [key, value] of Object.entries(snapshot)) {
87
+ process.env[key] = value;
88
+ }
89
+ }
@@ -23,11 +23,45 @@ export function listPublicPackages(root: string): PackageInfo[] {
23
23
  return getWorkspacePackages(root).filter((pkg) => !pkg.private && pkg.tags.includes('npm:public'));
24
24
  }
25
25
 
26
+ export function listReleasePackages(
27
+ root: string,
28
+ rootPackage = readPackageJson(join(root, 'package.json')),
29
+ ): PackageInfo[] {
30
+ if (!rootPackage) {
31
+ return [];
32
+ }
33
+ const rootRepository = repositoryInfo(rootPackage.json);
34
+ if (!rootRepository) {
35
+ return [];
36
+ }
37
+ return getWorkspacePackagesForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage.json)).filter(
38
+ (pkg) => !pkg.private && pkg.tags.includes('npm:public') && isOwnedPackage(rootRepository, pkg),
39
+ );
40
+ }
41
+
42
+ export function rootRepositoryInfo(root: string): RepositoryInfo | null {
43
+ const rootPackage = readJsonObject(join(root, 'package.json'));
44
+ return rootPackage ? repositoryInfo(rootPackage) : null;
45
+ }
46
+
47
+ export function packageRepositoryInfo(pkg: PackageInfo): RepositoryInfo | null {
48
+ return repositoryInfo(pkg.json);
49
+ }
50
+
51
+ export function isOwnedPackage(rootRepository: RepositoryInfo, pkg: PackageInfo): boolean {
52
+ const packageRepository = packageRepositoryInfo(pkg);
53
+ return packageRepository !== null && packageRepository.url === rootRepository.url;
54
+ }
55
+
26
56
  export function getWorkspacePackages(root: string): PackageInfo[] {
27
- if (!readPackageJson(join(root, 'package.json'))) {
57
+ const rootPackage = readPackageJson(join(root, 'package.json'));
58
+ if (!rootPackage) {
28
59
  throw new Error('package.json not found or invalid');
29
60
  }
30
- const workspacePatterns = getWorkspacePatterns(root);
61
+ return getWorkspacePackagesForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage.json));
62
+ }
63
+
64
+ function getWorkspacePackagesForPatterns(root: string, workspacePatterns: string[]): PackageInfo[] {
31
65
  const packages: PackageInfo[] = [];
32
66
  for (const pattern of workspacePatterns) {
33
67
  if (!pattern.endsWith('/*')) {
@@ -62,15 +96,22 @@ export function listPackageJsonRecords(root: string): PackageInfo[] {
62
96
  if (!rootPackage) {
63
97
  throw new Error('package.json not found or invalid');
64
98
  }
65
- return [{ ...rootPackage, path: '.' }, ...getWorkspacePackages(root)];
99
+ return [
100
+ { ...rootPackage, path: '.' },
101
+ ...getWorkspacePackagesForPatterns(root, getWorkspacePatternsFromPackageJson(rootPackage.json)),
102
+ ];
66
103
  }
67
104
 
68
105
  export function getWorkspacePatterns(root: string): string[] {
69
106
  const raw = readJson(join(root, 'package.json'));
70
- if (!isRecord(raw) || !hasOwn(raw, 'workspaces')) {
107
+ return isRecord(raw) ? getWorkspacePatternsFromPackageJson(raw) : ['packages/*'];
108
+ }
109
+
110
+ function getWorkspacePatternsFromPackageJson(pkg: Record<string, unknown>): string[] {
111
+ if (!hasOwn(pkg, 'workspaces')) {
71
112
  return ['packages/*'];
72
113
  }
73
- const workspaces = raw.workspaces;
114
+ const workspaces = pkg.workspaces;
74
115
  if (Array.isArray(workspaces)) {
75
116
  return workspaces.filter((entry): entry is string => typeof entry === 'string');
76
117
  }
@@ -120,6 +161,36 @@ export function repositoryInfo(pkg: Record<string, unknown>): RepositoryInfo | n
120
161
  return { type: stringProperty(repository, 'type') ?? 'git', url };
121
162
  }
122
163
 
164
+ export function sameRepositoryAfterNormalization(left: string, right: string): boolean {
165
+ return normalizedRepositoryUrl(left) === normalizedRepositoryUrl(right);
166
+ }
167
+
168
+ function normalizedRepositoryUrl(url: string): string {
169
+ const trimmed = url
170
+ .trim()
171
+ .replace(/^git\+/, '')
172
+ .replace(/#.*$/, '')
173
+ .replace(/\/$/, '')
174
+ .replace(/\.git$/, '');
175
+ const https = /^https:\/\/github\.com\/([^/]+)\/([^/]+)$/i.exec(trimmed);
176
+ if (https?.[1] && https[2]) {
177
+ return githubRepositoryKey(https[1], https[2]);
178
+ }
179
+ const ssh = /^git@github\.com:([^/]+)\/([^/]+)$/i.exec(trimmed);
180
+ if (ssh?.[1] && ssh[2]) {
181
+ return githubRepositoryKey(ssh[1], ssh[2]);
182
+ }
183
+ const shorthand = /^github:([^/]+)\/([^/]+)$/i.exec(trimmed);
184
+ if (shorthand?.[1] && shorthand[2]) {
185
+ return githubRepositoryKey(shorthand[1], shorthand[2]);
186
+ }
187
+ return trimmed;
188
+ }
189
+
190
+ function githubRepositoryKey(owner: string, repo: string): string {
191
+ return `github:${owner.toLowerCase()}/${repo.toLowerCase()}`;
192
+ }
193
+
123
194
  export function escapeRegex(value: string): string {
124
195
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
125
196
  }
@@ -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,315 @@
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('bootstraps the self-hosted CLI before release commands only for the SmoothBricks repo', async () => {
24
+ const smoothbricks = await publishWorkflowScenario({
25
+ repoName: '@smoothbricks/codebase',
26
+ repairs: [],
27
+ current: [],
28
+ bump: 'patch',
29
+ dryRun: false,
30
+ version: { mode: 'none', projects: [] },
31
+ }).run();
32
+ const downstream = await publishWorkflowScenario({
33
+ repoName: '@other/repo',
34
+ repairs: [],
35
+ current: [],
36
+ bump: 'patch',
37
+ dryRun: false,
38
+ version: { mode: 'none', projects: [] },
39
+ }).run();
40
+
41
+ expect(smoothbricks.selfHostedCliBuilt).toBe(true);
42
+ expect(smoothbricks.repairSawSelfHostedCli).toBe(true);
43
+ expect(smoothbricks.versionSawSelfHostedCli).toBe(true);
44
+ expect(downstream.selfHostedCliBuilt).toBe(false);
45
+ expect(downstream.repairSawSelfHostedCli).toBe(false);
46
+ expect(downstream.versionSawSelfHostedCli).toBe(false);
47
+ });
48
+
49
+ it('repairs older gaps, skips validation for mode none, and still completes current HEAD publish', async () => {
50
+ const scenario = publishWorkflowScenario({
51
+ repairs: [
52
+ { tag: '@scope/a@1.1.0', npmMissing: false, githubMissing: true },
53
+ { tag: '@scope/b@2.0.0-beta.1', npmMissing: true, githubMissing: true },
54
+ ],
55
+ current: [{ tag: '@scope/a@1.2.0', npmMissing: true, githubMissing: false }],
56
+ bump: 'auto',
57
+ dryRun: false,
58
+ version: { mode: 'none', projects: [] },
59
+ });
60
+
61
+ const outcome = await scenario.run();
62
+
63
+ expect(outcome.fixtureRepoSetup).toBe(true);
64
+ expect(outcome.releaseAuthorConfigured).toBe(true);
65
+ expect(outcome.repairedTags).toEqual(['@scope/a@1.1.0', '@scope/b@2.0.0-beta.1']);
66
+ expect(outcome.repairBuildArtifacts).toEqual(['@scope/b']);
67
+ expect(outcome.validation).toEqual({ checks: 0, builds: [], lints: [], tests: [], validates: 0 });
68
+ expect(outcome.publishRan).toBe(true);
69
+ expect(outcome.publishCompletedTags).toEqual(['@scope/a@1.2.0']);
70
+ expect(outcome.remainingDurableGaps).toEqual([]);
71
+ });
72
+
73
+ it('validates a new release before publishing current HEAD gaps', async () => {
74
+ const scenario = publishWorkflowScenario({
75
+ repairs: [],
76
+ current: [
77
+ { tag: '@scope/a@1.2.0', npmMissing: true, githubMissing: true },
78
+ { tag: '@scope/b@2.0.0', npmMissing: false, githubMissing: true },
79
+ ],
80
+ bump: 'patch',
81
+ dryRun: false,
82
+ version: { mode: 'new', projects: ['@scope/a', '@scope/b'] },
83
+ });
84
+
85
+ const outcome = await scenario.run();
86
+
87
+ expect(outcome.validation).toEqual({
88
+ checks: 1,
89
+ builds: ['@scope/a', '@scope/b'],
90
+ lints: ['@scope/a', '@scope/b'],
91
+ tests: ['@scope/a', '@scope/b'],
92
+ validates: 1,
93
+ });
94
+ expect(outcome.publishSawValidatedRelease).toBe(true);
95
+ expect(outcome.publishCompletedTags).toEqual(['@scope/a@1.2.0', '@scope/b@2.0.0']);
96
+ expect(outcome.remainingDurableGaps).toEqual([]);
97
+ });
98
+
99
+ it('dry-run walks the workflow outcomes without mutating durable release state', async () => {
100
+ const scenario = publishWorkflowScenario({
101
+ repairs: [{ tag: '@scope/a@1.1.0', npmMissing: true, githubMissing: true }],
102
+ current: [{ tag: '@scope/a@1.2.0', npmMissing: true, githubMissing: true }],
103
+ bump: 'prerelease',
104
+ dryRun: true,
105
+ version: { mode: 'new', projects: ['@scope/a'] },
106
+ });
107
+
108
+ const outcome = await scenario.run();
109
+
110
+ expect(outcome.fixtureRepoSetup).toBe(true);
111
+ expect(outcome.repairedTags).toEqual([]);
112
+ expect(outcome.repairBuildArtifacts).toEqual([]);
113
+ expect(outcome.validation).toEqual({
114
+ checks: 1,
115
+ builds: ['@scope/a'],
116
+ lints: ['@scope/a'],
117
+ tests: ['@scope/a'],
118
+ validates: 1,
119
+ });
120
+ expect(outcome.publishRan).toBe(true);
121
+ expect(outcome.publishCompletedTags).toEqual([]);
122
+ expect(outcome.remainingDurableGaps).toEqual([
123
+ '@scope/a@1.1.0:github',
124
+ '@scope/a@1.1.0:npm',
125
+ '@scope/a@1.2.0:github',
126
+ '@scope/a@1.2.0:npm',
127
+ ]);
128
+ });
129
+ });
130
+
131
+ interface ReleaseGap {
132
+ tag: string;
133
+ npmMissing: boolean;
134
+ githubMissing: boolean;
135
+ }
136
+
137
+ interface WorkflowScenarioConfig {
138
+ repoName?: string;
139
+ repairs: ReleaseGap[];
140
+ current: ReleaseGap[];
141
+ bump: PublishWorkflowBump;
142
+ dryRun: boolean;
143
+ version: PublishWorkflowVersionOutputs;
144
+ }
145
+
146
+ interface WorkflowOutcome {
147
+ fixtureRepoSetup: boolean;
148
+ releaseAuthorConfigured: boolean;
149
+ selfHostedCliBuilt: boolean;
150
+ repairSawSelfHostedCli: boolean;
151
+ versionSawSelfHostedCli: boolean;
152
+ repairedTags: string[];
153
+ repairBuildArtifacts: string[];
154
+ validation: { checks: number; builds: string[]; lints: string[]; tests: string[]; validates: number };
155
+ publishRan: boolean;
156
+ publishSawValidatedRelease: boolean;
157
+ publishCompletedTags: string[];
158
+ remainingDurableGaps: string[];
159
+ }
160
+
161
+ function publishWorkflowScenario(config: WorkflowScenarioConfig): { run(): Promise<WorkflowOutcome> } {
162
+ return {
163
+ async run() {
164
+ const state = new WorkflowScenarioState(config);
165
+ await runPublishWorkflow(definePublishWorkflow({ repoName: config.repoName }), {
166
+ inputs: { bump: config.bump, dryRun: config.dryRun },
167
+ nodeAuthToken: true,
168
+ callbacks: state.callbacks(),
169
+ });
170
+ return state.outcome();
171
+ },
172
+ };
173
+ }
174
+
175
+ class WorkflowScenarioState {
176
+ private fixtureSetup = false;
177
+ private authorConfigured = false;
178
+ private selfHostedCli = false;
179
+ private repairObservedSelfHostedCli = false;
180
+ private versionObservedSelfHostedCli = false;
181
+ private publishReleaseRan = false;
182
+ private publishSawValidation = false;
183
+ private readonly repaired = new Set<string>();
184
+ private readonly repairBuilds = new Set<string>();
185
+ private readonly publishedCurrent = new Set<string>();
186
+ private readonly durableGaps = new Set<string>();
187
+ private readonly validationState = {
188
+ checks: 0,
189
+ builds: [] as string[],
190
+ lints: [] as string[],
191
+ tests: [] as string[],
192
+ validates: 0,
193
+ };
194
+
195
+ constructor(private readonly config: WorkflowScenarioConfig) {
196
+ for (const gap of [...config.repairs, ...config.current]) {
197
+ if (gap.githubMissing) {
198
+ this.durableGaps.add(`${gap.tag}:github`);
199
+ }
200
+ if (gap.npmMissing) {
201
+ this.durableGaps.add(`${gap.tag}:npm`);
202
+ }
203
+ }
204
+ }
205
+
206
+ callbacks(): PublishWorkflowCallbacks {
207
+ return {
208
+ checkout: async () => {},
209
+ setupDevenv: async () => {
210
+ this.fixtureSetup = true;
211
+ return { nixCacheHit: 'false', devenvCacheHit: 'false' };
212
+ },
213
+ configureReleaseAuthor: async () => {
214
+ this.authorConfigured = true;
215
+ },
216
+ buildSelfHostedCli: async () => {
217
+ this.selfHostedCli = true;
218
+ },
219
+ repairPendingReleases: async ({ dryRun }) => {
220
+ this.repairObservedSelfHostedCli = this.selfHostedCli;
221
+ if (dryRun) {
222
+ return;
223
+ }
224
+ for (const gap of this.config.repairs) {
225
+ if (gap.npmMissing) {
226
+ this.repairBuilds.add(packageNameFromTag(gap.tag));
227
+ this.durableGaps.delete(`${gap.tag}:npm`);
228
+ }
229
+ if (gap.githubMissing) {
230
+ this.durableGaps.delete(`${gap.tag}:github`);
231
+ }
232
+ this.repaired.add(gap.tag);
233
+ }
234
+ },
235
+ versionRelease: async ({ bump, dryRun }) => {
236
+ this.versionObservedSelfHostedCli = this.selfHostedCli;
237
+ expect(bump).toBe(this.config.bump);
238
+ expect(dryRun).toBe(this.config.dryRun);
239
+ return this.config.version;
240
+ },
241
+ checkManagedMonorepoFiles: async () => {
242
+ this.validationState.checks += 1;
243
+ },
244
+ nxRunMany: async ({ target, projects }) => {
245
+ this.validationProjects(target).push(...projects);
246
+ },
247
+ uploadTraceDbs: async () => {},
248
+ validateMonorepoConfig: async () => {
249
+ this.validationState.validates += 1;
250
+ },
251
+ publishRelease: async ({ bump, dryRun }) => {
252
+ expect(bump).toBe(this.config.bump);
253
+ expect(dryRun).toBe(this.config.dryRun);
254
+ this.publishReleaseRan = true;
255
+ this.publishSawValidation = this.config.version.mode === 'none' || this.validationState.validates > 0;
256
+ if (dryRun) {
257
+ return;
258
+ }
259
+ for (const gap of this.config.current) {
260
+ if (gap.npmMissing) {
261
+ this.durableGaps.delete(`${gap.tag}:npm`);
262
+ }
263
+ if (gap.githubMissing) {
264
+ this.durableGaps.delete(`${gap.tag}:github`);
265
+ }
266
+ if (gap.npmMissing || gap.githubMissing) {
267
+ this.publishedCurrent.add(gap.tag);
268
+ }
269
+ }
270
+ },
271
+ saveNixDevenv: async () => {},
272
+ };
273
+ }
274
+
275
+ outcome(): WorkflowOutcome {
276
+ return {
277
+ fixtureRepoSetup: this.fixtureSetup,
278
+ releaseAuthorConfigured: this.authorConfigured,
279
+ selfHostedCliBuilt: this.selfHostedCli,
280
+ repairSawSelfHostedCli: this.repairObservedSelfHostedCli,
281
+ versionSawSelfHostedCli: this.versionObservedSelfHostedCli,
282
+ repairedTags: [...this.repaired].sort(),
283
+ repairBuildArtifacts: [...this.repairBuilds].sort(),
284
+ validation: {
285
+ checks: this.validationState.checks,
286
+ builds: [...this.validationState.builds],
287
+ lints: [...this.validationState.lints],
288
+ tests: [...this.validationState.tests],
289
+ validates: this.validationState.validates,
290
+ },
291
+ publishRan: this.publishReleaseRan,
292
+ publishSawValidatedRelease: this.publishSawValidation,
293
+ publishCompletedTags: [...this.publishedCurrent].sort(),
294
+ remainingDurableGaps: [...this.durableGaps].sort(),
295
+ };
296
+ }
297
+
298
+ private validationProjects(target: PublishWorkflowNxTarget): string[] {
299
+ if (target === 'build') {
300
+ return this.validationState.builds;
301
+ }
302
+ if (target === 'lint') {
303
+ return this.validationState.lints;
304
+ }
305
+ return this.validationState.tests;
306
+ }
307
+ }
308
+
309
+ function packageNameFromTag(tag: string): string {
310
+ const versionSeparator = tag.lastIndexOf('@');
311
+ if (versionSeparator <= 0) {
312
+ throw new Error(`Invalid release tag fixture ${tag}.`);
313
+ }
314
+ return tag.slice(0, versionSeparator);
315
+ }
@@ -1,5 +1,7 @@
1
- import { readFileSync } from 'node:fs';
2
- import { listPublicPackages } from '../lib/workspace.js';
1
+ import { appendFileSync, readFileSync } from 'node:fs';
2
+ import { $ } from 'bun';
3
+ import { decode } from '../lib/run.js';
4
+ import { escapeRegex, getWorkspacePatterns, listReleasePackages } from '../lib/workspace.js';
3
5
  import { validateCommitMessage } from './commit-msg.js';
4
6
  import { applyWorkspaceGitConfig } from './git-config.js';
5
7
  import { syncBunLockfileVersions } from './lockfile.js';
@@ -15,6 +17,12 @@ export interface InitOptions {
15
17
 
16
18
  export interface ValidateOptions {
17
19
  failFast?: boolean;
20
+ onlyIfNewWorkspacePackage?: boolean;
21
+ }
22
+
23
+ export interface ListReleasePackagesOptions {
24
+ failEmpty?: boolean;
25
+ githubOutput?: string;
18
26
  }
19
27
 
20
28
  export async function initMonorepo(root: string, options: InitOptions): Promise<void> {
@@ -28,9 +36,13 @@ export async function initMonorepo(root: string, options: InitOptions): Promise<
28
36
  }
29
37
 
30
38
  export async function validateMonorepo(root: string, options: ValidateOptions = {}): Promise<void> {
39
+ if (options.onlyIfNewWorkspacePackage && !(await hasNewWorkspacePackage(root))) {
40
+ return;
41
+ }
31
42
  const failures = await runValidatePacks({ root, syncRuntime: false }, options);
32
43
  if (failures > 0) {
33
- throw new Error(`Monorepo validation failed with ${failures} problem(s).`);
44
+ const noun = failures === 1 ? 'problem' : 'problems';
45
+ throw new Error(`\n== summary ==\n❌ Monorepo validation failed with ${failures} ${noun}.`);
34
46
  }
35
47
  console.log('\n== summary ==');
36
48
  console.log('Monorepo configuration is valid.');
@@ -63,10 +75,17 @@ export function validateCommitMessageFile(path: string | undefined): void {
63
75
  }
64
76
  }
65
77
 
66
- export function listPublicProjects(root: string): string {
67
- return listPublicPackages(root)
78
+ export function listReleasePackagesForNx(root: string, options: ListReleasePackagesOptions = {}): string {
79
+ const packages = listReleasePackages(root)
68
80
  .map((pkg) => pkg.name)
69
81
  .join(',');
82
+ if (!packages && options.failEmpty) {
83
+ throw new Error('No owned release packages found.');
84
+ }
85
+ if (options.githubOutput) {
86
+ appendFileSync(options.githubOutput, `projects=${packages}\n`);
87
+ }
88
+ return packages;
70
89
  }
71
90
 
72
91
  export function validatePublicPackageTags(root: string): void {
@@ -76,3 +95,24 @@ export function validatePublicPackageTags(root: string): void {
76
95
  }
77
96
 
78
97
  export { applyWorkspaceGitConfig, syncBunLockfileVersions };
98
+
99
+ async function hasNewWorkspacePackage(root: string): Promise<boolean> {
100
+ const result = await $`git diff --cached --name-only --diff-filter=A -- ${'*/package.json'}`
101
+ .cwd(root)
102
+ .quiet()
103
+ .nothrow();
104
+ if (result.exitCode !== 0) {
105
+ throw new Error('Unable to inspect staged package manifests.');
106
+ }
107
+ const manifests = decode(result.stdout)
108
+ .split('\n')
109
+ .map((path) => path.trim())
110
+ .filter(Boolean);
111
+ if (manifests.length === 0) {
112
+ return false;
113
+ }
114
+ const patterns = getWorkspacePatterns(root)
115
+ .filter((pattern) => pattern.endsWith('/*'))
116
+ .map((pattern) => new RegExp(`^${escapeRegex(pattern.slice(0, -2))}/[^/]+/package\\.json$`));
117
+ return manifests.some((manifest) => patterns.some((pattern) => pattern.test(manifest)));
118
+ }