@smoothbricks/cli 0.10.8 → 0.10.10

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 (45) hide show
  1. package/dist/cli.js +7 -5
  2. package/dist/github-ci/index.d.ts +9 -7
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +36 -35
  5. package/dist/monorepo/ci-workflow.d.ts +3 -0
  6. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  7. package/dist/monorepo/ci-workflow.js +59 -15
  8. package/dist/monorepo/managed-files.d.ts +1 -0
  9. package/dist/monorepo/managed-files.d.ts.map +1 -1
  10. package/dist/monorepo/managed-files.js +15 -5
  11. package/dist/monorepo/publish-workflow.d.ts +2 -2
  12. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  13. package/dist/monorepo/publish-workflow.js +7 -7
  14. package/dist/release/index.d.ts +1 -0
  15. package/dist/release/index.d.ts.map +1 -1
  16. package/dist/release/index.js +14 -6
  17. package/dist/wrangler/cloudflare.d.ts +4 -2
  18. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  19. package/dist/wrangler/cloudflare.js +6 -3
  20. package/dist/wrangler/{deploy-environment.d.ts → deploy-stage.d.ts} +6 -6
  21. package/dist/wrangler/deploy-stage.d.ts.map +1 -0
  22. package/dist/wrangler/{deploy-environment.js → deploy-stage.js} +26 -26
  23. package/dist/wrangler/stage.d.ts +58 -0
  24. package/dist/wrangler/stage.d.ts.map +1 -0
  25. package/dist/wrangler/{environment.js → stage.js} +44 -44
  26. package/package.json +8 -8
  27. package/src/cli.ts +11 -8
  28. package/src/github-ci/index.test.ts +105 -34
  29. package/src/github-ci/index.ts +53 -46
  30. package/src/monorepo/__tests__/ci-workflow.test.ts +87 -49
  31. package/src/monorepo/__tests__/publish-workflow.test.ts +14 -12
  32. package/src/monorepo/ci-workflow.ts +67 -14
  33. package/src/monorepo/managed-files.test.ts +26 -5
  34. package/src/monorepo/managed-files.ts +18 -5
  35. package/src/monorepo/publish-workflow.ts +15 -12
  36. package/src/release/index.ts +12 -4
  37. package/src/wrangler/cloudflare.test.ts +76 -0
  38. package/src/wrangler/cloudflare.ts +6 -3
  39. package/src/wrangler/{deploy-environment.test.ts → deploy-stage.test.ts} +11 -11
  40. package/src/wrangler/{deploy-environment.ts → deploy-stage.ts} +41 -41
  41. package/src/wrangler/{environment.test.ts → stage.test.ts} +15 -15
  42. package/src/wrangler/{environment.ts → stage.ts} +49 -52
  43. package/dist/wrangler/deploy-environment.d.ts.map +0 -1
  44. package/dist/wrangler/environment.d.ts +0 -58
  45. package/dist/wrangler/environment.d.ts.map +0 -1
@@ -7,12 +7,7 @@ import typia from 'typia';
7
7
  import { parseStringArrayText } from '../lib/json.js';
8
8
  import { decode, run, runStatus } from '../lib/run.js';
9
9
  import { type ProjectTargets, readProjectTargets } from '../nx/index.js';
10
- import {
11
- type EnvironmentToken,
12
- isPullRequestEnvironment,
13
- parseEnvironmentToken,
14
- pullRequestEnvironment,
15
- } from '../wrangler/environment.js';
10
+ import { type DeploymentStage, isPullRequestStage, parseDeploymentStage, pullRequestStage } from '../wrangler/stage.js';
16
11
  import type { NxTargetRun } from './outputs.js';
17
12
 
18
13
  export interface GithubActionsEventPayload {
@@ -159,24 +154,32 @@ async function addReferencesFrom(roots: Set<string>, path: string, cwd: string):
159
154
  }
160
155
  }
161
156
 
