@smoothbricks/cli 0.10.7 โ†’ 0.10.9

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 (58) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +24 -1
  3. package/dist/github-ci/index.d.ts +44 -4
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +220 -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 +3 -3
  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 +1 -0
  22. package/dist/release/index.d.ts.map +1 -1
  23. package/dist/release/index.js +31 -6
  24. package/dist/wrangler/cloudflare.d.ts +87 -0
  25. package/dist/wrangler/cloudflare.d.ts.map +1 -0
  26. package/dist/wrangler/cloudflare.js +238 -0
  27. package/dist/wrangler/deploy-environment.d.ts +48 -0
  28. package/dist/wrangler/deploy-environment.d.ts.map +1 -0
  29. package/dist/wrangler/deploy-environment.js +383 -0
  30. package/dist/wrangler/environment.d.ts +58 -0
  31. package/dist/wrangler/environment.d.ts.map +1 -0
  32. package/dist/wrangler/environment.js +297 -0
  33. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +3 -4
  34. package/package.json +9 -2
  35. package/src/cli.ts +27 -3
  36. package/src/github-ci/index.test.ts +175 -2
  37. package/src/github-ci/index.ts +274 -31
  38. package/src/monorepo/__tests__/ci-workflow.test.ts +10 -4
  39. package/src/monorepo/__tests__/pr-preview-cleanup-workflow.test.ts +23 -0
  40. package/src/monorepo/__tests__/publish-workflow.test.ts +7 -5
  41. package/src/monorepo/ci-workflow.ts +16 -6
  42. package/src/monorepo/managed-files.test.ts +56 -1
  43. package/src/monorepo/managed-files.ts +20 -1
  44. package/src/monorepo/pr-preview-cleanup-workflow.ts +44 -0
  45. package/src/monorepo/publish-workflow.ts +8 -5
  46. package/src/monorepo/tool-validation.test.ts +85 -0
  47. package/src/monorepo/tool-validation.ts +94 -5
  48. package/src/playwright/index.test.ts +90 -0
  49. package/src/playwright/index.ts +73 -0
  50. package/src/release/__tests__/bootstrap-npm-packages.test.ts +63 -2
  51. package/src/release/bootstrap-npm-packages.ts +34 -0
  52. package/src/release/index.ts +30 -4
  53. package/src/wrangler/cloudflare.test.ts +76 -0
  54. package/src/wrangler/cloudflare.ts +292 -0
  55. package/src/wrangler/deploy-environment.test.ts +354 -0
  56. package/src/wrangler/deploy-environment.ts +445 -0
  57. package/src/wrangler/environment.test.ts +173 -0
  58. package/src/wrangler/environment.ts +366 -0
@@ -8,10 +8,14 @@ import type { ProjectTargets } from '../nx/index.js';
8
8
  import {
9
9
  expandNxTargetDependencyRuns,
10
10
  expandNxTargetRuns,
11
+ githubCiNxDeploy,
11
12
  githubCiNxRunMany,
12
13
  nxRunManyArgs,
13
14
  nxSmartArgs,
15
+ publishGithubDeployment,
14
16
  readGitHeadSha,
17
+ resolveDeploymentEnvironment,
18
+ selectEnvironmentDeployProjects,
15
19
  } from './index.js';
