@smoothbricks/cli 0.10.6 → 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 (82) 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 -3
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +242 -36
  6. package/dist/lib/json.d.ts +1 -1
  7. package/dist/lib/json.d.ts.map +1 -1
  8. package/dist/lib/json.js +3 -18
  9. package/dist/lib/workspace.d.ts.map +1 -1
  10. package/dist/lib/workspace.js +1 -0
  11. package/dist/monorepo/ci-workflow.js +16 -6
  12. package/dist/monorepo/managed-files.d.ts +6 -22
  13. package/dist/monorepo/managed-files.d.ts.map +1 -1
  14. package/dist/monorepo/managed-files.js +31 -48
  15. package/dist/monorepo/package-policy.js +2 -2
  16. package/dist/monorepo/packs/index.d.ts.map +1 -1
  17. package/dist/monorepo/packs/index.js +2 -0
  18. package/dist/monorepo/pr-preview-cleanup-workflow.d.ts +5 -0
  19. package/dist/monorepo/pr-preview-cleanup-workflow.d.ts.map +1 -0
  20. package/dist/monorepo/pr-preview-cleanup-workflow.js +38 -0
  21. package/dist/monorepo/publish-workflow.js +3 -3
  22. package/dist/monorepo/runtime.d.ts +2 -2
  23. package/dist/monorepo/runtime.d.ts.map +1 -1
  24. package/dist/monorepo/runtime.js +11 -16
  25. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  26. package/dist/monorepo/tool-validation.js +105 -28
  27. package/dist/nx/index.d.ts +7 -4
  28. package/dist/nx/index.d.ts.map +1 -1
  29. package/dist/nx/index.js +117 -56
  30. package/dist/playwright/index.d.ts +22 -0
  31. package/dist/playwright/index.d.ts.map +1 -0
  32. package/dist/playwright/index.js +44 -0
  33. package/dist/release/bootstrap-npm-packages.d.ts +3 -0
  34. package/dist/release/bootstrap-npm-packages.d.ts.map +1 -1
  35. package/dist/release/bootstrap-npm-packages.js +21 -0
  36. package/dist/release/index.d.ts.map +1 -1
  37. package/dist/release/index.js +17 -0
  38. package/dist/wrangler/cloudflare.d.ts +85 -0
  39. package/dist/wrangler/cloudflare.d.ts.map +1 -0
  40. package/dist/wrangler/cloudflare.js +235 -0
  41. package/dist/wrangler/deploy-environment.d.ts +48 -0
  42. package/dist/wrangler/deploy-environment.d.ts.map +1 -0
  43. package/dist/wrangler/deploy-environment.js +383 -0
  44. package/dist/wrangler/environment.d.ts +58 -0
  45. package/dist/wrangler/environment.d.ts.map +1 -0
  46. package/dist/wrangler/environment.js +297 -0
  47. package/managed/raw/tooling/devenv +71 -0
  48. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +3 -4
  49. package/package.json +11 -4
  50. package/src/cli.ts +33 -3
  51. package/src/github-ci/index.test.ts +221 -1
  52. package/src/github-ci/index.ts +303 -32
  53. package/src/lib/json.ts +1 -1
  54. package/src/lib/workspace.ts +1 -0
  55. package/src/monorepo/__tests__/ci-workflow.test.ts +10 -4
  56. package/src/monorepo/__tests__/pr-preview-cleanup-workflow.test.ts +23 -0
  57. package/src/monorepo/__tests__/publish-workflow.test.ts +12 -4
  58. package/src/monorepo/ci-workflow.ts +16 -6
  59. package/src/monorepo/managed-files.test.ts +87 -23
  60. package/src/monorepo/managed-files.ts +35 -69
  61. package/src/monorepo/package-policy.test.ts +31 -0
  62. package/src/monorepo/package-policy.ts +2 -2
  63. package/src/monorepo/packs/index.ts +2 -0
  64. package/src/monorepo/pr-preview-cleanup-workflow.ts +44 -0
  65. package/src/monorepo/publish-workflow.ts +7 -2
  66. package/src/monorepo/runtime.test.ts +23 -17
  67. package/src/monorepo/runtime.ts +12 -17
  68. package/src/monorepo/tool-validation.test.ts +100 -8
  69. package/src/monorepo/tool-validation.ts +115 -31
  70. package/src/nx/index.test.ts +45 -14
  71. package/src/nx/index.ts +133 -64
  72. package/src/playwright/index.test.ts +90 -0
  73. package/src/playwright/index.ts +73 -0
  74. package/src/release/__tests__/bootstrap-npm-packages.test.ts +63 -2
  75. package/src/release/bootstrap-npm-packages.ts +34 -0
  76. package/src/release/index.ts +18 -0
  77. package/src/wrangler/cloudflare.ts +289 -0
  78. package/src/wrangler/deploy-environment.test.ts +354 -0
  79. package/src/wrangler/deploy-environment.ts +445 -0
  80. package/src/wrangler/environment.test.ts +173 -0
  81. package/src/wrangler/environment.ts +366 -0
  82. package/managed/raw/patches/ttsc@0.19.3.patch +0 -67