162
- export function nxSmartArgs(target: string, mode: 'affected' | 'run-many', configuration?: string): string[] {
157
+ export function nxSmartArgs(
158
+ target: string,
159
+ mode: 'affected' | 'run-many',
160
+ configuration?: string,
161
+ stage?: string,
162
+ ): string[] {
163
163
  const args = [mode, '-t', target];
164
164
  if (configuration) {
165
165
  args.push(`--configuration=${configuration}`);
166
166
  }
167
+ if (stage) {
168
+ args.push(`--stage=${stage}`);
169
+ }
167
170
  args.push(`--exclude=tag:ci:skip:${target}`, `--parallel=${NX_PARALLEL}`);
168
171
  return args;
169
172
  }
170
173
 
171
174
  export async function githubCiNxSmart(
172
175
  root: string,
173
- options: { target: string; name?: string; step?: string; mode?: NxSmartMode; configuration?: string },
176
+ options: { target: string; name?: string; step?: string; mode?: NxSmartMode; configuration?: string; stage?: string },
174
177
  ): Promise<void> {
175
178
  const name = options.name ?? options.target;
176
179
  const step = options.step ?? '';
177
180
  await createGithubStatus(name, step);
178
181
  const mode = resolveNxSmartMode(options.mode ?? 'auto');
179
- const nxArgs = nxSmartArgs(options.target, mode, options.configuration);
182
+ const nxArgs = nxSmartArgs(options.target, mode, options.configuration, options.stage);
180
183
  const status = await runStatus('nx', nxArgs, root);
181
184
  await updateGithubStatus(name, status === 0 ? 'success' : 'failure', step);
182
185
  if (status !== 0) {
@@ -315,7 +318,7 @@ export async function readGitHeadSha(root: string): Promise<string> {
315
318
  return decode((await $`git rev-parse HEAD`.cwd(root).quiet()).stdout).trim();
316
319
  }
317
320
 
318
- export async function githubCiNxRunMany(root: string, options: NxRunManyOptions): Promise<void> {
321
+ export async function githubCiNxRunMany(root: string, options: NxRunManyOptions): Promise<ExpandedNxTargetRuns> {
319
322
  const expanded = expandNxTargetRuns(await readProjectTargets(root), options);
320
323
  if (expanded.unmatchedGlobs.length > 0) {
321
324
  console.log(`No Nx targets matched target glob(s): ${expanded.unmatchedGlobs.join(', ')}; skipping.`);
@@ -328,6 +331,7 @@ export async function githubCiNxRunMany(root: string, options: NxRunManyOptions)
328
331
  const sourceSha = await readGitHeadSha(root);
329
332
  await collectNxOutputs(root, options.collectOutputs, expandNxTargetDependencyRuns(expanded.runs), sourceSha);
330
333
  }
334
+ return expanded;
331
335
  }
332
336
 
333
337
  export async function githubCiApplyOutputs(
@@ -380,7 +384,7 @@ function commaSeparatedValues(value: string): string[] {
380
384
  }
381
385
 
382
386
  export interface GithubCiNxDeployOptions {
383
- environment?: string;
387
+ stage?: string;
384
388
  mode?: NxSmartMode;
385
389
  name?: string;
386
390
  step?: string;
@@ -391,7 +395,8 @@ export interface GithubCiNxDeployDependencies {
391
395
  listProjects?: (root: string, target: string, mode: 'affected' | 'run-many') => Promise<string[]>;
392
396
  runNx?: (args: string[], root: string) => Promise<number>;
393
397
  appendSummary?: (summaryPath: string, content: string) => Promise<void>;
394
- publishDeployment?: (environment: `pr${number}`, url: string) => Promise<void>;
398
+ appendOutput?: (outputPath: string, content: string) => Promise<void>;
399
+ publishDeployment?: (stage: `pr${number}`, url: string) => Promise<void>;
395
400
  setStatus?: (state: 'pending' | 'success' | 'failure') => Promise<void>;
396
401
  processEnv?: NodeJS.ProcessEnv;
397
402
  eventPayload?: GithubActionsEventPayload;
@@ -404,8 +409,8 @@ export async function githubCiNxDeploy(
404
409
  ): Promise<void> {
405
410
  const processEnv = dependencies.processEnv ?? process.env;
406
411
  const eventPayload = dependencies.eventPayload ?? readGithubActionsEvent(processEnv);
407
- const environment = resolveDeploymentEnvironment(options.environment, processEnv, eventPayload);
408
- const name = options.name ?? 'Deploy Environment';
412
+ const stage = resolveDeploymentStage(options.stage, processEnv, eventPayload);
413
+ const name = options.name ?? 'Deploy Stage';
409
414
  const step = options.step ?? '';
410
415
  const setStatus =
411
416
  dependencies.setStatus ??
@@ -417,7 +422,7 @@ export async function githubCiNxDeploy(
417
422
  const runNx = dependencies.runNx ?? ((args: string[], commandRoot: string) => runStatus('nx', args, commandRoot));
418
423
  const projects = await listProjects(root, 'deploy', mode);
419
424
  if (projects.length === 0) {
420
- console.log(`No ${mode} deploy projects; skipping ${environment}.`);
425
+ console.log(`No ${mode} deploy projects; skipping ${stage}.`);
421
426
  await setStatus('success');
422
427
  return;
423
428
  }
@@ -434,7 +439,7 @@ export async function githubCiNxDeploy(
434
439
  `--parallel=${NX_PARALLEL}`,
435
440
  ];
436
441
  if (target === 'deploy') {
437
- nxArgs.push(`--environment=${environment}`);
442
+ nxArgs.push(`--stage=${stage}`);
438
443
  }
439
444
  const status = await runNx(nxArgs, root);
440
445
  if (status !== 0) {
@@ -443,37 +448,39 @@ export async function githubCiNxDeploy(
443
448
  }
444
449
  }
445
450
 
446
- if (isPullRequestEnvironment(environment)) {
447
- const url = `https://app.${environment}.conloca.com`;
451
+ if (isPullRequestStage(stage)) {
452
+ const url = `https://app.${stage}.conloca.com`;
448
453
  const summaryPath = processEnv.GITHUB_STEP_SUMMARY;
449
454
  if (summaryPath) {
450
455
  const appendSummary = dependencies.appendSummary ?? appendFile;
451
- await appendSummary(summaryPath, `## ${environment} deployment\n\n[View deployment](${url})\n`);
456
+ await appendSummary(
457
+ summaryPath,
458
+ `## ${stage} deployment
459
+
460
+ [View deployment](${url})
461
+ `,
462
+ );
452
463
  }
453
464
  const publishDeployment =
454
465
  dependencies.publishDeployment ??
455
466
  ((token: `pr${number}`, deploymentUrl: string) => publishGithubDeployment(token, deploymentUrl, processEnv));
456
- await publishDeployment(environment, url);
457
- }
458
- if (environment !== 'production') {
459
- const e2eProjects = await listProjects(root, 'e2e-deployed', 'run-many');
460
- if (e2eProjects.length > 0) {
461
- const nxArgs = [
462
- 'run-many',
463
- '-t',
464
- 'e2e-deployed',
465
- `--projects=${e2eProjects.join(',')}`,
466
- `--parallel=${NX_PARALLEL}`,
467
- `--environment=${environment}`,
468
- ];
469
- const status = await runNx(nxArgs, root);
470
- if (status !== 0) {
471
- await setStatus('failure');
472
- throw new Error(`nx ${nxArgs.join(' ')} failed with exit code ${status}`);
473
- }
467
+ await publishDeployment(stage, url);
468
+ }
469
+
470
+ const outputPath = processEnv.GITHUB_OUTPUT;
471
+ if (outputPath) {
472
+ try {
473
+ const appendOutput = dependencies.appendOutput ?? appendFile;
474
+ await appendOutput(
475
+ outputPath,
476
+ `stage=${stage}
477
+ `,
478
+ );
479
+ } catch (error) {
480
+ await setStatus('failure');
481
+ throw error;
474
482
  }
475
483
  }
476
-
477
484
  await setStatus('success');
478
485
  }
479
486
 
@@ -508,12 +515,12 @@ function eventDefaultBranch(): string | undefined {
508
515
  }
509
516
  }
510
517
 
511
- export function resolveDeploymentEnvironment(
518
+ export function resolveDeploymentStage(
512
519
  explicit: string | undefined,
513
520
  environment: NodeJS.ProcessEnv,
514
521
  event: GithubActionsEventPayload | undefined,
515
- ): EnvironmentToken {
516
- if (explicit !== undefined) return parseEnvironmentToken(explicit);
522
+ ): DeploymentStage {
523
+ if (explicit !== undefined) return parseDeploymentStage(explicit);
517
524
  if (environment.GITHUB_EVENT_NAME === 'pull_request') {
518
525
  if (!event?.action || !['opened', 'reopened', 'synchronize'].includes(event.action)) {
519
526
  throw new Error('Pull-request deployment runs only for opened, reopened, or synchronize events.');
@@ -525,7 +532,7 @@ export function resolveDeploymentEnvironment(
525
532
  }
526
533
  const number = event.pull_request?.number;
527
534
  if (number === undefined) throw new Error('GitHub pull_request event is missing its PR number.');
528
- return pullRequestEnvironment(number);
535
+ return pullRequestStage(number);
529
536
  }
530
537
  if (environment.GITHUB_EVENT_NAME === 'push' && environment.GITHUB_REF_NAME === 'private') {
531
538
  return 'staging';
@@ -533,7 +540,7 @@ export function resolveDeploymentEnvironment(
533
540
  if (environment.GITHUB_EVENT_NAME === 'release') {
534
541
  return 'production';
535
542
  }
536
- throw new Error('Cannot resolve a deployment environment from this GitHub event; pass --environment explicitly.');
543
+ throw new Error('Cannot resolve a deployment stage from this GitHub event; pass --stage explicitly.');
537
544
  }
538
545
 
539
546
  function readGithubActionsEvent(environment: NodeJS.ProcessEnv): GithubActionsEventPayload | undefined {
@@ -559,7 +566,7 @@ async function listNxProjectsWithTarget(
559
566
  const result = await $`nx ${listArgs}`.cwd(root).quiet();
560
567
  const candidates = nxProjectList(decode(result.stdout)).sort((left, right) => left.localeCompare(right));
561
568
  if (target !== 'deploy') return candidates;
562
- return selectEnvironmentDeployProjects(candidates, async (project) => {
569
+ return selectStageDeployProjects(candidates, async (project) => {
563
570
  const projectResult = await $`nx show project ${project} --json`.cwd(root).quiet();
564
571
  const parsed = parseNxProjectDeployTarget(decode(projectResult.stdout));
565
572
  if (!parsed) throw new Error(`nx show project ${project} returned invalid JSON.`);
@@ -567,7 +574,7 @@ async function listNxProjectsWithTarget(
567
574
  });
568
575
  }
569
576
 
570
- export async function selectEnvironmentDeployProjects(
577
+ export async function selectStageDeployProjects(
571
578
  candidates: string[],
572
579
  loadProject: (project: string) => Promise<unknown>,
573
580
  ): Promise<string[]> {
@@ -577,7 +584,7 @@ export async function selectEnvironmentDeployProjects(
577
584
  if (!isNxProjectDeployTarget(definition)) continue;
578
585
  const deploy = definition.targets?.deploy;
579
586
  const commandValue = deploy?.options?.command ?? deploy?.command;
580
- if (typeof commandValue === 'string' && commandValue.includes('smoo wrangler deploy-environment')) {
587
+ if (typeof commandValue === 'string' && commandValue.includes('smoo wrangler deploy-stage')) {
581
588
  selected.push(project);
582
589
  }
583
590
  }
@@ -3,58 +3,109 @@
3
3
  import { describe, expect, it } from 'bun:test';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
- import { CiWorkflowStepKind, defineCiWorkflow, renderCiWorkflowYaml } from '../ci-workflow.js';
6
+ import {
7
+ type CiWorkflowDefinitionOptions,
8
+ CiWorkflowStepKind,
9
+ defineCiWorkflow,
10
+ renderCiWorkflowYaml,
11
+ } from '../ci-workflow.js';
7
12
 
8
13
  const nixosRunsOn = ['nixos-latest-x64', 'self-hosted'] as const;
9
14
 
15
+ function options(overrides: Partial<CiWorkflowDefinitionOptions> = {}): CiWorkflowDefinitionOptions {
16
+ return {
17
+ deploy: false,
18
+ browserTests: false,
19
+ e2eDeployment: false,
20
+ pushBranches: ['main'],
21
+ ...overrides,
22
+ };
23
+ }
24
+
10
25
  describe('CI workflow definition', () => {
11
26
  it('renders the checked-in local CI workflow copy', async () => {
12
- const rendered = renderCiWorkflowYaml({
13
- deploy: false,
14
- pushBranches: ['main'],
15
- runsOn: [...nixosRunsOn],
16
- });
27
+ const rendered = renderCiWorkflowYaml(options({ runsOn: [...nixosRunsOn] }));
17
28
  const packageRoot = join(import.meta.dir, '..', '..', '..');
18
29
 
19
30
  await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/ci.yml'), 'utf8')).resolves.toBe(rendered);
20
31
  });
21
32
 
22
- it('inserts deploy after tests and renumbers following deeplink steps', () => {
23
- const steps = defineCiWorkflow({ deploy: true, pushBranches: ['main'] });
24
- const rendered = renderCiWorkflowYaml({ deploy: true, pushBranches: ['main'] });
33
+ it('deploys immediately after build and renumbers following steps', () => {
34
+ const definition = options({ deploy: true, browserTests: true });
35
+ const steps = defineCiWorkflow(definition);
36
+ const rendered = renderCiWorkflowYaml(definition);
25
37
 
26
- expect(steps.map((step) => [step.kind, step.number])).toContainEqual([CiWorkflowStepKind.Deploy, 11]);
27
- expect(rendered.match(/- name: 🚀 Deploy Environment/g)).toHaveLength(1);
28
- expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
29
- expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
30
- expect(rendered).toContain('smoo github-ci nx-deploy --mode run-many --name "Deploy Environment" --step 11');
38
+ expect(steps.map((step) => [step.kind, step.number])).toEqual([
39
+ [CiWorkflowStepKind.Checkout, 2],
40
+ [CiWorkflowStepKind.SetupDevenv, 3],
41
+ [CiWorkflowStepKind.SetNxShas, 4],
42
+ [CiWorkflowStepKind.RestoreNxCache, 5],
43
+ [CiWorkflowStepKind.Build, 6],
44
+ [CiWorkflowStepKind.Deploy, 7],
45
+ [CiWorkflowStepKind.Lint, 8],
46
+ [CiWorkflowStepKind.UnitTests, 9],
47
+ [CiWorkflowStepKind.BrowserTests, 10],
48
+ [CiWorkflowStepKind.ManagedFilesCheck, 11],
49
+ [CiWorkflowStepKind.ManagedFilesDispatch, 12],
50
+ [CiWorkflowStepKind.SaveNxCache, 13],
51
+ [CiWorkflowStepKind.UploadTraceDbs, 14],
52
+ [CiWorkflowStepKind.SaveNixDevenv, 15],
53
+ ]);
54
+ expect(rendered.match(/- name: 🚀 Deploy Stage/g)).toHaveLength(1);
55
+ expect(rendered).toContain('id: deploy');
56
+ expect(rendered).toContain('smoo github-ci nx-deploy --mode run-many --name "Deploy Stage" --step 7');
57
+ expect(rendered).toContain('smoo github-ci nx-smart --target test-browser --name "Browser Tests" --step 10');
58
+ expect(rendered).toContain('group: ${{ github.workflow }}-${{ github.ref }}');
59
+ expect(rendered).toContain('cancel-in-progress: true');
31
60
  expect(rendered).toContain('github.event.pull_request.head.repo.full_name == github.repository');
32
61
  expect(rendered).toContain("github.ref == 'refs/heads/private'");
33
- expect(rendered).toContain("# Step 12\n # Nx's database cache needs artifact files");
34
- expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
35
- expect(rendered).toContain('id: setup');
36
- expect(rendered).not.toContain('Setup Nix/devenv (fork)');
37
- expect(rendered).not.toContain('Setup Nix/devenv (nixos)');
38
- expect(rendered).not.toContain('github-actions-bootstrap.sh');
39
- expect(rendered).toContain('uses: ./.github/actions/save-nix-devenv');
40
- expect(rendered).toContain('runs-on: ubuntu-latest');
62
+ expect(rendered).toContain("# Step 13\n # Nx's database cache needs artifact files");
41
63
  });
42
64
 
43
- it('adds Cloudflare credentials for Wrangler-backed deploys', () => {
44
- const rendered = renderCiWorkflowYaml({ deploy: true, deployProvider: 'cloudflare', pushBranches: ['main'] });
65
+ it('adds only generic Cloudflare credentials for Wrangler-backed deploys', () => {
66
+ const rendered = renderCiWorkflowYaml(options({ deploy: true, deployProvider: 'cloudflare' }));
45
67
 
46
68
  expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
47
69
  expect(rendered).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
48
- expect(rendered).toContain('GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }}');
49
- expect(rendered).toContain('GITHUB_APP_PRIVATE_KEY: ${{ secrets.GITHUB_APP_PRIVATE_KEY }}');
50
- expect(rendered).toContain('GITHUB_APP_PRIVATE_KEY_PEM: ${{ secrets.GITHUB_APP_PRIVATE_KEY_PEM }}');
51
- expect(rendered).toContain('OAUTH_STATE_SIGNING_KEY: ${{ secrets.OAUTH_STATE_SIGNING_KEY }}');
52
- expect(rendered).toContain('MAIL_CAPTURE_CONTROL_TOKEN: ${{ secrets.MAIL_CAPTURE_CONTROL_TOKEN }}');
53
- expect(rendered).toContain('TOKEN_ENCRYPTION_KEY: ${{ secrets.TOKEN_ENCRYPTION_KEY }}');
70
+ for (const appSecret of [
71
+ 'GITHUB_CLIENT_SECRET',
72
+ 'GITHUB_APP_PRIVATE_KEY',
73
+ 'GITHUB_APP_PRIVATE_KEY_PEM',
74
+ 'OAUTH_STATE_SIGNING_KEY',
75
+ 'MAIL_CAPTURE_CONTROL_TOKEN',
76
+ 'TOKEN_ENCRYPTION_KEY',
77
+ 'E2E_CONTROL_TOKEN',
78
+ ]) {
79
+ expect(rendered).not.toContain(appSecret);
80
+ }
81
+ });
82
+
83
+ it('renders deployment E2E as a dependent job with an independent stage input', () => {
84
+ const rendered = renderCiWorkflowYaml(options({ deploy: true, e2eDeployment: true, runsOn: [...nixosRunsOn] }));
85
+
86
+ expect(rendered).toContain('deployment-stage: ${{ steps.deploy.outputs.stage }}');
87
+ expect(rendered).toContain(' e2e-deployment:\n name: Deployment E2E\n needs: main');
88
+ expect(rendered).toContain(
89
+ "if: ${{ needs.main.result == 'success' && needs.main.outputs.deployment-stage != '' }}",
90
+ );
91
+ expect(rendered).toContain('timeout-minutes: 15');
92
+ expect(rendered).toContain(
93
+ 'smoo github-ci nx-smart --target e2e-deployment --mode run-many --stage "${{ needs.main.outputs.deployment-stage }}" --name "Deployment E2E" --step 4',
94
+ );
95
+ expect(rendered.match(/name: Deployment E2E/g)).toHaveLength(2);
96
+ expect(rendered).not.toContain('E2E_CONTROL_TOKEN');
97
+ });
98
+
99
+ it('omits optional browser and deployment-E2E lanes when disabled', () => {
100
+ const rendered = renderCiWorkflowYaml(options({ deploy: true }));
101
+
102
+ expect(rendered).not.toContain('--target test-browser');
103
+ expect(rendered).not.toContain(' e2e-deployment:');
104
+ expect(rendered).not.toContain('deployment-stage:');
54
105
  });
55
106
 
56
107
  it('uses the same architecture-scoped key to restore and save the Nx cache', async () => {
57
- const rendered = renderCiWorkflowYaml({ deploy: false, pushBranches: ['main'] });
108
+ const rendered = renderCiWorkflowYaml(options());
58
109
  const packageRoot = join(import.meta.dir, '..', '..', '..');
59
110
  const restoreAction = await readFile(join(packageRoot, '..', '..', '.github/actions/cache-nx/action.yml'), 'utf8');
60
111
  const restoreKey = restoreAction.match(/^\s*key: (.+)$/m)?.[1];
@@ -64,26 +115,13 @@ describe('CI workflow definition', () => {
64
115
  expect(saveKey).toBe(restoreKey);
65
116
  });
66
117
 
67
- it('nixos config: fork PRs on ubuntu; one setup-devenv (composite owns host path)', () => {
68
- const rendered = renderCiWorkflowYaml({
69
- deploy: false,
70
- pushBranches: ['main'],
71
- runsOn: [...nixosRunsOn],
72
- });
118
+ it('nixos config gates both jobs away from private runners for fork PRs', () => {
119
+ const rendered = renderCiWorkflowYaml(options({ deploy: true, e2eDeployment: true, runsOn: [...nixosRunsOn] }));
120
+ const runnerExpression =
121
+ "runs-on:\n ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&\n fromJSON('[\"nixos-latest-x64\",\"self-hosted\"]') || 'ubuntu-latest' }}";
73
122
 
74
- expect(rendered).toContain(
75
- "runs-on:\n ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&\n fromJSON('[\"nixos-latest-x64\",\"self-hosted\"]') || 'ubuntu-latest' }}",
76
- );
123
+ expect(rendered.match(new RegExp(runnerExpression.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(2);
77
124
  expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
78
- expect(rendered).toContain('id: setup');
79
- expect(rendered).not.toContain('Setup Nix/devenv (fork)');
80
- expect(rendered).not.toContain('Setup Nix/devenv (nixos)');
81
125
  expect(rendered).not.toContain('github-actions-bootstrap.sh');
82
- expect(rendered).not.toContain('determinate-nix-action');
83
- expect(rendered).toContain('if: always()');
84
- expect(rendered).toContain('uses: ./.github/actions/save-nix-devenv');
85
- // workflow yaml does not embed composite internals
86
- expect(rendered).not.toContain('cache-node-modules');
87
- expect(rendered).not.toContain('cache-ttsc-plugins');
88
126
  });
89
127
  });
@@ -159,15 +159,17 @@ describe('publish workflow definition', () => {
159
159
  expect(foldedRunCommand(macosPlatform, '🍎 Build selected macOS and iOS release outputs')).toBe(
160
160
  `smoo release build-platform-outputs --bump "\${{ inputs.bump }}" --ref "\${{ github.sha }}" --targets "${MACOS_PLATFORM_TARGET_GLOBS.join(
161
161
  ',',
162
- )}" --output "\${{ runner.temp }}/macos-platform-outputs"`,
162
+ )}" --output "\${{ runner.temp }}/macos-platform-outputs" --github-output "$GITHUB_OUTPUT"`,
163
163
  );
164
+ expect(macosPlatform).toContain('id: platform-outputs');
165
+ expect(macosPlatform).toContain("if: steps.platform-outputs.outputs.projects != ''");
164
166
  expect(macosPlatform).toContain(
165
- `run: smoo github-ci nx-run-many --targets test --projects-with-targets "${MACOS_PLATFORM_TARGET_GLOBS.join(',')}"`,
167
+ 'run: smoo github-ci nx-run-many --targets test --projects "${{ steps.platform-outputs.outputs.projects }}"',
166
168
  );
167
169
  expect(macosPlatform.indexOf('- name: 🍎 Build selected macOS and iOS release outputs')).toBeLessThan(
168
- macosPlatform.indexOf('- name: 🧪 Unit test macOS and iOS packages'),
170
+ macosPlatform.indexOf('- name: 🧪 Unit test selected macOS and iOS packages'),
169
171
  );
170
- expect(macosPlatform.indexOf('- name: 🧪 Unit test macOS and iOS packages')).toBeLessThan(
172
+ expect(macosPlatform.indexOf('- name: 🧪 Unit test selected macOS and iOS packages')).toBeLessThan(
171
173
  macosPlatform.indexOf('- name: 📤 Upload macOS platform outputs'),
172
174
  );
173
175
  expect(finalJob).not.toContain('smoo release version');
@@ -230,7 +232,7 @@ describe('publish workflow definition', () => {
230
232
  );
231
233
  expect(rendered).not.toContain('GITHUB_SHA:');
232
234
  expect(finalJob).toContain("needs.linux-release-candidate.outputs.mode != 'none'");
233
- expect(finalJob).toContain("inputs.deploy_environment == 'production'");
235
+ expect(finalJob).toContain("inputs.deploy_stage == 'production'");
234
236
  expect(finalJob).toContain("inputs.dry_run != 'true'");
235
237
  expect(finalJob).toContain('smoo release publish --bump "${{ inputs.bump }}" --dry-run "${{ inputs.dry_run }}"');
236
238
  expect(finalJob).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
@@ -248,7 +250,7 @@ describe('publish workflow definition', () => {
248
250
  it('omits production deploy controls when no production deploy target exists', () => {
249
251
  const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
250
252
 
251
- expect(rendered).not.toContain('deploy_environment');
253
+ expect(rendered).not.toContain('deploy_stage');
252
254
  expect(rendered).not.toContain('Deploy production');
253
255
  expect(rendered).not.toContain('nx-deploy');
254
256
  });
@@ -256,12 +258,12 @@ describe('publish workflow definition', () => {
256
258
  it('renders production deploy controls for repos with production deploy targets', () => {
257
259
  const rendered = renderPublishWorkflowYaml({ deploy: true, repoName: '@smoothbricks/codebase' });
258
260
 
259
- expect(rendered).toContain('deploy_environment:');
261
+ expect(rendered).toContain('deploy_stage:');
260
262
  expect(rendered).toContain('- name: 🚀 Deploy production');
261
263
  expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
262
264
  expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
263
265
  expect(rendered).toContain(
264
- 'smoo github-ci nx-deploy --environment production --mode run-many --verify --name "Deploy Production"',
266
+ 'smoo github-ci nx-deploy --stage production --mode run-many --verify --name "Deploy Production"',
265
267
  );
266
268
  });
267
269
 
@@ -282,7 +284,7 @@ describe('publish workflow definition', () => {
282
284
  repairs: [],
283
285
  current: [],
284
286
  bump: 'auto',
285
- deployEnvironment: 'production',
287
+ deployStage: 'production',
286
288
  dryRun: false,
287
289
  version: { mode: 'new', projects: ['app'] },
288
290
  }).run();
@@ -291,7 +293,7 @@ describe('publish workflow definition', () => {
291
293
  repairs: [],
292
294
  current: [],
293
295
  bump: 'auto',
294
- deployEnvironment: 'production',
296
+ deployStage: 'production',
295
297
  dryRun: true,
296
298
  version: { mode: 'new', projects: ['app'] },
297
299
  }).run();
@@ -420,7 +422,7 @@ interface WorkflowScenarioConfig {
420
422
  repairs: ReleaseGap[];
421
423
  current: ReleaseGap[];
422
424
  bump: PublishWorkflowBump;
423
- deployEnvironment?: 'none' | 'production';
425
+ deployStage?: 'none' | 'production';
424
426
  dryRun: boolean;
425
427
  version: PublishWorkflowVersionOutputs;
426
428
  }
@@ -446,7 +448,7 @@ function publishWorkflowScenario(config: WorkflowScenarioConfig): { run(): Promi
446
448
  async run() {
447
449
  const state = new WorkflowScenarioState(config);
448
450
  await runPublishWorkflow(definePublishWorkflow({ deploy: config.deploy, repoName: config.repoName }), {
449
- inputs: { bump: config.bump, deployEnvironment: config.deployEnvironment ?? 'none', dryRun: config.dryRun },
451
+ inputs: { bump: config.bump, deployStage: config.deployStage ?? 'none', dryRun: config.dryRun },
450
452
  callbacks: state.callbacks(),
451
453
  });
452
454
  return state.outcome();
@@ -8,6 +8,7 @@ export enum CiWorkflowStepKind {
8
8
  SetNxShas = 'set-nx-shas',
9
9
  RestoreNxCache = 'restore-nx-cache',
10
10
  Build = 'build',
11
+ BrowserTests = 'browser-tests',
11
12
  Lint = 'lint',
12
13
  UnitTests = 'unit-tests',
13
14
  ManagedFilesCheck = 'managed-files-check',
@@ -26,6 +27,8 @@ export interface CiWorkflowStep {
26
27
 
27
28
  export interface CiWorkflowDefinitionOptions {
28
29
  deploy: boolean;
30
+ browserTests: boolean;
31
+ e2eDeployment: boolean;
29
32
  deployProvider?: 'cloudflare';
30
33
  pushBranches: string[];
31
34
  /** Default ubuntu-latest when omitted. */
@@ -41,15 +44,20 @@ export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkfl
41
44
  { kind: CiWorkflowStepKind.SetNxShas, name: '🧭 Set Nx SHAs' },
42
45
  { kind: CiWorkflowStepKind.RestoreNxCache, name: '🧠 Restore Nx cache' },
43
46
  { kind: CiWorkflowStepKind.Build, name: '🔨 Build' },
44
- { kind: CiWorkflowStepKind.Lint, name: '🔍 Lint' },
45
- { kind: CiWorkflowStepKind.UnitTests, name: '🧪 Unit Tests' },
46
- { kind: CiWorkflowStepKind.ManagedFilesCheck, name: '🩺 Check managed-file drift' },
47
- { kind: CiWorkflowStepKind.ManagedFilesDispatch, name: '🔁 Dispatch managed-file drift healing' },
48
47
  ];
49
48
  if (options.deploy) {
50
- steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Environment' });
49
+ steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Stage' });
51
50
  }
52
51
  steps.push(
52
+ { kind: CiWorkflowStepKind.Lint, name: '🔍 Lint' },
53
+ { kind: CiWorkflowStepKind.UnitTests, name: '🧪 Unit Tests' },
54
+ );
55
+ if (options.browserTests) {
56
+ steps.push({ kind: CiWorkflowStepKind.BrowserTests, name: '🌐 Browser Tests' });
57
+ }
58
+ steps.push(
59
+ { kind: CiWorkflowStepKind.ManagedFilesCheck, name: '🩺 Check managed-file drift' },
60
+ { kind: CiWorkflowStepKind.ManagedFilesDispatch, name: '🔁 Dispatch managed-file drift healing' },
53
61
  { kind: CiWorkflowStepKind.SaveNxCache, name: '💾 Save Nx cache' },
54
62
  { kind: CiWorkflowStepKind.UploadTraceDbs, name: '📎 Upload trace DBs' },
55
63
  { kind: CiWorkflowStepKind.SaveNixDevenv, name: '🧹 Cleanup and cache Nix/devenv' },
@@ -59,7 +67,7 @@ export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkfl
59
67
 
60
68
  export function renderCiWorkflowYaml(options: CiWorkflowDefinitionOptions): string {
61
69
  const steps = defineCiWorkflow(options);
62
- return `${renderCiWorkflowHeader(options)}${renderCiWorkflowSteps(steps, options)}`;
70
+ return `${renderCiWorkflowHeader(options)}${renderCiWorkflowSteps(steps, options)}${renderE2eDeploymentJob(options)}`;
63
71
  }
64
72
 
65
73
  function renderCiWorkflowHeader(options: CiWorkflowDefinitionOptions): string {
@@ -77,6 +85,10 @@ permissions:
77
85
  contents: read
78
86
  ${options.deploy ? ' deployments: write\n' : ''} statuses: write
79
87
 
88
+ concurrency:
89
+ group: \${{ github.workflow }}-\${{ github.ref }}
90
+ cancel-in-progress: true
91
+
80
92
  defaults:
81
93
  run:
82
94
  working-directory: tooling/direnv
@@ -86,7 +98,13 @@ jobs:
86
98
  name: Validate
87
99
  ${renderRunsOnLine(options.runsOn)}
88
100
  timeout-minutes: 45
89
- env:
101
+ ${
102
+ options.e2eDeployment
103
+ ? ` outputs:
104
+ deployment-stage: ${githubExpression('steps.deploy.outputs.stage')}
105
+ `
106
+ : ''
107
+ } env:
90
108
  NIX_STORE_NAR: ${githubExpression('github.workspace')}/nix-store.nar
91
109
  GH_TOKEN: ${githubExpression('github.token')}
92
110
  steps:
@@ -169,6 +187,8 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
169
187
  return [` - name: ${step.name}`, ' id: nx-cache', ' uses: ./.github/actions/cache-nx'];
170
188
  case CiWorkflowStepKind.Build:
171
189
  return nxSmartStep(step, 'build', 'Build');
190
+ case CiWorkflowStepKind.BrowserTests:
191
+ return nxSmartStep(step, 'test-browser', 'Browser Tests');
172
192
  case CiWorkflowStepKind.Lint:
173
193
  return nxSmartStep(step, 'lint', 'Lint');
174
194
  case CiWorkflowStepKind.UnitTests:
@@ -197,6 +217,7 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
197
217
  case CiWorkflowStepKind.Deploy:
198
218
  return [
199
219
  ` - name: ${step.name}`,
220
+ ' id: deploy',
200
221
  ' if: >-',
201
222
  ' ${{',
202
223
  " (github.event_name == 'pull_request' &&",
@@ -205,7 +226,7 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
205
226
  " (github.event_name == 'push' && github.ref == 'refs/heads/private')",
206
227
  ' }}',
207
228
  ...deployEnvLines(options),
208
- ` run: smoo github-ci nx-deploy --mode run-many --name "Deploy Environment" --step ${step.number}`,
229
+ ` run: smoo github-ci nx-deploy --mode run-many --name "Deploy Stage" --step ${step.number}`,
209
230
  ];
210
231
  case CiWorkflowStepKind.SaveNxCache:
211
232
  return [
@@ -255,12 +276,6 @@ function deployEnvLines(options: CiWorkflowDefinitionOptions): string[] {
255
276
  ' env:',
256
277
  ' CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}',
257
278
  ' CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}',
258
- ' GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }}',
259
- ' GITHUB_APP_PRIVATE_KEY: ${{ secrets.GITHUB_APP_PRIVATE_KEY }}',
260
- ' GITHUB_APP_PRIVATE_KEY_PEM: ${{ secrets.GITHUB_APP_PRIVATE_KEY_PEM }}',
261
- ' OAUTH_STATE_SIGNING_KEY: ${{ secrets.OAUTH_STATE_SIGNING_KEY }}',
262
- ' MAIL_CAPTURE_CONTROL_TOKEN: ${{ secrets.MAIL_CAPTURE_CONTROL_TOKEN }}',
263
- ' TOKEN_ENCRYPTION_KEY: ${{ secrets.TOKEN_ENCRYPTION_KEY }}',
264
279
  ];
265
280
  }
266
281
 
@@ -275,3 +290,41 @@ function renderYamlList(values: string[], spaces: number): string {
275
290
  const indent = ' '.repeat(spaces);
276
291
  return values.map((value) => `${indent}- ${value}`).join('\n');
277
292
  }
293
+
294
+ function renderE2eDeploymentJob(options: CiWorkflowDefinitionOptions): string {
295
+ if (!options.e2eDeployment) return '';
296
+ return `
297
+
298
+ e2e-deployment:
299
+ name: Deployment E2E
300
+ needs: main
301
+ ${renderRunsOnLine(options.runsOn)}
302
+ timeout-minutes: 15
303
+ if: \${{ needs.main.result == 'success' && needs.main.outputs.deployment-stage != '' }}
304
+ steps:
305
+ # Step 1: GitHub adds "Set up job" automatically
306
+ # Step 2
307
+ - name: 📥 Checkout
308
+ uses: actions/checkout@v6.0.2
309
+ with:
310
+ filter: blob:none
311
+ fetch-depth: 0
312
+
313
+ # Step 3. Composite action internals do not affect top-level job step anchors.
314
+ - name: 🧱 Setup Nix/devenv
315
+ id: setup
316
+ uses: ./.github/actions/setup-devenv
317
+
318
+ # Step 4
319
+ - name: Deployment E2E
320
+ run: smoo github-ci nx-smart --target e2e-deployment --mode run-many --stage "\${{ needs.main.outputs.deployment-stage }}" --name "Deployment E2E" --step 4
321
+
322
+ # Step 5
323
+ - name: 🧹 Cleanup and cache Nix/devenv
324
+ if: always()
325
+ uses: ./.github/actions/save-nix-devenv
326
+ with:
327
+ nix-cache-hit: \${{ steps.setup.outputs.nix-cache-hit }}
328
+ devenv-cache-hit: \${{ steps.setup.outputs.devenv-cache-hit }}
329
+ `;
330
+ }