@smoothbricks/cli 0.10.7 → 0.10.8

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 (56) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +23 -1
  3. package/dist/github-ci/index.d.ts +43 -3
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +219 -34
  6. package/dist/monorepo/ci-workflow.js +16 -6
  7. package/dist/monorepo/managed-files.d.ts.map +1 -1
  8. package/dist/monorepo/managed-files.js +19 -1
  9. package/dist/monorepo/pr-preview-cleanup-workflow.d.ts +5 -0
  10. package/dist/monorepo/pr-preview-cleanup-workflow.d.ts.map +1 -0
  11. package/dist/monorepo/pr-preview-cleanup-workflow.js +38 -0
  12. package/dist/monorepo/publish-workflow.js +2 -2
  13. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  14. package/dist/monorepo/tool-validation.js +85 -5
  15. package/dist/playwright/index.d.ts +22 -0
  16. package/dist/playwright/index.d.ts.map +1 -0
  17. package/dist/playwright/index.js +44 -0
  18. package/dist/release/bootstrap-npm-packages.d.ts +3 -0
  19. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -1
  20. package/dist/release/bootstrap-npm-packages.js +21 -0
  21. package/dist/release/index.d.ts.map +1 -1
  22. package/dist/release/index.js +17 -0
  23. package/dist/wrangler/cloudflare.d.ts +85 -0
  24. package/dist/wrangler/cloudflare.d.ts.map +1 -0
  25. package/dist/wrangler/cloudflare.js +235 -0
  26. package/dist/wrangler/deploy-environment.d.ts +48 -0
  27. package/dist/wrangler/deploy-environment.d.ts.map +1 -0
  28. package/dist/wrangler/deploy-environment.js +383 -0
  29. package/dist/wrangler/environment.d.ts +58 -0
  30. package/dist/wrangler/environment.d.ts.map +1 -0
  31. package/dist/wrangler/environment.js +297 -0
  32. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +3 -4
  33. package/package.json +8 -1
  34. package/src/cli.ts +25 -2
  35. package/src/github-ci/index.test.ts +171 -0
  36. package/src/github-ci/index.ts +272 -30
  37. package/src/monorepo/__tests__/ci-workflow.test.ts +10 -4
  38. package/src/monorepo/__tests__/pr-preview-cleanup-workflow.test.ts +23 -0
  39. package/src/monorepo/__tests__/publish-workflow.test.ts +1 -1
  40. package/src/monorepo/ci-workflow.ts +16 -6
  41. package/src/monorepo/managed-files.test.ts +56 -1
  42. package/src/monorepo/managed-files.ts +20 -1
  43. package/src/monorepo/pr-preview-cleanup-workflow.ts +44 -0
  44. package/src/monorepo/publish-workflow.ts +2 -2
  45. package/src/monorepo/tool-validation.test.ts +85 -0
  46. package/src/monorepo/tool-validation.ts +94 -5
  47. package/src/playwright/index.test.ts +90 -0
  48. package/src/playwright/index.ts +73 -0
  49. package/src/release/__tests__/bootstrap-npm-packages.test.ts +63 -2
  50. package/src/release/bootstrap-npm-packages.ts +34 -0
  51. package/src/release/index.ts +18 -0
  52. package/src/wrangler/cloudflare.ts +289 -0
  53. package/src/wrangler/deploy-environment.test.ts +354 -0
  54. package/src/wrangler/deploy-environment.ts +445 -0
  55. package/src/wrangler/environment.test.ts +173 -0
  56. package/src/wrangler/environment.ts +366 -0
@@ -1,3 +1,4 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import { appendFile, mkdtemp, realpath, rename, rm } from 'node:fs/promises';
3
4
  import { dirname, join } from 'node:path';
@@ -6,24 +7,45 @@ import typia from 'typia';
6
7
  import { parseStringArrayText } from '../lib/json.js';
7
8
  import { decode, run, runStatus } from '../lib/run.js';
8
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';
9
16
  import type { NxTargetRun } from './outputs.js';
10
17
 
