@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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +24 -1
- package/dist/github-ci/index.d.ts +44 -4
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +220 -34
- package/dist/monorepo/ci-workflow.js +16 -6
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +19 -1
- package/dist/monorepo/pr-preview-cleanup-workflow.d.ts +5 -0
- package/dist/monorepo/pr-preview-cleanup-workflow.d.ts.map +1 -0
- package/dist/monorepo/pr-preview-cleanup-workflow.js +38 -0
- package/dist/monorepo/publish-workflow.js +3 -3
- package/dist/monorepo/tool-validation.d.ts.map +1 -1
- package/dist/monorepo/tool-validation.js +85 -5
- package/dist/playwright/index.d.ts +22 -0
- package/dist/playwright/index.d.ts.map +1 -0
- package/dist/playwright/index.js +44 -0
- package/dist/release/bootstrap-npm-packages.d.ts +3 -0
- package/dist/release/bootstrap-npm-packages.d.ts.map +1 -1
- package/dist/release/bootstrap-npm-packages.js +21 -0
- package/dist/release/index.d.ts +1 -0
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +31 -6
- package/dist/wrangler/cloudflare.d.ts +87 -0
- package/dist/wrangler/cloudflare.d.ts.map +1 -0
- package/dist/wrangler/cloudflare.js +238 -0
- package/dist/wrangler/deploy-environment.d.ts +48 -0
- package/dist/wrangler/deploy-environment.d.ts.map +1 -0
- package/dist/wrangler/deploy-environment.js +383 -0
- package/dist/wrangler/environment.d.ts +58 -0
- package/dist/wrangler/environment.d.ts.map +1 -0
- package/dist/wrangler/environment.js +297 -0
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +3 -4
- package/package.json +9 -2
- package/src/cli.ts +27 -3
- package/src/github-ci/index.test.ts +175 -2
- package/src/github-ci/index.ts +274 -31
- package/src/monorepo/__tests__/ci-workflow.test.ts +10 -4
- package/src/monorepo/__tests__/pr-preview-cleanup-workflow.test.ts +23 -0
- package/src/monorepo/__tests__/publish-workflow.test.ts +7 -5
- package/src/monorepo/ci-workflow.ts +16 -6
- package/src/monorepo/managed-files.test.ts +56 -1
- package/src/monorepo/managed-files.ts +20 -1
- package/src/monorepo/pr-preview-cleanup-workflow.ts +44 -0
- package/src/monorepo/publish-workflow.ts +8 -5
- package/src/monorepo/tool-validation.test.ts +85 -0
- package/src/monorepo/tool-validation.ts +94 -5
- package/src/playwright/index.test.ts +90 -0
- package/src/playwright/index.ts +73 -0
- package/src/release/__tests__/bootstrap-npm-packages.test.ts +63 -2
- package/src/release/bootstrap-npm-packages.ts +34 -0
- package/src/release/index.ts +30 -4
- package/src/wrangler/cloudflare.test.ts +76 -0
- package/src/wrangler/cloudflare.ts +292 -0
- package/src/wrangler/deploy-environment.test.ts +354 -0
- package/src/wrangler/deploy-environment.ts +445 -0
- package/src/wrangler/environment.test.ts +173 -0
- 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
|
+
});
|
package/src/github-ci/index.ts
CHANGED
|
@@ -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
|
|
34
|
+
interface NxProjectDeployTarget {
|
|
18
35
|
targets?: {
|
|
19
36
|
deploy?: {
|
|
20
|
-
|
|
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
|
|
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<
|
|
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:
|
|
403
|
+
options: GithubCiNxDeployOptions,
|
|
404
|
+
dependencies: GithubCiNxDeployDependencies = {},
|
|
363
405
|
): Promise<void> {
|
|
364
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
371
|
-
await
|
|
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 = [
|
|
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(`--
|
|
438
|
+
nxArgs.push(`--environment=${environment}`);
|
|
381
439
|
}
|
|
382
|
-
const status = await
|
|
440
|
+
const status = await runNx(nxArgs, root);
|
|
383
441
|
if (status !== 0) {
|
|
384
|
-
await
|
|
442
|
+
await setStatus('failure');
|
|
385
443
|
throw new Error(`nx ${nxArgs.join(' ')} failed with exit code ${status}`);
|
|
386
444
|
}
|
|
387
445
|
}
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
552
|
+
target: string,
|
|
425
553
|
mode: 'affected' | 'run-many',
|
|
426
554
|
): Promise<string[]> {
|
|
427
|
-
const listArgs =
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
|
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
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 --
|
|
266
|
+
'smoo github-ci nx-deploy --environment production --mode run-many --verify --name "Deploy Production"',
|
|
265
267
|
);
|
|
266
268
|
});
|
|
267
269
|
|