@smoothbricks/cli 0.3.3 โ†’ 0.4.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 (33) hide show
  1. package/dist/cli.js +14 -0
  2. package/dist/github-ci/index.d.ts +12 -0
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +72 -4
  5. package/dist/monorepo/ci-workflow.d.ts +26 -0
  6. package/dist/monorepo/ci-workflow.d.ts.map +1 -0
  7. package/dist/monorepo/ci-workflow.js +201 -0
  8. package/dist/monorepo/managed-files.d.ts.map +1 -1
  9. package/dist/monorepo/managed-files.js +97 -5
  10. package/dist/monorepo/package-policy.d.ts.map +1 -1
  11. package/dist/monorepo/package-policy.js +4 -2
  12. package/dist/monorepo/publish-workflow.d.ts +7 -1
  13. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  14. package/dist/monorepo/publish-workflow.js +59 -15
  15. package/dist/monorepo/runtime.d.ts +3 -0
  16. package/dist/monorepo/runtime.d.ts.map +1 -1
  17. package/dist/monorepo/runtime.js +66 -2
  18. package/managed/raw/git-format-staged.yml +4 -0
  19. package/package.json +3 -3
  20. package/src/cli.ts +35 -5
  21. package/src/github-ci/index.ts +94 -8
  22. package/src/monorepo/__tests__/ci-workflow.test.ts +34 -0
  23. package/src/monorepo/__tests__/publish-workflow.test.ts +65 -2
  24. package/src/monorepo/ci-workflow.ts +229 -0
  25. package/src/monorepo/managed-files.ts +115 -5
  26. package/src/monorepo/package-policy.test.ts +24 -9
  27. package/src/monorepo/package-policy.ts +4 -2
  28. package/src/monorepo/publish-workflow.ts +73 -16
  29. package/src/monorepo/runtime.test.ts +30 -0
  30. package/src/monorepo/runtime.ts +94 -2
  31. package/src/release/__tests__/fixture-repo.test.ts +14 -13
  32. package/src/release/__tests__/helpers/fixture-repo.ts +85 -12
  33. package/managed/templates/github/workflows/ci.yml +0 -102
@@ -1,8 +1,9 @@
1
1
  import { isSmoothBricksCodebasePackageName } from '../lib/cli-package.js';
2
2
 
3
3
  export type PublishWorkflowBump = 'auto' | 'patch' | 'minor' | 'major' | 'prerelease';
4
- export type PublishWorkflowCondition = 'version-mode-not-none' | 'failure' | 'always';
4
+ export type PublishWorkflowCondition = 'version-mode-not-none' | 'deploy-production' | 'failure' | 'always';
5
5
  export type PublishWorkflowNxTarget = 'build' | 'lint' | 'test';
6
+ export type PublishWorkflowDeployEnvironment = 'none' | 'production';
6
7
 