16
20
  import {
17
21
  applyCollectedOutputs,
@@ -162,11 +166,12 @@ describe('collected Nx outputs', () => {
162
166
 
163
167
  it('collects an empty artifact when selected projects have no matching target', async () => {
164
168
  await withNxRunManyFixture(async ({ root, artifact }) => {
165
- await githubCiNxRunMany(root, {
169
+ const expanded = await githubCiNxRunMany(root, {
166
170
  targets: '*-linux',
167
171
  projects: 'app',
168
172
  collectOutputs: artifact,
169
173
  });
174
+ expect(expanded.runs).toEqual([]);
170
175
 
171
176
  const sourceSha = await readGitHeadSha(root);
172
177
  expect(JSON.parse(await readFile(join(artifact, 'manifest.json'), 'utf8'))).toEqual({
@@ -182,10 +187,11 @@ describe('collected Nx outputs', () => {
182
187
  await writeFile(join(root, 'packages/app/test-target.ts'), "await Bun.write('test-ran.txt', 'tested');\n");
183
188
  await writeFile(join(root, 'packages/app/build-target.ts'), "throw new Error('build must not run here');\n");
184
189
 
185
- await githubCiNxRunMany(root, {
190
+ const expanded = await githubCiNxRunMany(root, {
186
191
  targets: 'test',
187
192
  projectsWithTargets: '*-macos,*-ios',
188
193
  });
194
+ expect(expanded.runs.map((run) => run.projects.map((project) => project.project))).toEqual([['app']]);
189
195
 
190
196
  await expect(readFile(join(root, 'test-ran.txt'), 'utf8')).resolves.toBe('tested');
191
197
  await expect(readFile(join(root, 'build-ran.txt'), 'utf8')).rejects.toThrow();
@@ -533,3 +539,170 @@ async function withNxRunManyFixture(
533
539
  await rm(temp, { recursive: true, force: true });
534
540
  }
535
541
  }
542
+
543
+ describe('event-aware environment deployment', () => {
544
+ it('resolves same-repository PR, private push, release, and explicit production environments', () => {
545
+ expect(
546
+ resolveDeploymentEnvironment(
547
+ undefined,
548
+ { GITHUB_EVENT_NAME: 'pull_request' },
549
+ {
550
+ action: 'synchronize',
551
+ repository: { full_name: 'owner/repo' },
552
+ pull_request: { number: 123, head: { repo: { full_name: 'owner/repo' } } },
553
+ },
554
+ ),
555
+ ).toBe('pr123');
556
+ expect(
557
+ resolveDeploymentEnvironment(undefined, { GITHUB_EVENT_NAME: 'push', GITHUB_REF_NAME: 'private' }, undefined),
558
+ ).toBe('staging');
559
+ expect(resolveDeploymentEnvironment(undefined, { GITHUB_EVENT_NAME: 'release' }, undefined)).toBe('production');
560
+ expect(resolveDeploymentEnvironment('production', {}, undefined)).toBe('production');
561
+ expect(() =>
562
+ resolveDeploymentEnvironment(
563
+ undefined,
564
+ { GITHUB_EVENT_NAME: 'pull_request' },
565
+ {
566
+ action: 'opened',
567
+ repository: { full_name: 'owner/repo' },
568
+ pull_request: { number: 123, head: { repo: { full_name: 'fork/repo' } } },
569
+ },
570
+ ),
571
+ ).toThrow(/same-repository/);
572
+ });
573
+
574
+ it('selects only deploy targets owned by the environment convention', async () => {
575
+ const definitions: Record<string, unknown> = {
576
+ 'conloca-app': {
577
+ targets: {
578
+ deploy: { options: { command: 'smoo wrangler deploy-environment --environment {args.environment}' } },
579
+ },
580
+ },
581
+ 'conloca-app-backend': {
582
+ targets: { deploy: { command: 'smoo wrangler deploy-environment --environment {args.environment}' } },
583
+ },
584
+ 'conloca-oauth-redirect': {
585
+ targets: { deploy: { options: { command: 'wrangler deploy --config wrangler.toml' } } },
586
+ },
587
+ 'conloca-website': {
588
+ targets: { deploy: { options: { command: 'bun scripts/deploy-website.ts' } } },
589
+ },
590
+ };
591
+
592
+ await expect(
593
+ selectEnvironmentDeployProjects(Object.keys(definitions), async (project) => definitions[project]),
594
+ ).resolves.toEqual(['conloca-app', 'conloca-app-backend']);
595
+ });
596
+
597
+ it('publishes GitHub deployment JSON through a real stdin process seam', async () => {
598
+ const calls: Array<{ args: string[]; input: unknown }> = [];
599
+ await publishGithubDeployment(
600
+ 'pr123',
601
+ 'https://app.pr123.conloca.com',
602
+ { GITHUB_REPOSITORY: 'owner/repo', GITHUB_SHA: 'abc123' },
603
+ {
604
+ run: async (args, input) => {
605
+ calls.push({ args, input: JSON.parse(input) });
606
+ return {
607
+ exitCode: 0,
608
+ stdout: calls.length === 1 ? JSON.stringify({ id: 42 }) : '',
609
+ stderr: '',
610
+ };
611
+ },
612
+ },
613
+ );
614
+
615
+ expect(calls).toEqual([
616
+ {
617
+ args: [
618
+ 'api',
619
+ '--method',
620
+ 'POST',
621
+ '-H',
622
+ 'Accept: application/vnd.github+json',
623
+ '/repos/owner/repo/deployments',
624
+ '--input',
625
+ '-',
626
+ ],
627
+ input: {
628
+ ref: 'abc123',
629
+ environment: 'pr123',
630
+ auto_merge: false,
631
+ required_contexts: [],
632
+ transient_environment: true,
633
+ production_environment: false,
634
+ },
635
+ },
636
+ {
637
+ args: [
638
+ 'api',
639
+ '--method',
640
+ 'POST',
641
+ '-H',
642
+ 'Accept: application/vnd.github+json',
643
+ '/repos/owner/repo/deployments/42/statuses',
644
+ '--input',
645
+ '-',
646
+ ],
647
+ input: {
648
+ state: 'success',
649
+ environment: 'pr123',
650
+ environment_url: 'https://app.pr123.conloca.com',
651
+ auto_inactive: false,
652
+ },
653
+ },
654
+ ]);
655
+ });
656
+
657
+ it('deploys app/backend, follows with e2e-deployed, and publishes PR metadata', async () => {
658
+ const nxCalls: string[][] = [];
659
+ const listCalls: Array<[string, string]> = [];
660
+ const summaries: string[] = [];
661
+ const deployments: Array<[string, string]> = [];
662
+
663
+ await githubCiNxDeploy(
664
+ '/repo',
665
+ { mode: 'run-many', name: 'Deploy Environment' },
666
+ {
667
+ processEnv: {
668
+ GITHUB_EVENT_NAME: 'pull_request',
669
+ GITHUB_STEP_SUMMARY: '/summary',
670
+ },
671
+ setStatus: async () => {},
672
+ eventPayload: {
673
+ action: 'opened',
674
+ repository: { full_name: 'owner/repo' },
675
+ pull_request: { number: 123, head: { repo: { full_name: 'owner/repo' } } },
676
+ },
677
+ listProjects: async (_root, target, mode) => {
678
+ listCalls.push([target, mode]);
679
+ return target === 'deploy' ? ['conloca-app', 'conloca-app-backend'] : ['conloca-e2e'];
680
+ },
681
+ runNx: async (args) => {
682
+ nxCalls.push(args);
683
+ return 0;
684
+ },
685
+ appendSummary: async (_path, content) => {
686
+ summaries.push(content);
687
+ },
688
+ publishDeployment: async (environment, url) => {
689
+ deployments.push([environment, url]);
690
+ },
691
+ },
692
+ );
693
+
694
+ expect(listCalls).toEqual([
695
+ ['deploy', 'run-many'],
696
+ ['e2e-deployed', 'run-many'],
697
+ ]);
698
+ expect(nxCalls).toHaveLength(2);
699
+ expect(nxCalls[0]).toContain('--projects=conloca-app,conloca-app-backend');
700
+ expect(nxCalls[0]).toContain('--exclude=tag:permanent-deploy-target');
701
+ expect(nxCalls[0]).toContain('--environment=pr123');
702
+ expect(nxCalls[1]).toContain('-t');
703
+ expect(nxCalls[1]).toContain('e2e-deployed');
704
+ expect(nxCalls[1]).toContain('--environment=pr123');
705
+ expect(summaries).toEqual(['## pr123 deployment\n\n[View deployment](https://app.pr123.conloca.com)\n']);
706
+ expect(deployments).toEqual([['pr123', 'https://app.pr123.conloca.com']]);
707
+ });
708
+ });
@@ -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
 
@@ -293,7 +315,7 @@ export async function readGitHeadSha(root: string): Promise<string> {
293
315
  return decode((await $`git rev-parse HEAD`.cwd(root).quiet()).stdout).trim();
294
316
  }
295
317
 
296
- export async function githubCiNxRunMany(root: string, options: NxRunManyOptions): Promise<void> {
318
+ export async function githubCiNxRunMany(root: string, options: NxRunManyOptions): Promise<ExpandedNxTargetRuns> {
297
319
  const expanded = expandNxTargetRuns(await readProjectTargets(root), options);
298
320
  if (expanded.unmatchedGlobs.length > 0) {
299
321
  console.log(`No Nx targets matched target glob(s): ${expanded.unmatchedGlobs.join(', ')}; skipping.`);
@@ -306,6 +328,7 @@ export async function githubCiNxRunMany(root: string, options: NxRunManyOptions)
306
328
  const sourceSha = await readGitHeadSha(root);
307
329
  await collectNxOutputs(root, options.collectOutputs, expandNxTargetDependencyRuns(expanded.runs), sourceSha);
308
330
  }
331
+ return expanded;
309
332
  }
310
333
 
311
334
  export async function githubCiApplyOutputs(
@@ -357,35 +380,102 @@ function commaSeparatedValues(value: string): string[] {
357
380
  .filter(Boolean);
358
381
  }
359
382
 
383
+ export interface GithubCiNxDeployOptions {
384
+ environment?: string;
385
+ mode?: NxSmartMode;
386
+ name?: string;
387
+ step?: string;
388
+ verify?: boolean;
389
+ }
390
+
391
+ export interface GithubCiNxDeployDependencies {
392
+ listProjects?: (root: string, target: string, mode: 'affected' | 'run-many') => Promise<string[]>;
393
+ runNx?: (args: string[], root: string) => Promise<number>;
394
+ appendSummary?: (summaryPath: string, content: string) => Promise<void>;
395
+ publishDeployment?: (environment: `pr${number}`, url: string) => Promise<void>;
396
+ setStatus?: (state: 'pending' | 'success' | 'failure') => Promise<void>;
397
+ processEnv?: NodeJS.ProcessEnv;
398
+ eventPayload?: GithubActionsEventPayload;
399
+ }
400
+
360
401
  export async function githubCiNxDeploy(
361
402
  root: string,
362
- options: { configuration: string; mode?: NxSmartMode; name?: string; step?: string; verify?: boolean },
403
+ options: GithubCiNxDeployOptions,
404
+ dependencies: GithubCiNxDeployDependencies = {},
363
405
  ): Promise<void> {
364
- const name = options.name ?? `Deploy ${options.configuration}`;
406
+ const processEnv = dependencies.processEnv ?? process.env;
407
+ const eventPayload = dependencies.eventPayload ?? readGithubActionsEvent(processEnv);
408
+ const environment = resolveDeploymentEnvironment(options.environment, processEnv, eventPayload);
409
+ const name = options.name ?? 'Deploy Environment';
365
410
  const step = options.step ?? '';
366
- await createGithubStatus(name, step);
411
+ const setStatus =
412
+ dependencies.setStatus ??
413
+ ((state: 'pending' | 'success' | 'failure') =>
414
+ state === 'pending' ? createGithubStatus(name, step) : updateGithubStatus(name, state, step));
415
+ await setStatus('pending');
367
416
  const mode = resolveNxSmartMode(options.mode ?? 'run-many');
368
- const projects = await deployProjectsWithConfiguration(root, options.configuration, mode);
417
+ const listProjects = dependencies.listProjects ?? listNxProjectsWithTarget;
418
+ const runNx = dependencies.runNx ?? ((args: string[], commandRoot: string) => runStatus('nx', args, commandRoot));
419
+ const projects = await listProjects(root, 'deploy', mode);
369
420
  if (projects.length === 0) {
370
- console.log(`No ${mode} deploy projects with configuration ${options.configuration}; skipping.`);
371
- await updateGithubStatus(name, 'success', step);
421
+ console.log(`No ${mode} deploy projects; skipping ${environment}.`);
422
+ await setStatus('success');
372
423
  return;
373
424
  }
374
425
 
375
426
  const projectList = projects.join(',');
376
427
  const targets = options.verify === true ? ['build', 'lint', 'test', 'deploy'] : ['deploy'];
377
428
  for (const target of targets) {
378
- const nxArgs = ['run-many', '-t', target, `--projects=${projectList}`, `--parallel=${NX_PARALLEL}`];
429
+ const nxArgs = [
430
+ 'run-many',
431
+ '-t',
432
+ target,
433
+ `--projects=${projectList}`,
434
+ '--exclude=tag:permanent-deploy-target',
435
+ `--parallel=${NX_PARALLEL}`,
436
+ ];
379
437
  if (target === 'deploy') {
380
- nxArgs.push(`--configuration=${options.configuration}`);
438
+ nxArgs.push(`--environment=${environment}`);
381
439
  }
382
- const status = await runStatus('nx', nxArgs, root);
440
+ const status = await runNx(nxArgs, root);
383
441
  if (status !== 0) {
384
- await updateGithubStatus(name, 'failure', step);
442
+ await setStatus('failure');
385
443
  throw new Error(`nx ${nxArgs.join(' ')} failed with exit code ${status}`);
386
444
  }
387
445
  }
388
- await updateGithubStatus(name, 'success', step);
446
+
447
+ if (isPullRequestEnvironment(environment)) {
448
+ const url = `https://app.${environment}.conloca.com`;
449
+ const summaryPath = processEnv.GITHUB_STEP_SUMMARY;
450
+ if (summaryPath) {
451
+ const appendSummary = dependencies.appendSummary ?? appendFile;
452
+ await appendSummary(summaryPath, `## ${environment} deployment\n\n[View deployment](${url})\n`);
453
+ }
454
+ const publishDeployment =
455
+ dependencies.publishDeployment ??
456
+ ((token: `pr${number}`, deploymentUrl: string) => publishGithubDeployment(token, deploymentUrl, processEnv));
457
+ await publishDeployment(environment, url);
458
+ }
459
+ if (environment !== 'production') {
460
+ const e2eProjects = await listProjects(root, 'e2e-deployed', 'run-many');
461
+ if (e2eProjects.length > 0) {
462
+ const nxArgs = [
463
+ 'run-many',
464
+ '-t',
465
+ 'e2e-deployed',
466
+ `--projects=${e2eProjects.join(',')}`,
467
+ `--parallel=${NX_PARALLEL}`,
468
+ `--environment=${environment}`,
469
+ ];
470
+ const status = await runNx(nxArgs, root);
471
+ if (status !== 0) {
472
+ await setStatus('failure');
473
+ throw new Error(`nx ${nxArgs.join(' ')} failed with exit code ${status}`);
474
+ }
475
+ }
476
+ }
477
+
478
+ await setStatus('success');
389
479
  }
390
480
 
391
481
  function resolveNxSmartMode(mode: NxSmartMode): 'affected' | 'run-many' {
@@ -419,30 +509,183 @@ function eventDefaultBranch(): string | undefined {
419
509
  }
420
510
  }
421
511
 
422
- async function deployProjectsWithConfiguration(
512
+ export function resolveDeploymentEnvironment(
513
+ explicit: string | undefined,
514
+ environment: NodeJS.ProcessEnv,
515
+ event: GithubActionsEventPayload | undefined,
516
+ ): EnvironmentToken {
517
+ if (explicit !== undefined) return parseEnvironmentToken(explicit);
518
+ if (environment.GITHUB_EVENT_NAME === 'pull_request') {
519
+ if (!event?.action || !['opened', 'reopened', 'synchronize'].includes(event.action)) {
520
+ throw new Error('Pull-request deployment runs only for opened, reopened, or synchronize events.');
521
+ }
522
+ const repository = event.repository?.full_name;
523
+ const headRepository = event.pull_request?.head?.repo?.full_name;
524
+ if (!repository || !headRepository || repository !== headRepository) {
525
+ throw new Error('Pull-request deployment is restricted to same-repository pull requests.');
526
+ }
527
+ const number = event.pull_request?.number;
528
+ if (number === undefined) throw new Error('GitHub pull_request event is missing its PR number.');
529
+ return pullRequestEnvironment(number);
530
+ }
531
+ if (environment.GITHUB_EVENT_NAME === 'push' && environment.GITHUB_REF_NAME === 'private') {
532
+ return 'staging';
533
+ }
534
+ if (environment.GITHUB_EVENT_NAME === 'release') {
535
+ return 'production';
536
+ }
537
+ throw new Error('Cannot resolve a deployment environment from this GitHub event; pass --environment explicitly.');
538
+ }
539
+
540
+ function readGithubActionsEvent(environment: NodeJS.ProcessEnv): GithubActionsEventPayload | undefined {
541
+ const eventPath = environment.GITHUB_EVENT_PATH;
542
+ if (!eventPath) return undefined;
543
+ try {
544
+ return parseGithubActionsEvent(readFileSync(eventPath, 'utf8')) || undefined;
545
+ } catch {
546
+ return undefined;
547
+ }
548
+ }
549
+
550
+ async function listNxProjectsWithTarget(
423
551
  root: string,
424
- configuration: string,
552
+ target: string,
425
553
  mode: 'affected' | 'run-many',
426
554
  ): Promise<string[]> {
427
- const listArgs =
428
- mode === 'affected'
429
- ? ['show', 'projects', '--affected', '--withTarget', 'deploy', '--json']
430
- : ['show', 'projects', '--withTarget', 'deploy', '--json'];
555
+ const listArgs = ['show', 'projects'];
556
+ if (mode === 'affected') listArgs.push('--affected');
557
+ listArgs.push('--withTarget', target);
558
+ if (target === 'deploy') listArgs.push('--exclude=tag:permanent-deploy-target');
559
+ listArgs.push('--json');
431
560
  const result = await $`nx ${listArgs}`.cwd(root).quiet();
432
- const candidates = nxProjectList(decode(result.stdout));
433
- const projects: string[] = [];
561
+ const candidates = nxProjectList(decode(result.stdout)).sort((left, right) => left.localeCompare(right));
562
+ if (target !== 'deploy') return candidates;
563
+ return selectEnvironmentDeployProjects(candidates, async (project) => {
564
+ const projectResult = await $`nx show project ${project} --json`.cwd(root).quiet();
565
+ const parsed = parseNxProjectDeployTarget(decode(projectResult.stdout));
566
+ if (!parsed) throw new Error(`nx show project ${project} returned invalid JSON.`);
567
+ return parsed;
568
+ });
569
+ }
570
+
571
+ export async function selectEnvironmentDeployProjects(
572
+ candidates: string[],
573
+ loadProject: (project: string) => Promise<unknown>,
574
+ ): Promise<string[]> {
575
+ const selected: string[] = [];
434
576
  for (const project of candidates) {
435
- if (await deployTargetHasConfiguration(root, project, configuration)) {
436
- projects.push(project);
577
+ const definition = await loadProject(project);
578
+ if (!isNxProjectDeployTarget(definition)) continue;
579
+ const deploy = definition.targets?.deploy;
580
+ const commandValue = deploy?.options?.command ?? deploy?.command;
581
+ if (typeof commandValue === 'string' && commandValue.includes('smoo wrangler deploy-environment')) {
582
+ selected.push(project);
437
583
  }
438
584
  }
439
- return projects.sort((a, b) => a.localeCompare(b));
585
+ return selected;
586
+ }
587
+
588
+ export interface GithubApiProcessResult {
589
+ exitCode: number;
590
+ stdout: string;
591
+ stderr: string;
592
+ }
593
+
594
+ export interface GithubApiProcessRunner {
595
+ run(args: string[], input: string, cwd: string): Promise<GithubApiProcessResult>;
596
+ }
597
+
598
+ export class NodeGithubApiProcessRunner implements GithubApiProcessRunner {
599
+ run(args: string[], input: string, cwd: string): Promise<GithubApiProcessResult> {
600
+ const { promise, resolve, reject } = Promise.withResolvers<GithubApiProcessResult>();
601
+ const child = spawn('gh', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
602
+ let stdout = '';
603
+ let stderr = '';
604
+ child.stdout.setEncoding('utf8');
605
+ child.stderr.setEncoding('utf8');
606
+ child.stdout.on('data', (chunk: string) => {
607
+ stdout += chunk;
608
+ });
609
+ child.stderr.on('data', (chunk: string) => {
610
+ stderr += chunk;
611
+ });
612
+ child.once('error', reject);
613
+ child.once('close', (code) => {
614
+ resolve({ exitCode: code ?? -1, stdout, stderr });
615
+ });
616
+ child.stdin.end(input);
617
+ return promise;
618
+ }
440
619
  }
441
620
 
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;
621
+ export async function publishGithubDeployment(
622
+ environment: `pr${number}`,
623
+ url: string,
624
+ processEnvironment: NodeJS.ProcessEnv,
625
+ runner: GithubApiProcessRunner = new NodeGithubApiProcessRunner(),
626
+ ): Promise<void> {
627
+ const repository = processEnvironment.GITHUB_REPOSITORY;
628
+ const sha = processEnvironment.GITHUB_SHA;
629
+ if (!repository || !sha) throw new Error('GITHUB_REPOSITORY and GITHUB_SHA are required to publish a deployment.');
630
+ const createBody = JSON.stringify({
631
+ ref: sha,
632
+ environment,
633
+ auto_merge: false,
634
+ required_contexts: [],
635
+ transient_environment: true,
636
+ production_environment: false,
637
+ });
638
+ const createResult = await runner.run(
639
+ [
640
+ 'api',
641
+ '--method',
642
+ 'POST',
643
+ '-H',
644
+ 'Accept: application/vnd.github+json',
645
+ `/repos/${repository}/deployments`,
646
+ '--input',
647
+ '-',
648
+ ],
649
+ createBody,
650
+ process.cwd(),
651
+ );
652
+ if (createResult.exitCode !== 0) {
653
+ throw new Error(
654
+ `GitHub deployment creation failed with exit code ${createResult.exitCode}: ${createResult.stderr.trim()}`,
655
+ );
656
+ }
657
+ let deployment: unknown;
658
+ try {
659
+ deployment = JSON.parse(createResult.stdout);
660
+ } catch {
661
+ throw new Error('GitHub returned invalid JSON while creating a deployment.');
662
+ }
663
+ if (!isGithubDeployment(deployment)) throw new Error('GitHub returned an invalid deployment response.');
664
+ const statusBody = JSON.stringify({
665
+ state: 'success',
666
+ environment,
667
+ environment_url: url,
668
+ auto_inactive: false,
669
+ });
670
+ const statusResult = await runner.run(
671
+ [
672
+ 'api',
673
+ '--method',
674
+ 'POST',
675
+ '-H',
676
+ 'Accept: application/vnd.github+json',
677
+ `/repos/${repository}/deployments/${deployment.id}/statuses`,
678
+ '--input',
679
+ '-',
680
+ ],
681
+ statusBody,
682
+ process.cwd(),
683
+ );
684
+ if (statusResult.exitCode !== 0) {
685
+ throw new Error(
686
+ `GitHub deployment status failed with exit code ${statusResult.exitCode}: ${statusResult.stderr.trim()}`,
687
+ );
688
+ }
446
689
  }
447
690
 
448
691
  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
+ });
@@ -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');
@@ -261,7 +263,7 @@ describe('publish workflow definition', () => {
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 --configuration production --mode run-many --verify --name "Deploy Production"',
266
+ 'smoo github-ci nx-deploy --environment production --mode run-many --verify --name "Deploy Production"',
265
267
  );
266
268
  });
267
269