@@ -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,
@@ -53,6 +57,28 @@ describe('GitHub CI Nx target expansion', () => {
53
57
  ]);
54
58
  });
55
59
 
60
+ it('selects test owners from target globs and fails if any selected project lacks test', () => {
61
+ const platformProjects = projects.map((project) =>
62
+ project.project === 'mobile' ? { ...project, targets: [...project.targets, 'test'] } : project,
63
+ );
64
+ const expanded = expandNxTargetRuns(platformProjects, {
65
+ targets: 'test',
66
+ projectsWithTargets: '*-macos,*-ios',
67
+ });
68
+
69
+ expect(expanded.runs.map((run) => [run.target, run.projects.map((project) => project.project)])).toEqual([
70
+ ['test', ['desktop', 'mobile']],
71
+ ]);
72
+ const [testRun] = expanded.runs;
73
+ if (!testRun) {
74
+ throw new Error('Expected a test target run.');
75
+ }
76
+ expect(nxRunManyArgs(testRun)).toEqual(['run-many', '-t', 'test', '--projects=desktop,mobile', '--parallel=100%']);
77
+ expect(() => expandNxTargetRuns(projects, { targets: 'test', projectsWithTargets: '*-macos,*-ios' })).toThrow(
78
+ 'Nx target test is missing for project(s) selected by --projects-with-targets *-macos,*-ios: mobile.',
79
+ );
80
+ });
81
+
56
82
  it('reports a zero-match glob as an empty run set', () => {
57
83
  expect(expandNxTargetRuns(projects, { targets: '*-windows' })).toEqual({
58
84
  runs: [],
@@ -155,6 +181,21 @@ describe('collected Nx outputs', () => {
155
181
  });
156
182
  });
157
183
 
184
+ it('runs test for projects selected by platform target ownership', async () => {
185
+ await withNxRunManyFixture(async ({ root }) => {
186
+ await writeFile(join(root, 'packages/app/test-target.ts'), "await Bun.write('test-ran.txt', 'tested');\n");
187
+ await writeFile(join(root, 'packages/app/build-target.ts'), "throw new Error('build must not run here');\n");
188
+
189
+ await githubCiNxRunMany(root, {
190
+ targets: 'test',
191
+ projectsWithTargets: '*-macos,*-ios',
192
+ });
193
+
194
+ await expect(readFile(join(root, 'test-ran.txt'), 'utf8')).resolves.toBe('tested');
195
+ await expect(readFile(join(root, 'build-ran.txt'), 'utf8')).rejects.toThrow();
196
+ });
197
+ });
198
+
158
199
  it('applies an empty manifest after artifact transport omits its empty workspace directory', async () => {
159
200
  await withOutputFixture(async ({ root, artifact }) => {
160
201
  const manifest = await collectNxOutputs(root, artifact, [], SOURCE_SHA);
@@ -472,7 +513,19 @@ async function withNxRunManyFixture(
472
513
  join(root, 'packages/app/package.json'),
473
514
  JSON.stringify({
474
515
  name: '@fixture/app',
475
- nx: { name: 'app', targets: { build: { executor: 'nx:noop' } } },
516
+ nx: {
517
+ name: 'app',
518
+ targets: {
519
+ 'build-macos': {
520
+ executor: 'nx:run-commands',
521
+ options: { command: 'bun packages/app/build-target.ts', cwd: '.' },
522
+ },
523
+ test: {
524
+ executor: 'nx:run-commands',
525
+ options: { command: 'bun packages/app/test-target.ts', cwd: '.' },
526
+ },
527
+ },
528
+ },
476
529
  }),
477
530
  );
478
531
  await $`git init --quiet`.cwd(root);
@@ -484,3 +537,170 @@ async function withNxRunManyFixture(
484
537
  await rm(temp, { recursive: true, force: true });
485
538
  }
486
539
  }
540
+
541
+ describe('event-aware environment deployment', () => {
542
+ it('resolves same-repository PR, private push, release, and explicit production environments', () => {
543
+ expect(
544
+ resolveDeploymentEnvironment(
545
+ undefined,
546
+ { GITHUB_EVENT_NAME: 'pull_request' },
547
+ {
548
+ action: 'synchronize',
549
+ repository: { full_name: 'owner/repo' },
550
+ pull_request: { number: 123, head: { repo: { full_name: 'owner/repo' } } },
551
+ },
552
+ ),
553
+ ).toBe('pr123');
554
+ expect(
555
+ resolveDeploymentEnvironment(undefined, { GITHUB_EVENT_NAME: 'push', GITHUB_REF_NAME: 'private' }, undefined),
556
+ ).toBe('staging');
557
+ expect(resolveDeploymentEnvironment(undefined, { GITHUB_EVENT_NAME: 'release' }, undefined)).toBe('production');
558
+ expect(resolveDeploymentEnvironment('production', {}, undefined)).toBe('production');
559
+ expect(() =>
560
+ resolveDeploymentEnvironment(
561
+ undefined,
562
+ { GITHUB_EVENT_NAME: 'pull_request' },
563
+ {
564
+ action: 'opened',
565
+ repository: { full_name: 'owner/repo' },
566
+ pull_request: { number: 123, head: { repo: { full_name: 'fork/repo' } } },
567
+ },
568
+ ),
569
+ ).toThrow(/same-repository/);
570
+ });
571
+
572
+ it('selects only deploy targets owned by the environment convention', async () => {
573
+ const definitions: Record<string, unknown> = {
574
+ 'conloca-app': {
575
+ targets: {
576
+ deploy: { options: { command: 'smoo wrangler deploy-environment --environment {args.environment}' } },
577
+ },
578
+ },
579
+ 'conloca-app-backend': {
580
+ targets: { deploy: { command: 'smoo wrangler deploy-environment --environment {args.environment}' } },
581
+ },
582
+ 'conloca-oauth-redirect': {
583
+ targets: { deploy: { options: { command: 'wrangler deploy --config wrangler.toml' } } },
584
+ },
585
+ 'conloca-website': {
586
+ targets: { deploy: { options: { command: 'bun scripts/deploy-website.ts' } } },
587
+ },
588
+ };
589
+
590
+ await expect(
591
+ selectEnvironmentDeployProjects(Object.keys(definitions), async (project) => definitions[project]),
592
+ ).resolves.toEqual(['conloca-app', 'conloca-app-backend']);
593
+ });
594
+
595
+ it('publishes GitHub deployment JSON through a real stdin process seam', async () => {
596
+ const calls: Array<{ args: string[]; input: unknown }> = [];
597
+ await publishGithubDeployment(
598
+ 'pr123',
599
+ 'https://app.pr123.conloca.com',
600
+ { GITHUB_REPOSITORY: 'owner/repo', GITHUB_SHA: 'abc123' },
601
+ {
602
+ run: async (args, input) => {
603
+ calls.push({ args, input: JSON.parse(input) });
604
+ return {
605
+ exitCode: 0,
606
+ stdout: calls.length === 1 ? JSON.stringify({ id: 42 }) : '',
607
+ stderr: '',
608
+ };
609
+ },
610
+ },
611
+ );
612
+
613
+ expect(calls).toEqual([
614
+ {
615
+ args: [
616
+ 'api',
617
+ '--method',
618
+ 'POST',
619
+ '-H',
620
+ 'Accept: application/vnd.github+json',
621
+ '/repos/owner/repo/deployments',
622
+ '--input',
623
+ '-',
624
+ ],
625
+ input: {
626
+ ref: 'abc123',
627
+ environment: 'pr123',
628
+ auto_merge: false,
629
+ required_contexts: [],
630
+ transient_environment: true,
631
+ production_environment: false,
632
+ },
633
+ },
634
+ {
635
+ args: [
636
+ 'api',
637
+ '--method',
638
+ 'POST',
639
+ '-H',
640
+ 'Accept: application/vnd.github+json',
641
+ '/repos/owner/repo/deployments/42/statuses',
642
+ '--input',
643
+ '-',
644
+ ],
645
+ input: {
646
+ state: 'success',
647
+ environment: 'pr123',
648
+ environment_url: 'https://app.pr123.conloca.com',
649
+ auto_inactive: false,
650
+ },
651
+ },
652
+ ]);
653
+ });
654
+
655
+ it('deploys app/backend, follows with e2e-deployed, and publishes PR metadata', async () => {
656
+ const nxCalls: string[][] = [];
657
+ const listCalls: Array<[string, string]> = [];
658
+ const summaries: string[] = [];
659
+ const deployments: Array<[string, string]> = [];
660
+
661
+ await githubCiNxDeploy(
662
+ '/repo',
663
+ { mode: 'run-many', name: 'Deploy Environment' },
664
+ {
665
+ processEnv: {
666
+ GITHUB_EVENT_NAME: 'pull_request',
667
+ GITHUB_STEP_SUMMARY: '/summary',
668
+ },
669
+ setStatus: async () => {},
670
+ eventPayload: {
671
+ action: 'opened',
672
+ repository: { full_name: 'owner/repo' },
673
+ pull_request: { number: 123, head: { repo: { full_name: 'owner/repo' } } },
674
+ },
675
+ listProjects: async (_root, target, mode) => {
676
+ listCalls.push([target, mode]);
677
+ return target === 'deploy' ? ['conloca-app', 'conloca-app-backend'] : ['conloca-e2e'];
678
+ },
679
+ runNx: async (args) => {
680
+ nxCalls.push(args);
681
+ return 0;
682
+ },
683
+ appendSummary: async (_path, content) => {
684
+ summaries.push(content);
685
+ },
686
+ publishDeployment: async (environment, url) => {
687
+ deployments.push([environment, url]);
688
+ },
689
+ },
690
+ );
691
+
692
+ expect(listCalls).toEqual([
693
+ ['deploy', 'run-many'],
694
+ ['e2e-deployed', 'run-many'],
695
+ ]);
696
+ expect(nxCalls).toHaveLength(2);
697
+ expect(nxCalls[0]).toContain('--projects=conloca-app,conloca-app-backend');
698
+ expect(nxCalls[0]).toContain('--exclude=tag:permanent-deploy-target');
699
+ expect(nxCalls[0]).toContain('--environment=pr123');
700
+ expect(nxCalls[1]).toContain('-t');
701
+ expect(nxCalls[1]).toContain('e2e-deployed');
702
+ expect(nxCalls[1]).toContain('--environment=pr123');
703
+ expect(summaries).toEqual(['## pr123 deployment\n\n[View deployment](https://app.pr123.conloca.com)\n']);
704
+ expect(deployments).toEqual([['pr123', 'https://app.pr123.conloca.com']]);
705
+ });
706
+ });
@@ -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
 
@@ -168,6 +190,7 @@ export interface NxRunManyOptions {
168
190
  configuration?: string;
169
191
  collectOutputs?: string;
170
192
  allowEmptyProjects?: boolean;
193
+ projectsWithTargets?: string;
171
194
  }
172
195
 
173
196
  export interface ExpandedNxTargetRuns {
@@ -176,10 +199,18 @@ export interface ExpandedNxTargetRuns {
176
199
  }
177
200
 
178
201
  export function expandNxTargetRuns(projects: ProjectTargets[], options: NxRunManyOptions): ExpandedNxTargetRuns {
179
- const selectedProjects = selectProjects(projects, options.projects);
180
- if (options.projects !== undefined && selectedProjects.length === 0 && options.allowEmptyProjects !== true) {
202
+ const namedProjects = selectProjects(projects, options.projects);
203
+ if (options.projects !== undefined && namedProjects.length === 0 && options.allowEmptyProjects !== true) {
181
204
  throw new Error(`No Nx projects matched --projects ${options.projects}.`);
182
205
  }
206
+ const selectedProjects = selectProjectsWithTargets(namedProjects, options.projectsWithTargets);
207
+ if (
208
+ options.projectsWithTargets !== undefined &&
209
+ selectedProjects.length === 0 &&
210
+ options.allowEmptyProjects !== true
211
+ ) {
212
+ throw new Error(`No Nx projects own targets matching --projects-with-targets ${options.projectsWithTargets}.`);
213
+ }
183
214
  const selectedTargetNames = [...new Set(selectedProjects.flatMap((project) => project.targets))].sort((a, b) =>
184
215
  a.localeCompare(b),
185
216
  );
@@ -195,6 +226,16 @@ export function expandNxTargetRuns(projects: ProjectTargets[], options: NxRunMan
195
226
  unmatchedGlobs.push(targetPattern);
196
227
  }
197
228
  for (const target of targets) {
229
+ if (!isGlob && options.projectsWithTargets !== undefined) {
230
+ const missingProjects = selectedProjects
231
+ .filter((project) => !project.targets.includes(target))
232
+ .map((project) => project.project);
233
+ if (missingProjects.length > 0) {
234
+ throw new Error(
235
+ `Nx target ${target} is missing for project(s) selected by --projects-with-targets ${options.projectsWithTargets}: ${missingProjects.join(', ')}.`,
236
+ );
237
+ }
238
+ }
198
239
  if (addedTargets.has(target)) {
199
240
  continue;
200
241
  }
@@ -321,6 +362,16 @@ function selectProjects(projects: ProjectTargets[], selectors: string | undefine
321
362
  .sort((left, right) => left.project.localeCompare(right.project));
322
363
  }
323
364
 
365
+ function selectProjectsWithTargets(projects: ProjectTargets[], selectors: string | undefined): ProjectTargets[] {
366
+ if (selectors === undefined) {
367
+ return projects;
368
+ }
369
+ const patterns = commaSeparatedValues(selectors);
370
+ return projects.filter((project) =>
371
+ project.targets.some((target) => patterns.some((pattern) => new Bun.Glob(pattern).match(target))),
372
+ );
373
+ }
374
+
324
375
  function commaSeparatedValues(value: string): string[] {
325
376
  return value
326
377
  .split(',')
@@ -328,35 +379,102 @@ function commaSeparatedValues(value: string): string[] {
328
379
  .filter(Boolean);
329
380
  }
330
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
+
331
400
  export async function githubCiNxDeploy(
332
401
  root: string,
333
- options: { configuration: string; mode?: NxSmartMode; name?: string; step?: string; verify?: boolean },
402
+ options: GithubCiNxDeployOptions,
403
+ dependencies: GithubCiNxDeployDependencies = {},
334
404
  ): Promise<void> {
335
- 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';
336
409
  const step = options.step ?? '';
337
- 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');
338
415
  const mode = resolveNxSmartMode(options.mode ?? 'run-many');
339
- 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);
340
419
  if (projects.length === 0) {
341
- console.log(`No ${mode} deploy projects with configuration ${options.configuration}; skipping.`);
342
- await updateGithubStatus(name, 'success', step);
420
+ console.log(`No ${mode} deploy projects; skipping ${environment}.`);
421
+ await setStatus('success');
343
422
  return;
344
423
  }
345
424
 
346
425
  const projectList = projects.join(',');
347
426
  const targets = options.verify === true ? ['build', 'lint', 'test', 'deploy'] : ['deploy'];
348
427
  for (const target of targets) {
349
- 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
+ ];
350
436
  if (target === 'deploy') {
351
- nxArgs.push(`--configuration=${options.configuration}`);
437
+ nxArgs.push(`--environment=${environment}`);
352
438
  }
353
- const status = await runStatus('nx', nxArgs, root);
439
+ const status = await runNx(nxArgs, root);
354
440
  if (status !== 0) {
355
- await updateGithubStatus(name, 'failure', step);
441
+ await setStatus('failure');
356
442
  throw new Error(`nx ${nxArgs.join(' ')} failed with exit code ${status}`);
357
443
  }
358
444
  }
359
- 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');
360
478
  }
361
479
 
362
480
  function resolveNxSmartMode(mode: NxSmartMode): 'affected' | 'run-many' {
@@ -390,30 +508,183 @@ function eventDefaultBranch(): string | undefined {
390
508
  }
391
509
  }
392
510
 
393
- 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(
394
550
  root: string,
395
- configuration: string,
551
+ target: string,
396
552
  mode: 'affected' | 'run-many',
397
553
  ): Promise<string[]> {
398
- const listArgs =
399
- mode === 'affected'
400
- ? ['show', 'projects', '--affected', '--withTarget', 'deploy', '--json']
401
- : ['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');
402
559
  const result = await $`nx ${listArgs}`.cwd(root).quiet();
403
- const candidates = nxProjectList(decode(result.stdout));
404
- 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[] = [];
405
575
  for (const project of candidates) {
406
- if (await deployTargetHasConfiguration(root, project, configuration)) {
407
- 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);
408
582
  }
409
583
  }
410
- 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;
411
591
  }
412
592
 
413
- async function deployTargetHasConfiguration(root: string, project: string, configuration: string): Promise<boolean> {
414
- const result = await $`nx show project ${project} --json`.cwd(root).quiet();
415
- const parsed = parseNxProjectDeployConfigurations(decode(result.stdout));
416
- return parsed?.targets?.deploy?.configurations?.[configuration] !== undefined;
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
+ }
618
+ }
619
+
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
+ }
417
688
  }
418
689
 
419
690
  function nxProjectList(output: string): string[] {