7
8
  export enum PublishWorkflowStepKind {
8
9
  Checkout = 'checkout',
@@ -18,6 +19,7 @@ export enum PublishWorkflowStepKind {
18
19
  UploadTraceDbs = 'upload-trace-dbs',
19
20
  ValidateMonorepoConfig = 'validate-monorepo-config',
20
21
  PublishRelease = 'publish-release',
22
+ DeployProduction = 'deploy-production',
21
23
  SaveNixDevenv = 'save-nix-devenv',
22
24
  }
23
25
 
@@ -35,11 +37,14 @@ export interface PublishWorkflowDefinition {
35
37
  }
36
38
 
37
39
  export interface PublishWorkflowDefinitionOptions {
40
+ deploy?: boolean;
41
+ deployProvider?: 'cloudflare';
38
42
  repoName?: string;
39
43
  }
40
44
 
41
45
  export interface PublishWorkflowInputs {
42
46
  bump: PublishWorkflowBump;
47
+ deployEnvironment: PublishWorkflowDeployEnvironment;
43
48
  dryRun: boolean;
44
49
  }
45
50
 
@@ -65,6 +70,7 @@ export interface PublishWorkflowCallbacks {
65
70
  uploadTraceDbs(): Promise<void>;
66
71
  validateMonorepoConfig(): Promise<void>;
67
72
  publishRelease(input: { bump: PublishWorkflowBump; dryRun: boolean }): Promise<void>;
73
+ deployProduction(): Promise<void>;
68
74
  saveNixDevenv(input: PublishWorkflowSetupOutputs): Promise<void>;
69
75
  }
70
76
 
@@ -90,6 +96,19 @@ export function definePublishWorkflow(options: PublishWorkflowDefinitionOptions
90
96
  if (isSmoothBricksCodebasePackageName(options.repoName)) {
91
97
  setupSteps.push({ kind: PublishWorkflowStepKind.BuildSelfHostedCli, name: '๐Ÿ—๏ธ Build self-hosted smoo' });
92
98
  }
99
+ const releaseSteps: PublishWorkflowStepInput[] = [
100
+ {
101
+ kind: PublishWorkflowStepKind.PublishRelease,
102
+ name: `๐Ÿ“ฆ Publish release (${versionMode})`,
103
+ },
104
+ ];
105
+ if (options.deploy === true) {
106
+ releaseSteps.push({
107
+ kind: PublishWorkflowStepKind.DeployProduction,
108
+ name: '๐Ÿš€ Deploy production',
109
+ condition: 'deploy-production',
110
+ });
111
+ }
93
112
  return {
94
113
  steps: numberWorkflowSteps([
95
114
  ...setupSteps,
@@ -127,10 +146,7 @@ export function definePublishWorkflow(options: PublishWorkflowDefinitionOptions
127
146
  name: `โœ… Validate monorepo config (${versionMode})`,
128
147
  condition: 'version-mode-not-none',
129
148
  },
130
- {
131
- kind: PublishWorkflowStepKind.PublishRelease,
132
- name: `๐Ÿ“ฆ Publish release (${versionMode})`,
133
- },
149
+ ...releaseSteps,
134
150
  {
135
151
  kind: PublishWorkflowStepKind.SaveNixDevenv,
136
152
  name: '๐Ÿงน Cleanup and cache Nix/devenv',
@@ -153,7 +169,7 @@ export async function runPublishWorkflow(
153
169
  let failed = false;
154
170
  let failure: unknown;
155
171
  for (const step of workflow.steps) {
156
- if (!shouldRunStep(step, version, failed)) {
172
+ if (!shouldRunStep(step, version, failed, context.inputs)) {
157
173
  continue;
158
174
  }
159
175
  try {
@@ -201,6 +217,9 @@ export async function runPublishWorkflow(
201
217
  dryRun: context.inputs.dryRun,
202
218
  });
203
219
  break;
220
+ case PublishWorkflowStepKind.DeployProduction:
221
+ await context.callbacks.deployProduction();
222
+ break;
204
223
  case PublishWorkflowStepKind.SaveNixDevenv:
205
224
  await context.callbacks.saveNixDevenv(setupOutputs);
206
225
  break;
@@ -216,10 +235,18 @@ export async function runPublishWorkflow(
216
235
  return { version, failed };
217
236
  }
218
237
 
219
- function shouldRunStep(step: PublishWorkflowStep, version: PublishWorkflowVersionOutputs, failed: boolean): boolean {
238
+ function shouldRunStep(
239
+ step: PublishWorkflowStep,
240
+ version: PublishWorkflowVersionOutputs,
241
+ failed: boolean,
242
+ inputs: PublishWorkflowInputs,
243
+ ): boolean {
220
244
  if (step.condition === 'version-mode-not-none') {
221
245
  return version.mode !== 'none';
222
246
  }
247
+ if (step.condition === 'deploy-production') {
248
+ return version.mode !== 'none' && inputs.deployEnvironment === 'production' && !inputs.dryRun;
249
+ }
223
250
  if (step.condition === 'failure') {
224
251
  return failed;
225
252
  }
@@ -228,10 +255,19 @@ function shouldRunStep(step: PublishWorkflowStep, version: PublishWorkflowVersio
228
255
 
229
256
  export function renderPublishWorkflowYaml(options: PublishWorkflowDefinitionOptions = {}): string {
230
257
  const steps = definePublishWorkflow(options).steps;
231
- return `${renderPublishWorkflowHeader()}${renderPublishWorkflowSteps(steps)}`;
258
+ return `${renderPublishWorkflowHeader(options)}${renderPublishWorkflowSteps(steps, options)}`;
232
259
  }
233
260
 
234
- function renderPublishWorkflowHeader(): string {
261
+ function renderPublishWorkflowHeader(options: PublishWorkflowDefinitionOptions): string {
262
+ const deployInput =
263
+ options.deploy === true
264
+ ? `
265
+ deploy_environment:
266
+ type: choice
267
+ description: Deploy live systems after a successful publish.
268
+ options: [none, production]
269
+ default: none`
270
+ : '';
235
271
  return `name: Publish
236
272
 
237
273
  on:
@@ -247,7 +283,7 @@ on:
247
283
  dry_run:
248
284
  type: boolean
249
285
  description: Run release commands without writing versions, tags, publishes, or GitHub Releases.
250
- default: false
286
+ default: false${deployInput}
251
287
 
252
288
  permissions:
253
289
  contents: write
@@ -271,12 +307,12 @@ jobs:
271
307
  `;
272
308
  }
273
309
 
274
- function renderPublishWorkflowSteps(steps: PublishWorkflowStep[]): string {
310
+ function renderPublishWorkflowSteps(steps: PublishWorkflowStep[], options: PublishWorkflowDefinitionOptions): string {
275
311
  const lines: string[] = [];
276
312
  for (const step of steps) {
277
313
  lines.push(...sectionLinesBefore(step));
278
314
  lines.push(...commentLinesForStep(step));
279
- lines.push(...yamlLinesForStep(step));
315
+ lines.push(...yamlLinesForStep(step, options));
280
316
  lines.push('');
281
317
  }
282
318
  return `${lines.join('\n').trimEnd()}\n`;
@@ -319,7 +355,7 @@ function commentLinesForStep(step: PublishWorkflowStep): string[] {
319
355
  return [` # Step ${step.number}`];
320
356
  }
321
357
 
322
- function yamlLinesForStep(step: PublishWorkflowStep): string[] {
358
+ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDefinitionOptions): string[] {
323
359
  switch (step.kind) {
324
360
  case PublishWorkflowStepKind.Checkout:
325
361
  return [
@@ -396,6 +432,8 @@ function yamlLinesForStep(step: PublishWorkflowStep): string[] {
396
432
  ' # Missing package names are bootstrapped locally before trust setup.',
397
433
  ` run: smoo release publish --bump "${githubExpression('inputs.bump')}" --dry-run "${githubExpression('inputs.dry_run')}"`,
398
434
  ];
435
+ case PublishWorkflowStepKind.DeployProduction:
436
+ return deployProductionStep(step, options);
399
437
  case PublishWorkflowStepKind.SaveNixDevenv:
400
438
  return [
401
439
  ` - name: ${step.name}`,
@@ -408,14 +446,33 @@ function yamlLinesForStep(step: PublishWorkflowStep): string[] {
408
446
  }
409
447
  }
410
448
 
411
- function conditionalRunStep(step: PublishWorkflowStep, run: string): string[] {
449
+ function deployProductionStep(step: PublishWorkflowStep, options: PublishWorkflowDefinitionOptions): string[] {
412
450
  return [
413
451
  ` - name: ${step.name}`,
414
- ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
415
- ` run: ${run}`,
452
+ ' if:',
453
+ " ${{ steps.version.outputs.mode != 'none' && inputs.deploy_environment == 'production' && inputs.dry_run !=",
454
+ " 'true' }}",
455
+ ...deployEnvLines(options),
456
+ ' run: smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
416
457
  ];
417
458
  }
418
459
 
460
+ function deployEnvLines(options: PublishWorkflowDefinitionOptions): string[] {
461
+ if (options.deployProvider !== 'cloudflare') {
462
+ return [];
463
+ }
464
+ return [
465
+ ' env:',
466
+ ' CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}',
467
+ ' CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}',
468
+ ];
469
+ }
470
+
471
+ function conditionalRunStep(step: PublishWorkflowStep, run: string): string[] {
472
+ const condition = "steps.version.outputs.mode != 'none'";
473
+ return [` - name: ${step.name}`, ` if: ${githubExpression(condition)}`, ` run: ${run}`];
474
+ }
475
+
419
476
  function githubExpression(expression: string): string {
420
477
  return ['$', '{{ ', expression, ' }}'].join('');
421
478
  }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { runtimeTypesRangeForPublishedVersions } from './runtime.js';
3
+
4
+ describe('runtimeTypesRangeForPublishedVersions', () => {
5
+ test('uses the installed Node major when @types/node has published it', () => {
6
+ expect(
7
+ runtimeTypesRangeForPublishedVersions('@types/node', '24.12.0', 'major', ['24.0.0', '24.12.4', '25.9.1']),
8
+ ).toBe('~24.0.0');
9
+ });
10
+
11
+ test('falls back to latest published @types/node when the Node major is unpublished', () => {
12
+ expect(
13
+ runtimeTypesRangeForPublishedVersions('@types/node', '26.0.0', 'major', ['24.12.4', '25.9.0', '25.9.1']),
14
+ ).toBe('~25.9.1');
15
+ });
16
+
17
+ test('uses the installed Bun version when @types/bun has published it', () => {
18
+ expect(runtimeTypesRangeForPublishedVersions('@types/bun', '1.3.14', 'exact', ['1.3.13', '1.3.14'])).toBe('1.3.14');
19
+ });
20
+
21
+ test('falls back to latest published @types/bun when the Bun version is unpublished', () => {
22
+ expect(runtimeTypesRangeForPublishedVersions('@types/bun', '1.3.15', 'exact', ['1.3.13', '1.3.14'])).toBe('1.3.14');
23
+ });
24
+
25
+ test('ignores non-stable version strings when choosing the fallback', () => {
26
+ expect(runtimeTypesRangeForPublishedVersions('@types/node', '26.0.0', 'major', ['25.9.1', '26.0.0-beta.1'])).toBe(
27
+ '~25.9.1',
28
+ );
29
+ });
30
+ });
@@ -23,8 +23,18 @@ export async function syncRootRuntimeVersions(root: string): Promise<void> {
23
23
  changed = setStringProperty(engines, 'node', `>=${nodeMajor}.0.0`) || changed;
24
24
  changed = setStringProperty(packageJson, 'packageManager', `bun@${bunVersion}`) || changed;
25
25
  const devDependencies = getOrCreateRecord(packageJson, 'devDependencies');
26
- changed = setStringProperty(devDependencies, '@types/node', `~${nodeMajor}.0.0`) || changed;
27
- changed = setStringProperty(devDependencies, '@types/bun', bunVersion) || changed;
26
+ changed =
27
+ setStringProperty(
28
+ devDependencies,
29
+ '@types/node',
30
+ await runtimeTypesRange(root, '@types/node', nodeVersion, 'major'),
31
+ ) || changed;
32
+ changed =
33
+ setStringProperty(
34
+ devDependencies,
35
+ '@types/bun',
36
+ await runtimeTypesRange(root, '@types/bun', bunVersion, 'exact'),
37
+ ) || changed;
28
38
 
29
39
  if (changed) {
30
40
  writeJsonObject(packageJsonPath, packageJson);
@@ -32,6 +42,88 @@ export async function syncRootRuntimeVersions(root: string): Promise<void> {
32
42
  }
33
43
  }
34
44
 
45
+ type RuntimeTypesPinMode = 'major' | 'exact';
46
+
47
+ async function runtimeTypesRange(
48
+ root: string,
49
+ packageName: string,
50
+ runtimeVersion: string,
51
+ pinMode: RuntimeTypesPinMode,
52
+ ): Promise<string> {
53
+ const versionsText = await $`bun pm view ${packageName} versions --json`.cwd(root).text();
54
+ const versions = JSON.parse(versionsText) as unknown;
55
+ if (!Array.isArray(versions) || !versions.every((version) => typeof version === 'string')) {
56
+ throw new Error(`Unable to read published ${packageName} versions`);
57
+ }
58
+ return runtimeTypesRangeForPublishedVersions(packageName, runtimeVersion, pinMode, versions);
59
+ }
60
+
61
+ export function runtimeTypesRangeForPublishedVersions(
62
+ packageName: string,
63
+ runtimeVersion: string,
64
+ pinMode: RuntimeTypesPinMode,
65
+ versions: readonly string[],
66
+ ): string {
67
+ const parsedRuntimeVersion = parseVersion(runtimeVersion);
68
+ if (!parsedRuntimeVersion) {
69
+ throw new Error(`Unable to parse runtime version ${runtimeVersion}`);
70
+ }
71
+
72
+ if (pinMode === 'major') {
73
+ const runtimeMajor = parsedRuntimeVersion[0].toString();
74
+ if (latestVersion(versions.filter((version) => versionMajor(version) === runtimeMajor))) {
75
+ return `~${runtimeMajor}.0.0`;
76
+ }
77
+ } else if (versions.includes(runtimeVersion)) {
78
+ return runtimeVersion;
79
+ }
80
+
81
+ const latest = latestVersion(versions);
82
+ if (!latest) {
83
+ throw new Error(`Unable to find any published ${packageName} versions`);
84
+ }
85
+
86
+ console.warn(`${packageName} has no published types for runtime ${runtimeVersion}; using ${latest}`);
87
+ return pinMode === 'major' ? `~${latest}` : latest;
88
+ }
89
+
90
+ function latestVersion(versions: readonly string[]): string | null {
91
+ let latest: string | null = null;
92
+ for (const version of versions) {
93
+ if (!parseVersion(version)) {
94
+ continue;
95
+ }
96
+ if (!latest || compareVersions(version, latest) > 0) {
97
+ latest = version;
98
+ }
99
+ }
100
+ return latest;
101
+ }
102
+
103
+ function compareVersions(left: string, right: string): number {
104
+ const leftParts = parseVersion(left);
105
+ const rightParts = parseVersion(right);
106
+ if (!leftParts || !rightParts) {
107
+ return 0;
108
+ }
109
+ for (let index = 0; index < leftParts.length; index++) {
110
+ const diff = leftParts[index] - rightParts[index];
111
+ if (diff !== 0) {
112
+ return diff;
113
+ }
114
+ }
115
+ return 0;
116
+ }
117
+
118
+ function versionMajor(version: string): string | null {
119
+ return parseVersion(version)?.[0].toString() ?? null;
120
+ }
121
+
122
+ function parseVersion(version: string): [number, number, number] | null {
123
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
124
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
125
+ }
126
+
35
127
  function runtimeCommand(root: string, name: 'bun' | 'node'): string {
36
128
  const candidates = [
37
129
  join(root, 'tooling', 'direnv', '.devenv', 'profile', 'bin', name),
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { $ } from 'bun';
5
4
  import { collectOwnedReleaseTagRecords, pendingReleaseTargets, type ReleasePackageInfo, releaseTag } from '../core.js';
6
5
  import { completeReleaseAtHead, type ReleaseRepairShell, repairPendingTargets } from '../orchestration.js';
7
6
  import {
@@ -9,7 +8,9 @@ import {
9
8
  gitIsAncestor,
10
9
  gitOutput,
11
10
  gitReleaseTagsByCreatorDate,
11
+ gitSucceeds,
12
12
  packageVersionAtRef,
13
+ runFixtureNx,
13
14
  tag,
14
15
  withFixtureRepo,
15
16
  writeBuildablePackage,
@@ -78,7 +79,7 @@ describe('release planning with fixture git repositories', () => {
78
79
  await git(root, ['init', '--bare', 'remote.git']);
79
80
  await git(root, ['remote', 'add', 'origin', join(root, 'remote.git')]);
80
81
  await git(root, ['push', 'origin', 'main', '--tags']);
81
- await $`git clone --branch main ${join(root, 'remote.git')} checkout`.cwd(root).quiet();
82
+ await git(root, ['clone', '--branch', 'main', join(root, 'remote.git'), 'checkout']);
82
83
  const checkout = join(root, 'checkout');
83
84
  await git(checkout, ['fetch', '--tags', 'origin', 'main']);
84
85
  const head = await gitOutput(checkout, ['rev-parse', 'HEAD']);
@@ -105,7 +106,7 @@ describe('release planning with fixture git repositories', () => {
105
106
  await writeBuildablePackage(root, '@scope/a', 'packages/a');
106
107
  await writeBuildablePackage(root, '@scope/b', 'packages/b');
107
108
 
108
- await $`nx run-many -t build --projects=${'a,b'}`.cwd(root).quiet();
109
+ await runFixtureNx(root, ['run-many', '-t', 'build', '--projects=a,b']);
109
110
 
110
111
  await expect(readFile(join(root, 'packages/a/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
111
112
  await expect(readFile(join(root, 'packages/b/dist/index.js'), 'utf8')).resolves.toBe('{}\n');
@@ -143,7 +144,7 @@ describe('release planning with fixture git repositories', () => {
143
144
  await git(author, ['init', '--bare', 'remote.git']);
144
145
  await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
145
146
  await git(author, ['push', 'origin', 'main', '--tags']);
146
- await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
147
+ await git(author, ['clone', '--branch', 'main', join(author, 'remote.git'), 'runner']);
147
148
  const runner = join(author, 'runner');
148
149
  await git(runner, ['config', 'user.name', 'Test User']);
149
150
  await git(runner, ['config', 'user.email', 'test@example.com']);
@@ -197,7 +198,7 @@ describe('release planning with fixture git repositories', () => {
197
198
  await git(author, ['init', '--bare', 'remote.git']);
198
199
  await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
199
200
  await git(author, ['push', 'origin', 'main']);
200
- await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
201
+ await git(author, ['clone', '--branch', 'main', join(author, 'remote.git'), 'runner']);
201
202
  const runner = join(author, 'runner');
202
203
  await git(runner, ['config', 'user.name', 'Test User']);
203
204
  await git(runner, ['config', 'user.email', 'test@example.com']);
@@ -213,7 +214,7 @@ describe('release planning with fixture git repositories', () => {
213
214
 
214
215
  expect(summary.pushed).toBe(true);
215
216
  expect(shell.pushes).toEqual([['pushed@1.0.0']]);
216
- await $`git clone --branch main ${join(author, 'remote.git')} auditor`.cwd(author).quiet();
217
+ await git(author, ['clone', '--branch', 'main', join(author, 'remote.git'), 'auditor']);
217
218
  const auditor = join(author, 'auditor');
218
219
  await git(auditor, ['fetch', '--tags', 'origin', 'main']);
219
220
  await expect(gitOutput(auditor, ['rev-parse', 'refs/tags/pushed@1.0.0^{}'])).resolves.toBe(
@@ -237,7 +238,7 @@ describe('release planning with fixture git repositories', () => {
237
238
  await git(author, ['init', '--bare', 'remote.git']);
238
239
  await git(author, ['remote', 'add', 'origin', join(author, 'remote.git')]);
239
240
  await git(author, ['push', 'origin', 'main', '--tags']);
240
- await $`git clone --branch main ${join(author, 'remote.git')} runner`.cwd(author).quiet();
241
+ await git(author, ['clone', '--branch', 'main', join(author, 'remote.git'), 'runner']);
241
242
  const runner = join(author, 'runner');
242
243
  await git(runner, ['config', 'user.name', 'Test User']);
243
244
  await git(runner, ['config', 'user.email', 'test@example.com']);
@@ -323,7 +324,12 @@ class LocalGitRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
323
324
 
324
325
  async buildReleaseCandidate(packages: ReleasePackageInfo[]): Promise<void> {
325
326
  this.builds.push(packages.map((pkg) => pkg.name));
326
- await $`nx run-many -t build --projects=${packages.map((pkg) => pkg.projectName).join(',')}`.cwd(this.root).quiet();
327
+ await runFixtureNx(this.root, [
328
+ 'run-many',
329
+ '-t',
330
+ 'build',
331
+ `--projects=${packages.map((pkg) => pkg.projectName).join(',')}`,
332
+ ]);
327
333
  }
328
334
 
329
335
  async publishPackage(pkg: ReleasePackageInfo, distTag: string, dryRun: boolean): Promise<void> {
@@ -357,8 +363,3 @@ class LocalGitRepairShell implements ReleaseRepairShell<ReleasePackageInfo> {
357
363
  await git(this.root, ['tag', '-a', tagName, '-m', tagName, 'HEAD']);
358
364
  }
359
365
  }
360
-
361
- async function gitSucceeds(root: string, args: string[]): Promise<boolean> {
362
- const result = await $`git ${args}`.cwd(root).quiet().nothrow();
363
- return result.exitCode === 0;
364
- }
@@ -4,6 +4,8 @@ import { join } from 'node:path';
4
4
  import { $ } from 'bun';
5
5
  import type { GitReleaseTagInfo } from '../../core.js';
6
6
 
7
+ const GIT_TIMEOUT_MS = 10_000;
8
+
7
9
  export async function withFixtureRepo(fn: (root: string) => Promise<void>): Promise<void> {
8
10
  const root = await mkdtemp(join(tmpdir(), 'smoo-release-test-'));
9
11
  try {
@@ -63,26 +65,98 @@ export async function writeBuildablePackage(
63
65
  }
64
66
 
65
67
  export async function git(root: string, args: string[], env?: Record<string, string>): Promise<void> {
66
- await $`git ${args}`
67
- .cwd(root)
68
- .env(env ?? {})
69
- .quiet();
68
+ const result = await gitResult(root, args, env);
69
+ if (result.exitCode !== 0) {
70
+ throw new Error(gitErrorMessage(root, args, result));
71
+ }
70
72
  }
71
73
 
72
74
  export async function gitOutput(root: string, args: string[]): Promise<string> {
73
- const result = await $`git ${args}`.cwd(root).quiet();
75
+ const result = await gitResult(root, args);
76
+ if (result.exitCode !== 0) {
77
+ throw new Error(gitErrorMessage(root, args, result));
78
+ }
74
79
  return new TextDecoder().decode(result.stdout).trim();
75
80
  }
76
81
 
82
+ export async function gitSucceeds(root: string, args: string[]): Promise<boolean> {
83
+ return (await gitResult(root, args)).exitCode === 0;
84
+ }
85
+
86
+ async function gitResult(root: string, args: string[], env?: Record<string, string>): Promise<GitResult> {
87
+ const proc = Bun.spawn(['git', ...args], {
88
+ cwd: root,
89
+ env: { ...process.env, ...env },
90
+ stdout: 'pipe',
91
+ stderr: 'pipe',
92
+ });
93
+ let timedOut = false;
94
+ const timeout = setTimeout(() => {
95
+ timedOut = true;
96
+ proc.kill();
97
+ }, GIT_TIMEOUT_MS);
98
+ try {
99
+ const [exitCode, stdout, stderr] = await Promise.all([
100
+ proc.exited,
101
+ streamBytes(proc.stdout),
102
+ streamBytes(proc.stderr),
103
+ ]);
104
+ return { exitCode, stdout, stderr, timedOut };
105
+ } finally {
106
+ clearTimeout(timeout);
107
+ }
108
+ }
109
+
110
+ async function streamBytes(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
111
+ return new Uint8Array(await new Response(stream).arrayBuffer());
112
+ }
113
+
114
+ export async function runFixtureNx(root: string, args: string[]): Promise<void> {
115
+ await $`nx ${args}`
116
+ .cwd(root)
117
+ .env({ ...definedProcessEnv(), NX_DAEMON: 'false' })
118
+ .quiet();
119
+ }
120
+
121
+ function definedProcessEnv(): Record<string, string> {
122
+ const env: Record<string, string> = {};
123
+ for (const [key, value] of Object.entries(process.env)) {
124
+ if (value !== undefined) {
125
+ env[key] = value;
126
+ }
127
+ }
128
+ return env;
129
+ }
130
+
131
+ interface GitResult {
132
+ exitCode: number;
133
+ stdout: Uint8Array;
134
+ stderr: Uint8Array;
135
+ timedOut: boolean;
136
+ }
137
+
138
+ function gitErrorMessage(root: string, args: string[], result: GitResult): string {
139
+ const stderr = new TextDecoder().decode(result.stderr).trim();
140
+ const timeoutText = result.timedOut
141
+ ? ` timed out after ${GIT_TIMEOUT_MS}ms`
142
+ : ` failed with exit code ${result.exitCode}`;
143
+ return [`git ${args.join(' ')}${timeoutText}`, `cwd: ${root}`, stderr].filter(Boolean).join('\n');
144
+ }
145
+
77
146
  export async function tag(root: string, tagName: string, date: string): Promise<void> {
78
147
  await git(root, ['tag', '-a', tagName, '-m', tagName], { GIT_COMMITTER_DATE: date });
79
148
  }
80
149
 
81
150
  export async function gitReleaseTagsByCreatorDate(root: string): Promise<GitReleaseTagInfo[]> {
82
- const result =
83
- await $`git for-each-ref --sort=-creatordate --format=${'%(refname:short)%09%(creatordate:unix)%09%(*objectname)%09%(objectname)'} refs/tags`
84
- .cwd(root)
85
- .quiet();
151
+ const result = await gitResult(root, [
152
+ 'for-each-ref',
153
+ '--sort=-creatordate',
154
+ '--format=%(refname:short)%09%(creatordate:unix)%09%(*objectname)%09%(objectname)',
155
+ 'refs/tags',
156
+ ]);
157
+ if (result.exitCode !== 0) {
158
+ throw new Error(gitErrorMessage(root, ['for-each-ref', 'refs/tags'], result));
159
+ }
86
160
  return new TextDecoder()
87
161
  .decode(result.stdout)
88
162
  .split('\n')
@@ -100,12 +174,11 @@ export async function gitReleaseTagsByCreatorDate(root: string): Promise<GitRele
100
174
  }
101
175
 
102
176
  export async function gitIsAncestor(root: string, ancestor: string, descendant: string): Promise<boolean> {
103
- const result = await $`git merge-base --is-ancestor ${ancestor} ${descendant}`.cwd(root).quiet().nothrow();
104
- return result.exitCode === 0;
177
+ return gitSucceeds(root, ['merge-base', '--is-ancestor', ancestor, descendant]);
105
178
  }
106
179
 
107
180
  export async function packageVersionAtRef(root: string, packagePath: string, ref: string): Promise<string | null> {
108
- const result = await $`git show ${`${ref}:${packagePath}/package.json`}`.cwd(root).quiet().nothrow();
181
+ const result = await gitResult(root, ['show', `${ref}:${packagePath}/package.json`]);
109
182
  if (result.exitCode !== 0) {
110
183
  return null;
111
184
  }
@@ -1,102 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches:
6
- - '**'
7
- pull_request:
8
-
9
- permissions:
10
- actions: read
11
- contents: read
12
- statuses: write
13
-
14
- defaults:
15
- run:
16
- working-directory: tooling/direnv
17
-
18
- jobs:
19
- main:
20
- name: Validate
21
- runs-on: ubuntu-latest
22
- timeout-minutes: 45
23
- env:
24
- NIX_STORE_NAR: ${{ github.workspace }}/nix-store.nar
25
- GH_TOKEN: ${{ github.token }}
26
- steps:
27
- # Step 1: GitHub adds "Set up job" automatically
28
- # Step 2
29
- - name: ๐Ÿ“ฅ Checkout
30
- uses: actions/checkout@v6.0.2
31
- with:
32
- filter: blob:none
33
- fetch-depth: 0
34
-
35
- # Step 3. Composite action internals do not affect top-level job step
36
- # anchors; update the nx-smart --step values below if top-level steps move.
37
- - name: ๐Ÿงฑ Setup Nix/devenv
38
- id: setup
39
- uses: ./.github/actions/setup-devenv
40
-
41
- # --- Nx -----------------------------------------------------------------
42
-
43
- # Step 4
44
- # Sets the base and head SHAs required for the nx affected commands
45
- - name: ๐Ÿงญ Set Nx SHAs
46
- uses: nrwl/nx-set-shas@v5.0.1
47
- with:
48
- workflow-id: ci.yml
49
-
50
- # Step 5
51
- - name: ๐Ÿง  Restore Nx cache
52
- id: nx-cache
53
- uses: ./.github/actions/cache-nx
54
-
55
- # Step 6
56
- - name: ๐Ÿ”จ Build
57
- run: smoo github-ci nx-smart --target build --name "Build" --step 6
58
-
59
- # Step 7
60
- - name: ๐Ÿ” Lint
61
- run: smoo github-ci nx-smart --target lint --name "Lint" --step 7
62
-
63
- # Step 8
64
- - name: ๐Ÿงช Unit Tests
65
- run: smoo github-ci nx-smart --target test --name "Unit Tests" --step 8
66
-
67
- # Step 9
68
- # Nx's database cache needs artifact files and .nx/workspace-data DB
69
- # metadata restored together; GitHub Actions cache is only the archive
70
- # transport. Save runs only after build/lint/test succeed on the default
71
- # branch, so PRs may restore shared cache but cannot publish it.
72
- - name: ๐Ÿ’พ Save Nx cache
73
- if:
74
- ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}',
75
- github.event.repository.default_branch) && steps.nx-cache.outputs.cache-hit != 'true' }}
76
- uses: actions/cache/save@v5.0.5
77
- with:
78
- path: |
79
- .nx/cache
80
- .nx/workspace-data/*.db*
81
- key: ${{ runner.os }}-nx-db-v1-${{ github.sha }}
82
-
83
- # Step 10
84
- - name: ๐Ÿ“Ž Upload trace DBs
85
- if: ${{ always() }}
86
- uses: actions/upload-artifact@v7.0.1
87
- with:
88
- name: trace-results-${{ github.run_id }}
89
- path: packages/*/.trace-results.db
90
- if-no-files-found: ignore
91
- retention-days: 14
92
- include-hidden-files: true
93
-
94
- # --- Cleanup ------------------------------------------------------------
95
-
96
- # Step 11
97
- - name: ๐Ÿงน Cleanup and cache Nix/devenv
98
- if: ${{ always() }}
99
- uses: ./.github/actions/save-nix-devenv
100
- with:
101
- nix-cache-hit: ${{ steps.setup.outputs.nix-cache-hit }}
102
- devenv-cache-hit: ${{ steps.setup.outputs.devenv-cache-hit }}