@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
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ type BootstrapNpmPackagesShell,
4
+ bootstrapNpmPackages,
5
+ NPM_BOOTSTRAP_DIST_TAG,
6
+ NPM_BOOTSTRAP_VERSION,
7
+ } from '../bootstrap-npm-packages.js';
8
+ import type { ReleasePackageInfo } from '../core.js';
9
+
10
+ const stable: ReleasePackageInfo = {
11
+ name: '@scope/stable',
12
+ projectName: 'stable',
13
+ path: 'packages/stable',
14
+ version: '1.2.3',
15
+ };
16
+ const missing: ReleasePackageInfo = {
17
+ name: '@scope/missing',
18
+ projectName: 'missing',
19
+ path: 'packages/missing',
20
+ version: '2.0.0',
21
+ };
22
+
23
+ describe('bootstrap npm packages', () => {
24
+ it('publishes placeholders only for selected packages missing from npm', async () => {
25
+ const shell = new RecordingBootstrapShell({
26
+ packages: [stable, missing],
27
+ existing: [stable.name],
28
+ otps: ['123456'],
29
+ });
30
+
31
+ const bootstrapped = await bootstrapNpmPackages(shell, {
32
+ dryRun: false,
33
+ skipLogin: false,
34
+ packages: [missing.name],
35
+ });
36
+
37
+ expect(bootstrapped.map((pkg) => pkg.name)).toEqual([missing.name]);
38
+ expect(shell.logins).toBe(1);
39
+ expect(shell.published).toEqual([{ name: missing.name, otp: '123456' }]);
40
+ expect(shell.logs.join('\n')).toContain(NPM_BOOTSTRAP_VERSION);
41
+ expect(shell.logs.join('\n')).toContain(NPM_BOOTSTRAP_DIST_TAG);
42
+ });
43
+
44
+ it('dry-run reports missing packages without login or publish', async () => {
45
+ const shell = new RecordingBootstrapShell({ packages: [stable, missing], existing: [stable.name] });
46
+
47
+ const bootstrapped = await bootstrapNpmPackages(shell, { dryRun: true, skipLogin: false, packages: [] });
48
+
49
+ expect(bootstrapped.map((pkg) => pkg.name)).toEqual([missing.name]);
50
+ expect(shell.logins).toBe(0);
51
+ expect(shell.published).toEqual([]);
52
+ expect(shell.logs).toContain(`${stable.name}: already exists on npm; skipping placeholder bootstrap.`);
53
+ });
54
+
55
+ it('supports skipping npm login when an existing session is already authenticated', async () => {
56
+ const shell = new RecordingBootstrapShell({ packages: [missing], existing: [], otps: ['654321'] });
57
+
58
+ await bootstrapNpmPackages(shell, { dryRun: false, skipLogin: true, packages: [] });
59
+
60
+ expect(shell.logins).toBe(0);
61
+ expect(shell.published).toEqual([{ name: missing.name, otp: '654321' }]);
62
+ });
63
+
64
+ it('passes explicit OTP to placeholder publishes without prompting', async () => {
65
+ const shell = new RecordingBootstrapShell({ packages: [missing], existing: [] });
66
+
67
+ await bootstrapNpmPackages(shell, { dryRun: false, skipLogin: true, packages: [], otp: '111222' });
68
+
69
+ expect(shell.published).toEqual([{ name: missing.name, otp: '111222' }]);
70
+ expect(shell.prompts).toEqual([]);
71
+ });
72
+
73
+ it('rejects unknown package selections before npm login', async () => {
74
+ const shell = new RecordingBootstrapShell({ packages: [stable], existing: [] });
75
+
76
+ await expect(
77
+ bootstrapNpmPackages(shell, { dryRun: false, skipLogin: false, packages: ['@scope/unknown'] }),
78
+ ).rejects.toThrow('Unknown owned release package selection: @scope/unknown');
79
+ expect(shell.logins).toBe(0);
80
+ expect(shell.published).toEqual([]);
81
+ });
82
+ });
83
+
84
+ class RecordingBootstrapShell implements BootstrapNpmPackagesShell<ReleasePackageInfo> {
85
+ readonly logs: string[] = [];
86
+ readonly published: Array<{ name: string; otp: string }> = [];
87
+ readonly prompts: string[] = [];
88
+ logins = 0;
89
+ private readonly packages: ReleasePackageInfo[];
90
+ private readonly existing: Set<string>;
91
+ private readonly otps: string[];
92
+
93
+ constructor(options: { packages: ReleasePackageInfo[]; existing: string[]; otps?: string[] }) {
94
+ this.packages = options.packages;
95
+ this.existing = new Set(options.existing);
96
+ this.otps = [...(options.otps ?? [])];
97
+ }
98
+
99
+ listReleasePackages(): ReleasePackageInfo[] {
100
+ return this.packages;
101
+ }
102
+
103
+ async packageExists(name: string): Promise<boolean> {
104
+ return this.existing.has(name);
105
+ }
106
+
107
+ async login(): Promise<void> {
108
+ this.logins += 1;
109
+ }
110
+
111
+ async publishPlaceholder(pkg: ReleasePackageInfo, env?: Record<string, string>): Promise<void> {
112
+ this.published.push({ name: pkg.name, otp: env?.NPM_CONFIG_OTP ?? '' });
113
+ }
114
+
115
+ async promptOtp(packageName: string): Promise<string> {
116
+ this.prompts.push(packageName);
117
+ const otp = this.otps.shift();
118
+ if (!otp) {
119
+ throw new Error(`unexpected OTP prompt for ${packageName}`);
120
+ }
121
+ return otp;
122
+ }
123
+
124
+ log(message: string): void {
125
+ this.logs.push(message);
126
+ }
127
+ }
@@ -0,0 +1,217 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { $ } from 'bun';
5
+ import { isRecord } from '../../lib/json.js';
6
+ import { type AutoReleaseCandidateShell, autoReleaseCandidatePackages } from '../candidates.js';
7
+ import type { ReleasePackageInfo } from '../core.js';
8
+ import { git, tag, withFixtureRepo, writePackage } from './helpers/fixture-repo.js';
9
+
10
+ const a: ReleasePackageInfo = { name: '@scope/a', projectName: 'a', path: 'packages/a', version: '1.0.0' };
11
+ const b: ReleasePackageInfo = { name: '@scope/b', projectName: 'b', path: 'packages/b', version: '1.0.0' };
12
+ const c: ReleasePackageInfo = { name: '@scope/c', projectName: 'c', path: 'packages/c', version: '0.1.0' };
13
+ const d: ReleasePackageInfo = { name: '@scope/d', projectName: 'd', path: 'packages/d', version: '0.1.0' };
14
+
15
+ describe('auto release candidate filtering', () => {
16
+ it('selects tagged packages only when their package path changed', async () => {
17
+ await withFixtureRepo(async (root) => {
18
+ await writePackage(root, a.name, a.path, a.version);
19
+ await writePackage(root, b.name, b.path, b.version);
20
+ await git(root, ['add', '.']);
21
+ await git(root, ['commit', '-m', 'initial packages']);
22
+ await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
23
+ await tag(root, '@scope/b@1.0.0', '2025-01-01T00:00:01Z');
24
+
25
+ await writeFile(join(root, 'README.md'), 'root-only change\n');
26
+ await git(root, ['add', 'README.md']);
27
+ await git(root, ['commit', '-m', 'docs: root only']);
28
+
29
+ await expect(autoReleaseCandidatePackages(gitCandidateShell(root), [a, b])).resolves.toEqual([]);
30
+
31
+ await mkdir(join(root, a.path, 'src'), { recursive: true });
32
+ await writeFile(join(root, a.path, 'src/index.ts'), 'export const changed = true;\n');
33
+ await git(root, ['add', join(a.path, 'src/index.ts')]);
34
+ await git(root, ['commit', '-m', 'feat(a): package local']);
35
+
36
+ await expect(autoReleaseCandidatePackages(gitCandidateShell(root), [a, b])).resolves.toEqual([a]);
37
+ });
38
+ });
39
+
40
+ it('ignores package-local config changes that do not affect published users', async () => {
41
+ await withFixtureRepo(async (root) => {
42
+ await writePackage(root, a.name, a.path, a.version);
43
+ await git(root, ['add', '.']);
44
+ await git(root, ['commit', '-m', 'initial package']);
45
+ await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
46
+
47
+ await mkdir(join(root, a.path), { recursive: true });
48
+ await writeFile(
49
+ join(root, a.path, 'package.json'),
50
+ `${JSON.stringify(
51
+ {
52
+ name: a.name,
53
+ version: a.version,
54
+ scripts: { test: 'nx run a:test' },
55
+ devDependencies: { typescript: '^5.9.3' },
56
+ nx: { name: 'a' },
57
+ },
58
+ null,
59
+ 2,
60
+ )}\n`,
61
+ );
62
+ await writeFile(join(root, a.path, 'tsconfig.test.json'), `${JSON.stringify({ compilerOptions: {} })}\n`);
63
+ await git(root, ['add', '.']);
64
+ await git(root, ['commit', '-m', 'chore(a): normalize package config']);
65
+
66
+ await expect(autoReleaseCandidatePackages(gitCandidateShell(root), [a])).resolves.toEqual([]);
67
+ });
68
+ });
69
+
70
+ it('selects package manifest changes that affect published users', async () => {
71
+ await withFixtureRepo(async (root) => {
72
+ await writePackage(root, a.name, a.path, a.version);
73
+ await git(root, ['add', '.']);
74
+ await git(root, ['commit', '-m', 'initial package']);
75
+ await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
76
+
77
+ await writeFile(
78
+ join(root, a.path, 'package.json'),
79
+ `${JSON.stringify(
80
+ {
81
+ name: a.name,
82
+ version: a.version,
83
+ exports: { '.': './dist/index.js' },
84
+ dependencies: { '@scope/shared': '^1.0.0' },
85
+ },
86
+ null,
87
+ 2,
88
+ )}\n`,
89
+ );
90
+ await git(root, ['add', join(a.path, 'package.json')]);
91
+ await git(root, ['commit', '-m', 'fix(a): update public entrypoint']);
92
+
93
+ await expect(autoReleaseCandidatePackages(gitCandidateShell(root), [a])).resolves.toEqual([a]);
94
+ });
95
+ });
96
+
97
+ it('selects files included by package manifest files entries', async () => {
98
+ await withFixtureRepo(async (root) => {
99
+ await mkdir(join(root, a.path), { recursive: true });
100
+ await writeFile(
101
+ join(root, a.path, 'package.json'),
102
+ `${JSON.stringify({ name: a.name, version: a.version, files: ['schema'] }, null, 2)}\n`,
103
+ );
104
+ await mkdir(join(root, a.path, 'schema'), { recursive: true });
105
+ await writeFile(join(root, a.path, 'schema/index.json'), '{}\n');
106
+ await git(root, ['add', '.']);
107
+ await git(root, ['commit', '-m', 'initial package']);
108
+ await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
109
+
110
+ await writeFile(join(root, a.path, 'schema/index.json'), '{"changed":true}\n');
111
+ await git(root, ['add', join(a.path, 'schema/index.json')]);
112
+ await git(root, ['commit', '-m', 'fix(a): update shipped schema']);
113
+
114
+ await expect(autoReleaseCandidatePackages(gitCandidateShell(root), [a])).resolves.toEqual([a]);
115
+ });
116
+ });
117
+
118
+ it('selects package changes from Nx build input patterns', async () => {
119
+ await withFixtureRepo(async (root) => {
120
+ await writePackage(root, a.name, a.path, a.version);
121
+ await git(root, ['add', '.']);
122
+ await git(root, ['commit', '-m', 'initial package']);
123
+ await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
124
+
125
+ await mkdir(join(root, a.path, 'generated'), { recursive: true });
126
+ await writeFile(join(root, a.path, 'generated/schema.ts'), 'export const schema = 1;\n');
127
+ await git(root, ['add', join(a.path, 'generated/schema.ts')]);
128
+ await git(root, ['commit', '-m', 'fix(a): update generated build input']);
129
+
130
+ await expect(
131
+ autoReleaseCandidatePackages(gitCandidateShell(root, { buildInputPatterns: ['generated/**/*'] }), [a]),
132
+ ).resolves.toEqual([a]);
133
+ });
134
+ });
135
+
136
+ it('uses precise Nx production inputs to ignore config and test-only package paths', async () => {
137
+ await withFixtureRepo(async (root) => {
138
+ await writePackage(root, a.name, a.path, a.version);
139
+ await git(root, ['add', '.']);
140
+ await git(root, ['commit', '-m', 'initial package']);
141
+ await tag(root, '@scope/a@1.0.0', '2025-01-01T00:00:00Z');
142
+
143
+ const shell = gitCandidateShell(root, { buildInputPatterns: ['src/**/*.ts', '!src/**/*.test.ts'] });
144
+
145
+ await mkdir(join(root, a.path, 'src'), { recursive: true });
146
+ await writeFile(join(root, a.path, 'vite.config.ts'), 'export default {};\n');
147
+ await writeFile(join(root, a.path, 'src/index.test.ts'), 'export const testOnly = true;\n');
148
+ await git(root, ['add', join(a.path, 'vite.config.ts'), join(a.path, 'src/index.test.ts')]);
149
+ await git(root, ['commit', '-m', 'test(a): update local-only inputs']);
150
+
151
+ await expect(autoReleaseCandidatePackages(shell, [a])).resolves.toEqual([]);
152
+
153
+ await writeFile(join(root, a.path, 'src/index.ts'), 'export const shipped = true;\n');
154
+ await git(root, ['add', join(a.path, 'src/index.ts')]);
155
+ await git(root, ['commit', '-m', 'fix(a): update production input']);
156
+
157
+ await expect(autoReleaseCandidatePackages(shell, [a])).resolves.toEqual([a]);
158
+ });
159
+ });
160
+
161
+ it('includes untagged packages only when their package path has history', async () => {
162
+ await withFixtureRepo(async (root) => {
163
+ await writePackage(root, c.name, c.path, c.version);
164
+ await git(root, ['add', '.']);
165
+ await git(root, ['commit', '-m', 'feat(c): add package']);
166
+
167
+ await expect(autoReleaseCandidatePackages(gitCandidateShell(root), [c, d])).resolves.toEqual([c]);
168
+ });
169
+ });
170
+ });
171
+
172
+ function gitCandidateShell(root: string, options: { buildInputPatterns?: string[] } = {}): AutoReleaseCandidateShell {
173
+ return {
174
+ gitRefExists: async (ref) => {
175
+ const result = await $`git rev-parse --verify ${ref}`.cwd(root).quiet().nothrow();
176
+ return result.exitCode === 0;
177
+ },
178
+ packageChangedFilesSince: async (ref, packagePath) => {
179
+ const result = await $`git diff --name-only ${`${ref}..HEAD`} -- ${packagePath}`.cwd(root).quiet().nothrow();
180
+ if (result.exitCode === 0) {
181
+ const packagePrefix = `${packagePath}/`;
182
+ return new TextDecoder()
183
+ .decode(result.stdout)
184
+ .split('\n')
185
+ .map((path) => path.trim())
186
+ .filter(Boolean)
187
+ .map((path) => (path.startsWith(packagePrefix) ? path.slice(packagePrefix.length) : path));
188
+ }
189
+ throw new Error(`Unable to inspect package changes under ${packagePath}.`);
190
+ },
191
+ packageJsonAtRef: async (ref, packagePath) => {
192
+ const result = await $`git show ${`${ref}:${packagePath}/package.json`}`.cwd(root).quiet().nothrow();
193
+ if (result.exitCode !== 0) {
194
+ return null;
195
+ }
196
+ return parseJsonObject(new TextDecoder().decode(result.stdout));
197
+ },
198
+ currentPackageJson: async (packagePath) =>
199
+ parseJsonObject(await readFile(join(root, packagePath, 'package.json'), 'utf8')),
200
+ packageBuildInputPatterns: async () => options.buildInputPatterns ?? ['**/*'],
201
+ packageHasHistory: async (packagePath) => {
202
+ const result = await $`git log --format=%H -- ${packagePath}`.cwd(root).quiet().nothrow();
203
+ if (result.exitCode !== 0) {
204
+ throw new Error(`Unable to inspect package history under ${packagePath}.`);
205
+ }
206
+ return new TextDecoder().decode(result.stdout).trim().length > 0;
207
+ },
208
+ };
209
+ }
210
+
211
+ function parseJsonObject(text: string): Record<string, unknown> {
212
+ const parsed = JSON.parse(text);
213
+ if (!isRecord(parsed)) {
214
+ throw new Error('expected JSON object');
215
+ }
216
+ return parsed;
217
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import fc from 'fast-check';
3
+ import { groupReleaseTargets, pendingReleaseTargets, type ReleasePackageInfo, type ReleaseTagRecord } from '../core.js';
4
+ import { planPublishActions } from '../publish-plan.js';
5
+
6
+ const packages: ReleasePackageInfo[] = [
7
+ { name: '@scope/a', projectName: 'a', path: 'packages/a', version: '1.0.0' },
8
+ { name: '@scope/b', projectName: 'b', path: 'packages/b', version: '2.0.0-beta.1' },
9
+ { name: '@scope/c', projectName: 'c', path: 'packages/c', version: '3.0.0' },
10
+ ];
11
+
12
+ describe('release core properties', () => {
13
+ it('groups release tags by commit while preserving repair classifications', () => {
14
+ fc.assert(
15
+ fc.property(releaseRecords(), (records) => {
16
+ const targets = groupReleaseTargets(records);
17
+
18
+ for (const target of targets) {
19
+ const recordsForTarget = records.filter((record) => record.sha === target.sha);
20
+ const expectedTimestamp = Math.max(...recordsForTarget.map((record) => record.timestamp));
21
+ const expectedPackages = recordsForTarget
22
+ .filter((record) => record.needsNpmPublish || record.needsGithubRelease)
23
+ .map((record) => record.pkg.name);
24
+ const expectedNpmPackages = recordsForTarget
25
+ .filter((record) => record.needsNpmPublish)
26
+ .map((record) => record.pkg.name);
27
+ const expectedGithubPackages = recordsForTarget
28
+ .filter((record) => record.needsGithubRelease)
29
+ .map((record) => record.pkg.name);
30
+
31
+ expect(target.timestamp).toBe(expectedTimestamp);
32
+ expect(target.packages.map((pkg) => pkg.name)).toEqual(expectedPackages);
33
+ expect(target.npmPackages.map((pkg) => pkg.name)).toEqual(expectedNpmPackages);
34
+ expect(target.githubPackages.map((pkg) => pkg.name)).toEqual(expectedGithubPackages);
35
+ }
36
+
37
+ expect(targets.map((target) => target.timestamp)).toEqual(
38
+ [...targets].map((target) => target.timestamp).sort((left, right) => right - left),
39
+ );
40
+ }),
41
+ { numRuns: 300 },
42
+ );
43
+ });
44
+
45
+ it('repairs exactly the newest incomplete non-HEAD suffix in oldest-first order', () => {
46
+ fc.assert(
47
+ fc.property(
48
+ releaseRecords(),
49
+ fc.option(fc.constantFrom('commit-0', 'commit-1', 'commit-2', 'commit-3')),
50
+ (records, headSha) => {
51
+ const newestFirst = groupReleaseTargets(records);
52
+ const expected: string[] = [];
53
+ for (const target of newestFirst) {
54
+ if (target.sha === headSha) {
55
+ continue;
56
+ }
57
+ if (target.npmPackages.length === 0 && target.githubPackages.length === 0) {
58
+ break;
59
+ }
60
+ expected.push(target.sha);
61
+ }
62
+
63
+ const pending = pendingReleaseTargets(records, headSha ?? 'not-head');
64
+ expect(pending.map((target) => target.sha)).toEqual(expected.reverse());
65
+ expect(pending.some((target) => target.sha === headSha)).toBe(false);
66
+ for (const target of pending) {
67
+ expect(target.npmPackages.length + target.githubPackages.length).toBeGreaterThan(0);
68
+ }
69
+ },
70
+ ),
71
+ { numRuns: 300 },
72
+ );
73
+ });
74
+
75
+ it('builds exactly npm-missing release packages and never builds GitHub-only repairs', () => {
76
+ fc.assert(
77
+ fc.property(packageSubset(), packageSubset(), (npmMissingPackages, githubMissingPackages) => {
78
+ const plan = planPublishActions({ releasePackages: packages, npmMissingPackages, githubMissingPackages });
79
+
80
+ expect(plan.buildProjects).toEqual(npmMissingPackages.map((pkg) => pkg.projectName));
81
+ expect(plan.publishPackages.map((action) => action.pkg.name)).toEqual(
82
+ npmMissingPackages.map((pkg) => pkg.name),
83
+ );
84
+ expect(plan.githubReleasePackages.map((pkg) => pkg.name)).toEqual(githubMissingPackages.map((pkg) => pkg.name));
85
+ for (const action of plan.publishPackages) {
86
+ expect(action.distTag).toBe(action.pkg.version.includes('-') ? 'next' : 'latest');
87
+ }
88
+ }),
89
+ { numRuns: 300 },
90
+ );
91
+ });
92
+ });
93
+
94
+ function packageSubset(): fc.Arbitrary<ReleasePackageInfo[]> {
95
+ return fc.subarray(packages, { minLength: 0, maxLength: packages.length });
96
+ }
97
+
98
+ function releaseRecords(): fc.Arbitrary<ReleaseTagRecord[]> {
99
+ return fc
100
+ .array(
101
+ fc.record({
102
+ shaIndex: fc.integer({ min: 0, max: 3 }),
103
+ pkgIndex: fc.integer({ min: 0, max: packages.length - 1 }),
104
+ timestamp: fc.integer({ min: 1, max: 10_000 }),
105
+ needsNpmPublish: fc.boolean(),
106
+ needsGithubRelease: fc.boolean(),
107
+ }),
108
+ { minLength: 1, maxLength: 12 },
109
+ )
110
+ .map((rows) => {
111
+ const seen = new Set<string>();
112
+ const records: ReleaseTagRecord[] = [];
113
+ for (const row of rows) {
114
+ const pkg = packages[row.pkgIndex];
115
+ if (!pkg) {
116
+ continue;
117
+ }
118
+ const sha = `commit-${row.shaIndex}`;
119
+ const key = `${sha}:${pkg.name}`;
120
+ if (seen.has(key)) {
121
+ continue;
122
+ }
123
+ seen.add(key);
124
+ records.push({
125
+ tag: `${pkg.name}@${pkg.version}`,
126
+ sha,
127
+ timestamp: row.timestamp,
128
+ pkg,
129
+ needsNpmPublish: row.needsNpmPublish,
130
+ needsGithubRelease: row.needsGithubRelease,
131
+ });
132
+ }
133
+ return records.length > 0 ? records : [record(packages[0], 'commit-0')];
134
+ });
135
+ }
136
+
137
+ function record(pkg: ReleasePackageInfo | undefined, sha: string): ReleaseTagRecord {
138
+ if (!pkg) {
139
+ throw new Error('Missing test package fixture.');
140
+ }
141
+ return {
142
+ tag: `${pkg.name}@${pkg.version}`,
143
+ sha,
144
+ timestamp: 1,
145
+ pkg,
146
+ needsNpmPublish: false,
147
+ needsGithubRelease: false,
148
+ };
149
+ }
@@ -0,0 +1,187 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ collectOwnedReleaseTagRecords,
4
+ type GitReleaseTagInfo,
5
+ groupReleaseTargets,
6
+ npmDistTagForVersion,
7
+ pendingReleaseTargets,
8
+ type ReleasePackageInfo,
9
+ type ReleasePlanningShell,
10
+ type ReleaseTagRecord,
11
+ } from '../core.js';
12
+
13
+ const stablePackage: ReleasePackageInfo = {
14
+ name: '@scope/stable',
15
+ projectName: 'stable',
16
+ path: 'packages/stable',
17
+ version: '1.0.0',
18
+ };
19
+ const prereleasePackage: ReleasePackageInfo = {
20
+ name: '@scope/prerelease',
21
+ projectName: 'prerelease',
22
+ path: 'packages/prerelease',
23
+ version: '2.0.0-beta.1',
24
+ };
25
+
26
+ function record(
27
+ pkg: ReleasePackageInfo,
28
+ sha: string,
29
+ timestamp: number,
30
+ needs: { npm?: boolean; github?: boolean },
31
+ ): ReleaseTagRecord {
32
+ return {
33
+ tag: `${pkg.name}@${pkg.version}`,
34
+ sha,
35
+ timestamp,
36
+ pkg,
37
+ needsNpmPublish: needs.npm === true,
38
+ needsGithubRelease: needs.github === true,
39
+ };
40
+ }
41
+
42
+ describe('release core scenario coverage', () => {
43
+ it('fails loudly for duplicate release tags for the same package on one commit', () => {
44
+ expect(() =>
45
+ groupReleaseTargets([
46
+ record(stablePackage, 'same-commit', 10, { npm: true }),
47
+ record({ ...stablePackage, version: '1.0.1' }, 'same-commit', 11, { github: true }),
48
+ ]),
49
+ ).toThrow('Release target same-commit has more than one release tag for @scope/stable.');
50
+ });
51
+
52
+ it('groups mixed stable and prerelease npm packages while dist-tags remain package-version specific', () => {
53
+ const targets = groupReleaseTargets([
54
+ record(stablePackage, 'mixed', 10, { npm: true }),
55
+ record(prereleasePackage, 'mixed', 11, { npm: true }),
56
+ ]);
57
+
58
+ expect(targets).toHaveLength(1);
59
+ expect(targets[0]?.npmPackages.map((pkg) => pkg.name)).toEqual(['@scope/stable', '@scope/prerelease']);
60
+ expect(targets[0]?.npmPackages.map((pkg) => [pkg.name, npmDistTagForVersion(pkg.version)])).toEqual([
61
+ ['@scope/stable', 'latest'],
62
+ ['@scope/prerelease', 'next'],
63
+ ]);
64
+ });
65
+
66
+ it('skips a complete HEAD and stops at the next older complete target so older gaps are ignored', () => {
67
+ const pending = pendingReleaseTargets(
68
+ [
69
+ record(stablePackage, 'head', 30, {}),
70
+ record(stablePackage, 'complete', 20, {}),
71
+ record(stablePackage, 'ignored-gap', 10, { npm: true, github: true }),
72
+ ],
73
+ 'head',
74
+ );
75
+
76
+ expect(pending).toEqual([]);
77
+ });
78
+
79
+ it('sorts targets by newest tag timestamp before pending reverses them oldest-first', () => {
80
+ const records = [
81
+ record(stablePackage, 'older-target', 10, { npm: true }),
82
+ record(prereleasePackage, 'newer-target', 30, { github: true }),
83
+ record({ ...stablePackage, version: '1.0.1' }, 'newer-target', 40, { npm: true }),
84
+ ];
85
+
86
+ expect(groupReleaseTargets(records).map((target) => target.sha)).toEqual(['newer-target', 'older-target']);
87
+ expect(pendingReleaseTargets(records, 'head').map((target) => target.sha)).toEqual([
88
+ 'older-target',
89
+ 'newer-target',
90
+ ]);
91
+ });
92
+
93
+ it('ignores unrelated tags and tags not reachable from the selected ref through shell callbacks', async () => {
94
+ const packageVersionRefs: string[] = [];
95
+ const durableTags: string[] = [];
96
+ const versions = new Map([
97
+ ['packages/stable:reachable', '1.0.0'],
98
+ ['packages/stable:unreachable', '1.0.1'],
99
+ ]);
100
+ const shell: ReleasePlanningShell = {
101
+ listReleaseTagsByCreatorDate: async () => [
102
+ tag('@scope/stable@1.0.0', 'reachable', 30),
103
+ tag('@other/pkg@9.9.9', 'reachable', 20),
104
+ tag('@scope/stable@1.0.1', 'unreachable', 10),
105
+ ],
106
+ isAncestor: async (ancestor) => ancestor === 'reachable',
107
+ packageVersionAtRef: async (packagePath, ref) => {
108
+ packageVersionRefs.push(`${packagePath}:${ref}`);
109
+ return versions.get(`${packagePath}:${ref}`) ?? null;
110
+ },
111
+ durableTagState: async (_pkg, tagName) => {
112
+ durableTags.push(tagName);
113
+ return { npmPublished: false, githubReleaseExists: true };
114
+ },
115
+ };
116
+
117
+ const records = await collectOwnedReleaseTagRecords(
118
+ [{ name: stablePackage.name, projectName: stablePackage.projectName, path: stablePackage.path }],
119
+ 'selected-ref',
120
+ shell,
121
+ );
122
+
123
+ expect(records.map((releaseRecord) => releaseRecord.tag)).toEqual(['@scope/stable@1.0.0']);
124
+ expect(packageVersionRefs).toEqual(['packages/stable:reachable']);
125
+ expect(durableTags).toEqual(['@scope/stable@1.0.0']);
126
+ });
127
+
128
+ it('stops querying older package tags after the newest tag for that package is fully durable', async () => {
129
+ const durableTags: string[] = [];
130
+ const versions = new Map([
131
+ ['packages/stable:newer-stable', '1.1.0'],
132
+ ['packages/stable:older-stable', '1.0.0'],
133
+ ['packages/prerelease:older-prerelease', '2.0.0-beta.1'],
134
+ ]);
135
+ const shell: ReleasePlanningShell = {
136
+ listReleaseTagsByCreatorDate: async () => [
137
+ tag('@scope/stable@1.1.0', 'newer-stable', 30),
138
+ tag('@scope/prerelease@2.0.0-beta.1', 'older-prerelease', 20),
139
+ tag('@scope/stable@1.0.0', 'older-stable', 10),
140
+ ],
141
+ isAncestor: async () => true,
142
+ packageVersionAtRef: async (packagePath, ref) => versions.get(`${packagePath}:${ref}`) ?? null,
143
+ durableTagState: async (_pkg, tagName) => {
144
+ durableTags.push(tagName);
145
+ return { npmPublished: true, githubReleaseExists: true };
146
+ },
147
+ };
148
+
149
+ const records = await collectOwnedReleaseTagRecords(
150
+ [
151
+ { name: stablePackage.name, projectName: stablePackage.projectName, path: stablePackage.path },
152
+ { name: prereleasePackage.name, projectName: prereleasePackage.projectName, path: prereleasePackage.path },
153
+ ],
154
+ 'selected-ref',
155
+ shell,
156
+ );
157
+
158
+ expect(records.map((releaseRecord) => releaseRecord.tag)).toEqual([
159
+ '@scope/stable@1.1.0',
160
+ '@scope/prerelease@2.0.0-beta.1',
161
+ ]);
162
+ expect(durableTags).toEqual(['@scope/stable@1.1.0', '@scope/prerelease@2.0.0-beta.1']);
163
+ });
164
+
165
+ it('throws when a release tag version does not match package.json at the peeled commit', async () => {
166
+ const shell: ReleasePlanningShell = {
167
+ listReleaseTagsByCreatorDate: async () => [tag('@scope/stable@1.0.0', 'mismatch', 10)],
168
+ isAncestor: async () => true,
169
+ packageVersionAtRef: async () => '1.0.1',
170
+ durableTagState: async () => ({ npmPublished: true, githubReleaseExists: true }),
171
+ };
172
+
173
+ await expect(
174
+ collectOwnedReleaseTagRecords(
175
+ [{ name: stablePackage.name, projectName: stablePackage.projectName, path: stablePackage.path }],
176
+ 'selected-ref',
177
+ shell,
178
+ ),
179
+ ).rejects.toThrow(
180
+ 'Release tag @scope/stable@1.0.0 points at mismatch, but packages/stable/package.json has version 1.0.1.',
181
+ );
182
+ });
183
+ });
184
+
185
+ function tag(name: string, sha: string, timestamp: number): GitReleaseTagInfo {
186
+ return { name, sha, timestamp };
187
+ }