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