11
- interface GithubActionsEventPayload {
18
+ export interface GithubActionsEventPayload {
19
+ action?: string;
12
20
  repository?: {
13
21
  default_branch?: string;
22
+ full_name?: string;
23
+ };
24
+ pull_request?: {
25
+ number?: number;
26
+ head?: {
27
+ repo?: {
28
+ full_name?: string;
29
+ };
30
+ };
14
31
  };
15
32
  }
16
33
 
17
- interface NxProjectDeployConfigurations {
34
+ interface NxProjectDeployTarget {
18
35
  targets?: {
19
36
  deploy?: {
20
- configurations?: Record<string, unknown>;
37
+ command?: unknown;
38
+ options?: {
39
+ command?: unknown;
40
+ };
21
41
  };
22
42
  };
23
43
  }
24
44
 
25
45
  const parseGithubActionsEvent = typia.json.createIsParse<GithubActionsEventPayload>();
26
- const parseNxProjectDeployConfigurations = typia.json.createIsParse<NxProjectDeployConfigurations>();
46
+ const isGithubDeployment = typia.createIs<{ id: number }>();
47
+ const isNxProjectDeployTarget = typia.createIs<NxProjectDeployTarget>();
48
+ const parseNxProjectDeployTarget = typia.json.createIsParse<NxProjectDeployTarget>();
27
49
 
28
50
  type NxSmartMode = 'auto' | 'affected' | 'run-many';
29
51
 
@@ -357,35 +379,102 @@ function commaSeparatedValues(value: string): string[] {
357
379
  .filter(Boolean);
358
380
  }
359
381
 
382
+ export interface GithubCiNxDeployOptions {
383
+ environment?: string;
384
+ mode?: NxSmartMode;
385
+ name?: string;
386
+ step?: string;
387
+ verify?: boolean;
388
+ }
389
+
390
+ export interface GithubCiNxDeployDependencies {
391
+ listProjects?: (root: string, target: string, mode: 'affected' | 'run-many') => Promise<string[]>;
392
+ runNx?: (args: string[], root: string) => Promise<number>;
393
+ appendSummary?: (summaryPath: string, content: string) => Promise<void>;
394
+ publishDeployment?: (environment: `pr${number}`, url: string) => Promise<void>;
395
+ setStatus?: (state: 'pending' | 'success' | 'failure') => Promise<void>;
396
+ processEnv?: NodeJS.ProcessEnv;
397
+ eventPayload?: GithubActionsEventPayload;
398
+ }
399
+
360
400
  export async function githubCiNxDeploy(
361
401
  root: string,
362
- options: { configuration: string; mode?: NxSmartMode; name?: string; step?: string; verify?: boolean },
402
+ options: GithubCiNxDeployOptions,
403
+ dependencies: GithubCiNxDeployDependencies = {},
363
404
  ): Promise<void> {
364
- const name = options.name ?? `Deploy ${options.configuration}`;
405
+ const processEnv = dependencies.processEnv ?? process.env;
406
+ const eventPayload = dependencies.eventPayload ?? readGithubActionsEvent(processEnv);
407
+ const environment = resolveDeploymentEnvironment(options.environment, processEnv, eventPayload);
408
+ const name = options.name ?? 'Deploy Environment';
365
409
  const step = options.step ?? '';
366
- await createGithubStatus(name, step);
410
+ const setStatus =
411
+ dependencies.setStatus ??
412
+ ((state: 'pending' | 'success' | 'failure') =>
413
+ state === 'pending' ? createGithubStatus(name, step) : updateGithubStatus(name, state, step));
414
+ await setStatus('pending');
367
415
  const mode = resolveNxSmartMode(options.mode ?? 'run-many');
368
- const projects = await deployProjectsWithConfiguration(root, options.configuration, mode);
416
+ const listProjects = dependencies.listProjects ?? listNxProjectsWithTarget;
417
+ const runNx = dependencies.runNx ?? ((args: string[], commandRoot: string) => runStatus('nx', args, commandRoot));
418
+ const projects = await listProjects(root, 'deploy', mode);
369
419
  if (projects.length === 0) {
370
- console.log(`No ${mode} deploy projects with configuration ${options.configuration}; skipping.`);
371
- await updateGithubStatus(name, 'success', step);
420
+ console.log(`No ${mode} deploy projects; skipping ${environment}.`);
421
+ await setStatus('success');
372
422
  return;
373
423
  }
374
424
 
375
425
  const projectList = projects.join(',');
376
426
  const targets = options.verify === true ? ['build', 'lint', 'test', 'deploy'] : ['deploy'];
377
427
  for (const target of targets) {
378
- const nxArgs = ['run-many', '-t', target, `--projects=${projectList}`, `--parallel=${NX_PARALLEL}`];
428
+ const nxArgs = [
429
+ 'run-many',
430
+ '-t',
431
+ target,
432
+ `--projects=${projectList}`,
433
+ '--exclude=tag:permanent-deploy-target',
434
+ `--parallel=${NX_PARALLEL}`,
435
+ ];
379
436
  if (target === 'deploy') {
380
- nxArgs.push(`--configuration=${options.configuration}`);
437
+ nxArgs.push(`--environment=${environment}`);
381
438
  }
382
- const status = await runStatus('nx', nxArgs, root);
439
+ const status = await runNx(nxArgs, root);
383
440
  if (status !== 0) {
384
- await updateGithubStatus(name, 'failure', step);
441
+ await setStatus('failure');
385
442
  throw new Error(`nx ${nxArgs.join(' ')} failed with exit code ${status}`);
386
443
  }
387
444
  }
388
- await updateGithubStatus(name, 'success', step);
445
+
446
+ if (isPullRequestEnvironment(environment)) {
447
+ const url = `https://app.${environment}.conloca.com`;
448
+ const summaryPath = processEnv.GITHUB_STEP_SUMMARY;
449
+ if (summaryPath) {
450
+ const appendSummary = dependencies.appendSummary ?? appendFile;
451
+ await appendSummary(summaryPath, `## ${environment} deployment\n\n[View deployment](${url})\n`);
452
+ }
453
+ const publishDeployment =
454
+ dependencies.publishDeployment ??
455
+ ((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
+ }
474
+ }
475
+ }
476
+
477
+ await setStatus('success');
389
478
  }
390
479
 
391
480
  function resolveNxSmartMode(mode: NxSmartMode): 'affected' | 'run-many' {
@@ -419,30 +508,183 @@ function eventDefaultBranch(): string | undefined {
419
508
  }
420
509
  }
421
510
 
422
- async function deployProjectsWithConfiguration(
511
+ export function resolveDeploymentEnvironment(
512
+ explicit: string | undefined,
513
+ environment: NodeJS.ProcessEnv,
514
+ event: GithubActionsEventPayload | undefined,
515
+ ): EnvironmentToken {
516
+ if (explicit !== undefined) return parseEnvironmentToken(explicit);
517
+ if (environment.GITHUB_EVENT_NAME === 'pull_request') {
518
+ if (!event?.action || !['opened', 'reopened', 'synchronize'].includes(event.action)) {
519
+ throw new Error('Pull-request deployment runs only for opened, reopened, or synchronize events.');
520
+ }
521
+ const repository = event.repository?.full_name;
522
+ const headRepository = event.pull_request?.head?.repo?.full_name;
523
+ if (!repository || !headRepository || repository !== headRepository) {
524
+ throw new Error('Pull-request deployment is restricted to same-repository pull requests.');
525
+ }
526
+ const number = event.pull_request?.number;
527
+ if (number === undefined) throw new Error('GitHub pull_request event is missing its PR number.');
528
+ return pullRequestEnvironment(number);
529
+ }
530
+ if (environment.GITHUB_EVENT_NAME === 'push' && environment.GITHUB_REF_NAME === 'private') {
531
+ return 'staging';
532
+ }
533
+ if (environment.GITHUB_EVENT_NAME === 'release') {
534
+ return 'production';
535
+ }
536
+ throw new Error('Cannot resolve a deployment environment from this GitHub event; pass --environment explicitly.');
537
+ }
538
+
539
+ function readGithubActionsEvent(environment: NodeJS.ProcessEnv): GithubActionsEventPayload | undefined {
540
+ const eventPath = environment.GITHUB_EVENT_PATH;
541
+ if (!eventPath) return undefined;
542
+ try {
543
+ return parseGithubActionsEvent(readFileSync(eventPath, 'utf8')) || undefined;
544
+ } catch {
545
+ return undefined;
546
+ }
547
+ }
548
+
549
+ async function listNxProjectsWithTarget(
423
550
  root: string,
424
- configuration: string,
551
+ target: string,
425
552
  mode: 'affected' | 'run-many',
426
553
  ): Promise<string[]> {
427
- const listArgs =
428
- mode === 'affected'
429
- ? ['show', 'projects', '--affected', '--withTarget', 'deploy', '--json']
430
- : ['show', 'projects', '--withTarget', 'deploy', '--json'];
554
+ const listArgs = ['show', 'projects'];
555
+ if (mode === 'affected') listArgs.push('--affected');
556
+ listArgs.push('--withTarget', target);
557
+ if (target === 'deploy') listArgs.push('--exclude=tag:permanent-deploy-target');
558
+ listArgs.push('--json');
431
559
  const result = await $`nx ${listArgs}`.cwd(root).quiet();
432
- const candidates = nxProjectList(decode(result.stdout));
433
- const projects: string[] = [];
560
+ const candidates = nxProjectList(decode(result.stdout)).sort((left, right) => left.localeCompare(right));
561
+ if (target !== 'deploy') return candidates;
562
+ return selectEnvironmentDeployProjects(candidates, async (project) => {
563
+ const projectResult = await $`nx show project ${project} --json`.cwd(root).quiet();
564
+ const parsed = parseNxProjectDeployTarget(decode(projectResult.stdout));
565
+ if (!parsed) throw new Error(`nx show project ${project} returned invalid JSON.`);
566
+ return parsed;
567
+ });
568
+ }
569
+
570
+ export async function selectEnvironmentDeployProjects(
571
+ candidates: string[],
572
+ loadProject: (project: string) => Promise<unknown>,
573
+ ): Promise<string[]> {
574
+ const selected: string[] = [];
434
575
  for (const project of candidates) {
435
- if (await deployTargetHasConfiguration(root, project, configuration)) {
436
- projects.push(project);
576
+ const definition = await loadProject(project);
577
+ if (!isNxProjectDeployTarget(definition)) continue;
578
+ const deploy = definition.targets?.deploy;
579
+ const commandValue = deploy?.options?.command ?? deploy?.command;
580
+ if (typeof commandValue === 'string' && commandValue.includes('smoo wrangler deploy-environment')) {
581
+ selected.push(project);
437
582
  }
438
583
  }
439
- return projects.sort((a, b) => a.localeCompare(b));
584
+ return selected;
585
+ }
586
+
587
+ export interface GithubApiProcessResult {
588
+ exitCode: number;
589
+ stdout: string;
590
+ stderr: string;
591
+ }
592
+
593
+ export interface GithubApiProcessRunner {
594
+ run(args: string[], input: string, cwd: string): Promise<GithubApiProcessResult>;
595
+ }
596
+
597
+ export class NodeGithubApiProcessRunner implements GithubApiProcessRunner {
598
+ run(args: string[], input: string, cwd: string): Promise<GithubApiProcessResult> {
599
+ const { promise, resolve, reject } = Promise.withResolvers<GithubApiProcessResult>();
600
+ const child = spawn('gh', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
601
+ let stdout = '';
602
+ let stderr = '';
603
+ child.stdout.setEncoding('utf8');
604
+ child.stderr.setEncoding('utf8');
605
+ child.stdout.on('data', (chunk: string) => {
606
+ stdout += chunk;
607
+ });
608
+ child.stderr.on('data', (chunk: string) => {
609
+ stderr += chunk;
610
+ });
611
+ child.once('error', reject);
612
+ child.once('close', (code) => {
613
+ resolve({ exitCode: code ?? -1, stdout, stderr });
614
+ });
615
+ child.stdin.end(input);
616
+ return promise;
617
+ }
440
618
  }
441
619
 
442
- async function deployTargetHasConfiguration(root: string, project: string, configuration: string): Promise<boolean> {
443
- const result = await $`nx show project ${project} --json`.cwd(root).quiet();
444
- const parsed = parseNxProjectDeployConfigurations(decode(result.stdout));
445
- return parsed?.targets?.deploy?.configurations?.[configuration] !== undefined;
620
+ export async function publishGithubDeployment(
621
+ environment: `pr${number}`,
622
+ url: string,
623
+ processEnvironment: NodeJS.ProcessEnv,
624
+ runner: GithubApiProcessRunner = new NodeGithubApiProcessRunner(),
625
+ ): Promise<void> {
626
+ const repository = processEnvironment.GITHUB_REPOSITORY;
627
+ const sha = processEnvironment.GITHUB_SHA;
628
+ if (!repository || !sha) throw new Error('GITHUB_REPOSITORY and GITHUB_SHA are required to publish a deployment.');
629
+ const createBody = JSON.stringify({
630
+ ref: sha,
631
+ environment,
632
+ auto_merge: false,
633
+ required_contexts: [],
634
+ transient_environment: true,
635
+ production_environment: false,
636
+ });
637
+ const createResult = await runner.run(
638
+ [
639
+ 'api',
640
+ '--method',
641
+ 'POST',
642
+ '-H',
643
+ 'Accept: application/vnd.github+json',
644
+ `/repos/${repository}/deployments`,
645
+ '--input',
646
+ '-',
647
+ ],
648
+ createBody,
649
+ process.cwd(),
650
+ );
651
+ if (createResult.exitCode !== 0) {
652
+ throw new Error(
653
+ `GitHub deployment creation failed with exit code ${createResult.exitCode}: ${createResult.stderr.trim()}`,
654
+ );
655
+ }
656
+ let deployment: unknown;
657
+ try {
658
+ deployment = JSON.parse(createResult.stdout);
659
+ } catch {
660
+ throw new Error('GitHub returned invalid JSON while creating a deployment.');
661
+ }
662
+ if (!isGithubDeployment(deployment)) throw new Error('GitHub returned an invalid deployment response.');
663
+ const statusBody = JSON.stringify({
664
+ state: 'success',
665
+ environment,
666
+ environment_url: url,
667
+ auto_inactive: false,
668
+ });
669
+ const statusResult = await runner.run(
670
+ [
671
+ 'api',
672
+ '--method',
673
+ 'POST',
674
+ '-H',
675
+ 'Accept: application/vnd.github+json',
676
+ `/repos/${repository}/deployments/${deployment.id}/statuses`,
677
+ '--input',
678
+ '-',
679
+ ],
680
+ statusBody,
681
+ process.cwd(),
682
+ );
683
+ if (statusResult.exitCode !== 0) {
684
+ throw new Error(
685
+ `GitHub deployment status failed with exit code ${statusResult.exitCode}: ${statusResult.stderr.trim()}`,
686
+ );
687
+ }
446
688
  }
447
689
 
448
690
  function nxProjectList(output: string): string[] {
@@ -24,12 +24,12 @@ describe('CI workflow definition', () => {
24
24
  const rendered = renderCiWorkflowYaml({ deploy: true, pushBranches: ['main'] });
25
25
 
26
26
  expect(steps.map((step) => [step.kind, step.number])).toContainEqual([CiWorkflowStepKind.Deploy, 11]);
27
- expect(rendered).toContain('- name: 🚀 Deploy Staging');
27
+ expect(rendered.match(/- name: 🚀 Deploy Environment/g)).toHaveLength(1);
28
28
  expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
29
29
  expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
30
- expect(rendered).toContain(
31
- 'smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step 11',
32
- );
30
+ expect(rendered).toContain('smoo github-ci nx-deploy --mode run-many --name "Deploy Environment" --step 11');
31
+ expect(rendered).toContain('github.event.pull_request.head.repo.full_name == github.repository');
32
+ expect(rendered).toContain("github.ref == 'refs/heads/private'");
33
33
  expect(rendered).toContain("# Step 12\n # Nx's database cache needs artifact files");
34
34
  expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
35
35
  expect(rendered).toContain('id: setup');
@@ -45,6 +45,12 @@ describe('CI workflow definition', () => {
45
45
 
46
46
  expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
47
47
  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 }}');
48
54
  });
49
55
 
50
56
  it('uses the same architecture-scoped key to restore and save the Nx cache', async () => {
@@ -0,0 +1,23 @@
1
+ /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: Assertions verify emitted GitHub Actions expressions literally. */
2
+ import { describe, expect, it } from 'bun:test';
3
+ import { managedFileTargetsForTest } from '../managed-files.js';
4
+ import { renderPrPreviewCleanupWorkflowYaml } from '../pr-preview-cleanup-workflow.js';
5
+
6
+ describe('PR preview cleanup workflow', () => {
7
+ it('renders one close-only same-repository cleanup job from the PR number', () => {
8
+ const rendered = renderPrPreviewCleanupWorkflowYaml({ runsOn: ['nixos-latest-x64', 'self-hosted'] });
9
+ expect(managedFileTargetsForTest).toContainEqual({
10
+ target: '.github/workflows/pr-preview-cleanup.yml',
11
+ executable: undefined,
12
+ });
13
+
14
+ expect(rendered).toContain('pull_request:\n types: [closed]');
15
+ expect(rendered).not.toContain('opened');
16
+ expect(rendered).not.toContain('synchronize');
17
+ expect(rendered).toContain('if: github.event.pull_request.head.repo.full_name == github.repository');
18
+ expect(rendered.match(/smoo wrangler cleanup-pr --pr/g)).toHaveLength(1);
19
+ expect(rendered).toContain('smoo wrangler cleanup-pr --pr ${{ github.event.pull_request.number }}');
20
+ expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
21
+ expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
22
+ });
23
+ });
@@ -261,7 +261,7 @@ describe('publish workflow definition', () => {
261
261
  expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
262
262
  expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
263
263
  expect(rendered).toContain(
264
- 'smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
264
+ 'smoo github-ci nx-deploy --environment production --mode run-many --verify --name "Deploy Production"',
265
265
  );
266
266
  });
267
267
 
@@ -47,7 +47,7 @@ export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkfl
47
47
  { kind: CiWorkflowStepKind.ManagedFilesDispatch, name: '🔁 Dispatch managed-file drift healing' },
48
48
  ];
49
49
  if (options.deploy) {
50
- steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Staging' });
50
+ steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Environment' });
51
51
  }
52
52
  steps.push(
53
53
  { kind: CiWorkflowStepKind.SaveNxCache, name: '💾 Save Nx cache' },
@@ -75,7 +75,7 @@ permissions:
75
75
  # actions:write lets the drift step dispatch the managed-files workflow.
76
76
  actions: write
77
77
  contents: read
78
- statuses: write
78
+ ${options.deploy ? ' deployments: write\n' : ''} statuses: write
79
79
 
80
80
  defaults:
81
81
  run:
@@ -197,11 +197,15 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
197
197
  case CiWorkflowStepKind.Deploy:
198
198
  return [
199
199
  ` - name: ${step.name}`,
200
- ' if:',
201
- " ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}',",
202
- ' github.event.repository.default_branch) }}',
200
+ ' if: >-',
201
+ ' ${{',
202
+ " (github.event_name == 'pull_request' &&",
203
+ ' contains(fromJSON(\'["opened","reopened","synchronize"]\'), github.event.action) &&',
204
+ ' github.event.pull_request.head.repo.full_name == github.repository) ||',
205
+ " (github.event_name == 'push' && github.ref == 'refs/heads/private')",
206
+ ' }}',
203
207
  ...deployEnvLines(options),
204
- ` run: smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step ${step.number}`,
208
+ ` run: smoo github-ci nx-deploy --mode run-many --name "Deploy Environment" --step ${step.number}`,
205
209
  ];
206
210
  case CiWorkflowStepKind.SaveNxCache:
207
211
  return [
@@ -251,6 +255,12 @@ function deployEnvLines(options: CiWorkflowDefinitionOptions): string[] {
251
255
  ' env:',
252
256
  ' CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}',
253
257
  ' 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 }}',
254
264
  ];
255
265
  }
256
266
 
@@ -1,6 +1,7 @@
1
1
  /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are asserted literally. */
2
2
  import { describe, expect, it } from 'bun:test';
3
- import { readFile } from 'node:fs/promises';
3
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
4
5
  import { join } from 'node:path';
5
6
  import { LINUX_PLATFORM_TARGET_GLOBS, PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
6
7
  import fc from 'fast-check';
@@ -215,6 +216,21 @@ describe('nx graph project helpers', () => {
215
216
  });
216
217
  expect(deployTargetInfoFromProjects(sampleProjects, 'preview')).toEqual({ exists: false });
217
218
  });
219
+
220
+ it('recognizes convention-driven deploy-environment targets without per-environment configurations', () => {
221
+ const projects: NxProjects = {
222
+ app: {
223
+ targets: {
224
+ deploy: {
225
+ options: { command: 'smoo wrangler deploy-environment --environment {args.environment}' },
226
+ },
227
+ },
228
+ },
229
+ };
230
+
231
+ expect(deployTargetInfoFromProjects(projects, 'staging')).toEqual({ exists: true, provider: 'cloudflare' });
232
+ expect(deployTargetInfoFromProjects(projects, 'production')).toEqual({ exists: true, provider: 'cloudflare' });
233
+ });
218
234
  });
219
235
 
220
236
  describe('managed raw files', () => {
@@ -228,6 +244,45 @@ describe('managed raw files', () => {
228
244
 
229
245
  expect(generated).toBe(source);
230
246
  });
247
+
248
+ it('exports the restored ttsc cache path while preserving host cache overrides', async () => {
249
+ const temp = await mkdtemp(join(tmpdir(), 'smoo-github-bootstrap-'));
250
+ const bin = join(temp, 'bin');
251
+ await mkdir(bin);
252
+ const devenv = join(bin, 'devenv');
253
+ await writeFile(devenv, '#!/bin/sh\nexit 0\n');
254
+ await chmod(devenv, 0o755);
255
+
256
+ try {
257
+ const cases = [
258
+ { input: '', expected: join(REPO_ROOT, '.cache', 'ttsc') },
259
+ { input: join(temp, 'host-ttsc'), expected: join(temp, 'host-ttsc') },
260
+ ];
261
+ for (const [index, cache] of cases.entries()) {
262
+ const githubEnv = join(temp, `github-env-${index}`);
263
+ const githubPath = join(temp, `github-path-${index}`);
264
+ const process = Bun.spawn(
265
+ [join(REPO_ROOT, 'tooling', 'direnv', 'github-actions-bootstrap.sh'), 'build-shell'],
266
+ {
267
+ cwd: join(REPO_ROOT, 'tooling', 'direnv'),
268
+ env: {
269
+ ...Bun.env,
270
+ GITHUB_ENV: githubEnv,
271
+ GITHUB_PATH: githubPath,
272
+ PATH: `${bin}:${Bun.env.PATH ?? ''}`,
273
+ TTSC_CACHE_DIR: cache.input,
274
+ },
275
+ stderr: 'pipe',
276
+ stdout: 'pipe',
277
+ },
278
+ );
279
+ expect(await process.exited).toBe(0);
280
+ expect(await readFile(githubEnv, 'utf8')).toContain(`TTSC_CACHE_DIR=${cache.expected}\n`);
281
+ }
282
+ } finally {
283
+ await rm(temp, { recursive: true, force: true });
284
+ }
285
+ });
231
286
  });
232
287
 
233
288
  describe('managed cache actions', () => {
@@ -6,6 +6,7 @@ import type { NxTargetConfig, PackageJson } from '../lib/json.js';
6
6
  import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
7
7
  import { loadNxProjects, type NxProjects, targetNamesFromProjects } from '../nx/index.js';
8
8
  import { renderCiWorkflowYaml } from './ci-workflow.js';
9
+ import { renderPrPreviewCleanupWorkflowYaml } from './pr-preview-cleanup-workflow.js';
9
10
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
10
11
 
11
12
  type ManagedKind = 'raw' | 'template' | 'generated';
@@ -24,6 +25,7 @@ interface ManagedFile {
24
25
  target: string;
25
26
  executable?: boolean;
26
27
  releasePackagesOnly?: boolean;
28
+ cloudflareDeployOnly?: boolean;
27
29
  }
28
30
 
29
31
  /** Split a managed target's content into the managed part and the repo-owned tail. */
@@ -211,6 +213,12 @@ const managedFiles: ManagedFile[] = [
211
213
  source: 'ci-workflow',
212
214
  target: '.github/workflows/ci.yml',
213
215
  },
216
+ {
217
+ kind: 'generated',
218
+ source: 'pr-preview-cleanup-workflow',
219
+ target: '.github/workflows/pr-preview-cleanup.yml',
220
+ cloudflareDeployOnly: true,
221
+ },
214
222
  {
215
223
  kind: 'generated',
216
224
  source: 'publish-workflow',
@@ -272,6 +280,9 @@ function applyManagedFile(
272
280
  if (file.releasePackagesOnly === true && !context.hasReleasePackages && !context.hasProductionDeployTargets) {
273
281
  return { target: file.target, action: 'skipped' };
274
282
  }
283
+ if (file.cloudflareDeployOnly === true && context.stagingDeployProvider !== 'cloudflare') {
284
+ return { target: file.target, action: 'skipped' };
285
+ }
275
286
  const target = resolve(root, file.target);
276
287
  const content = getManagedContent(file, context);
277
288
  if (existsSync(target)) {
@@ -322,6 +333,9 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
322
333
  runsOn: context.ciRunsOn,
323
334
  });
324
335
  }
336
+ if (file.source === 'pr-preview-cleanup-workflow') {
337
+ return renderPrPreviewCleanupWorkflowYaml({ runsOn: context.ciRunsOn });
338
+ }
325
339
  throw new Error(`Unknown generated managed file source ${file.source}`);
326
340
  }
327
341
  const sourceRoot = file.kind === 'raw' ? 'managed/raw' : 'managed/templates';
@@ -388,11 +402,16 @@ function deployTargetInfoFromTargets(targets: Record<string, NxTargetConfig>, co
388
402
  if (!deploy) {
389
403
  return { exists: false };
390
404
  }
405
+ const baseCommandValue = deploy.options?.command ?? deploy.command;
406
+ const baseCommand = typeof baseCommandValue === 'string' ? baseCommandValue : '';
407
+ if (baseCommand.includes('smoo wrangler deploy-environment')) {
408
+ return { exists: true, provider: 'cloudflare' };
409
+ }
391
410
  const config = deploy.configurations?.[configuration];
392
411
  if (!config) {
393
412
  return { exists: false };
394
413
  }
395
- const commandValue = config.command ?? config.options?.command ?? deploy.options?.command ?? deploy.command;
414
+ const commandValue = config.command ?? config.options?.command ?? baseCommand;
396
415
  const command = typeof commandValue === 'string' ? commandValue : '';
397
416
  return { exists: true, provider: command.includes('wrangler ') ? 'cloudflare' : undefined };
398
417
  }
@@ -0,0 +1,44 @@
1
+ /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are emitted literally. */
2
+
3
+ import { renderRunsOnLine } from './github-runs-on.js';
4
+
5
+ export interface PrPreviewCleanupWorkflowOptions {
6
+ runsOn?: string | string[];
7
+ }
8
+
9
+ export function renderPrPreviewCleanupWorkflowYaml(options: PrPreviewCleanupWorkflowOptions = {}): string {
10
+ return `name: PR Preview Cleanup
11
+
12
+ on:
13
+ pull_request:
14
+ types: [closed]
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ jobs:
20
+ cleanup:
21
+ name: Cleanup PR environment
22
+ if: github.event.pull_request.head.repo.full_name == github.repository
23
+ ${renderRunsOnLine(options.runsOn)}
24
+ timeout-minutes: 15
25
+ defaults:
26
+ run:
27
+ working-directory: tooling/direnv
28
+ steps:
29
+ - name: Checkout
30
+ uses: actions/checkout@v6.0.2
31
+ with:
32
+ filter: blob:none
33
+ fetch-depth: 1
34
+
35
+ - name: Setup Nix/devenv
36
+ uses: ./.github/actions/setup-devenv
37
+
38
+ - name: Cleanup PR environment
39
+ env:
40
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
41
+ CLOUDFLARE_ACCOUNT_ID: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
42
+ run: smoo wrangler cleanup-pr --pr \${{ github.event.pull_request.number }}
43
+ `;
44
+ }