@smoothbricks/cli 0.10.8 → 0.10.10
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.js +7 -5
- package/dist/github-ci/index.d.ts +9 -7
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +36 -35
- package/dist/monorepo/ci-workflow.d.ts +3 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +59 -15
- package/dist/monorepo/managed-files.d.ts +1 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +15 -5
- package/dist/monorepo/publish-workflow.d.ts +2 -2
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +7 -7
- package/dist/release/index.d.ts +1 -0
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +14 -6
- package/dist/wrangler/cloudflare.d.ts +4 -2
- package/dist/wrangler/cloudflare.d.ts.map +1 -1
- package/dist/wrangler/cloudflare.js +6 -3
- package/dist/wrangler/{deploy-environment.d.ts → deploy-stage.d.ts} +6 -6
- package/dist/wrangler/deploy-stage.d.ts.map +1 -0
- package/dist/wrangler/{deploy-environment.js → deploy-stage.js} +26 -26
- package/dist/wrangler/stage.d.ts +58 -0
- package/dist/wrangler/stage.d.ts.map +1 -0
- package/dist/wrangler/{environment.js → stage.js} +44 -44
- package/package.json +8 -8
- package/src/cli.ts +11 -8
- package/src/github-ci/index.test.ts +105 -34
- package/src/github-ci/index.ts +53 -46
- package/src/monorepo/__tests__/ci-workflow.test.ts +87 -49
- package/src/monorepo/__tests__/publish-workflow.test.ts +14 -12
- package/src/monorepo/ci-workflow.ts +67 -14
- package/src/monorepo/managed-files.test.ts +26 -5
- package/src/monorepo/managed-files.ts +18 -5
- package/src/monorepo/publish-workflow.ts +15 -12
- package/src/release/index.ts +12 -4
- package/src/wrangler/cloudflare.test.ts +76 -0
- package/src/wrangler/cloudflare.ts +6 -3
- package/src/wrangler/{deploy-environment.test.ts → deploy-stage.test.ts} +11 -11
- package/src/wrangler/{deploy-environment.ts → deploy-stage.ts} +41 -41
- package/src/wrangler/{environment.test.ts → stage.test.ts} +15 -15
- package/src/wrangler/{environment.ts → stage.ts} +49 -52
- package/dist/wrangler/deploy-environment.d.ts.map +0 -1
- package/dist/wrangler/environment.d.ts +0 -58
- package/dist/wrangler/environment.d.ts.map +0 -1
package/src/github-ci/index.ts
CHANGED
|
@@ -7,12 +7,7 @@ import typia from 'typia';
|
|
|
7
7
|
import { parseStringArrayText } from '../lib/json.js';
|
|
8
8
|
import { decode, run, runStatus } from '../lib/run.js';
|
|
9
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';
|
|
10
|
+
import { type DeploymentStage, isPullRequestStage, parseDeploymentStage, pullRequestStage } from '../wrangler/stage.js';
|
|
16
11
|
import type { NxTargetRun } from './outputs.js';
|
|
17
12
|
|
|
18
13
|
export interface GithubActionsEventPayload {
|
|
@@ -159,24 +154,32 @@ async function addReferencesFrom(roots: Set<string>, path: string, cwd: string):
|
|
|
159
154
|
}
|
|
160
155
|
}
|
|
161
156
|
|
|
162
|
-
export function nxSmartArgs(
|
|
157
|
+
export function nxSmartArgs(
|
|
158
|
+
target: string,
|
|
159
|
+
mode: 'affected' | 'run-many',
|
|
160
|
+
configuration?: string,
|
|
161
|
+
stage?: string,
|
|
162
|
+
): string[] {
|
|
163
163
|
const args = [mode, '-t', target];
|
|
164
164
|
if (configuration) {
|
|
165
165
|
args.push(`--configuration=${configuration}`);
|
|
166
166
|
}
|
|
167
|
+
if (stage) {
|
|
168
|
+
args.push(`--stage=${stage}`);
|
|
169
|
+
}
|
|
167
170
|
args.push(`--exclude=tag:ci:skip:${target}`, `--parallel=${NX_PARALLEL}`);
|
|
168
171
|
return args;
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
export async function githubCiNxSmart(
|
|
172
175
|
root: string,
|
|
173
|
-
options: { target: string; name?: string; step?: string; mode?: NxSmartMode; configuration?: string },
|
|
176
|
+
options: { target: string; name?: string; step?: string; mode?: NxSmartMode; configuration?: string; stage?: string },
|
|
174
177
|
): Promise<void> {
|
|
175
178
|
const name = options.name ?? options.target;
|
|
176
179
|
const step = options.step ?? '';
|
|
177
180
|
await createGithubStatus(name, step);
|
|
178
181
|
const mode = resolveNxSmartMode(options.mode ?? 'auto');
|
|
179
|
-
const nxArgs = nxSmartArgs(options.target, mode, options.configuration);
|
|
182
|
+
const nxArgs = nxSmartArgs(options.target, mode, options.configuration, options.stage);
|
|
180
183
|
const status = await runStatus('nx', nxArgs, root);
|
|
181
184
|
await updateGithubStatus(name, status === 0 ? 'success' : 'failure', step);
|
|
182
185
|
if (status !== 0) {
|
|
@@ -315,7 +318,7 @@ export async function readGitHeadSha(root: string): Promise<string> {
|
|
|
315
318
|
return decode((await $`git rev-parse HEAD`.cwd(root).quiet()).stdout).trim();
|
|
316
319
|
}
|
|
317
320
|
|
|
318
|
-
export async function githubCiNxRunMany(root: string, options: NxRunManyOptions): Promise<
|
|
321
|
+
export async function githubCiNxRunMany(root: string, options: NxRunManyOptions): Promise<ExpandedNxTargetRuns> {
|
|
319
322
|
const expanded = expandNxTargetRuns(await readProjectTargets(root), options);
|
|
320
323
|
if (expanded.unmatchedGlobs.length > 0) {
|
|
321
324
|
console.log(`No Nx targets matched target glob(s): ${expanded.unmatchedGlobs.join(', ')}; skipping.`);
|
|
@@ -328,6 +331,7 @@ export async function githubCiNxRunMany(root: string, options: NxRunManyOptions)
|
|
|
328
331
|
const sourceSha = await readGitHeadSha(root);
|
|
329
332
|
await collectNxOutputs(root, options.collectOutputs, expandNxTargetDependencyRuns(expanded.runs), sourceSha);
|
|
330
333
|
}
|
|
334
|
+
return expanded;
|
|
331
335
|
}
|
|
332
336
|
|
|
333
337
|
export async function githubCiApplyOutputs(
|
|
@@ -380,7 +384,7 @@ function commaSeparatedValues(value: string): string[] {
|
|
|
380
384
|
}
|
|
381
385
|
|
|
382
386
|
export interface GithubCiNxDeployOptions {
|
|
383
|
-
|
|
387
|
+
stage?: string;
|
|
384
388
|
mode?: NxSmartMode;
|
|
385
389
|
name?: string;
|
|
386
390
|
step?: string;
|
|
@@ -391,7 +395,8 @@ export interface GithubCiNxDeployDependencies {
|
|
|
391
395
|
listProjects?: (root: string, target: string, mode: 'affected' | 'run-many') => Promise<string[]>;
|
|
392
396
|
runNx?: (args: string[], root: string) => Promise<number>;
|
|
393
397
|
appendSummary?: (summaryPath: string, content: string) => Promise<void>;
|
|
394
|
-
|
|
398
|
+
appendOutput?: (outputPath: string, content: string) => Promise<void>;
|
|
399
|
+
publishDeployment?: (stage: `pr${number}`, url: string) => Promise<void>;
|
|
395
400
|
setStatus?: (state: 'pending' | 'success' | 'failure') => Promise<void>;
|
|
396
401
|
processEnv?: NodeJS.ProcessEnv;
|
|
397
402
|
eventPayload?: GithubActionsEventPayload;
|
|
@@ -404,8 +409,8 @@ export async function githubCiNxDeploy(
|
|
|
404
409
|
): Promise<void> {
|
|
405
410
|
const processEnv = dependencies.processEnv ?? process.env;
|
|
406
411
|
const eventPayload = dependencies.eventPayload ?? readGithubActionsEvent(processEnv);
|
|
407
|
-
const
|
|
408
|
-
const name = options.name ?? 'Deploy
|
|
412
|
+
const stage = resolveDeploymentStage(options.stage, processEnv, eventPayload);
|
|
413
|
+
const name = options.name ?? 'Deploy Stage';
|
|
409
414
|
const step = options.step ?? '';
|
|
410
415
|
const setStatus =
|
|
411
416
|
dependencies.setStatus ??
|
|
@@ -417,7 +422,7 @@ export async function githubCiNxDeploy(
|
|
|
417
422
|
const runNx = dependencies.runNx ?? ((args: string[], commandRoot: string) => runStatus('nx', args, commandRoot));
|
|
418
423
|
const projects = await listProjects(root, 'deploy', mode);
|
|
419
424
|
if (projects.length === 0) {
|
|
420
|
-
console.log(`No ${mode} deploy projects; skipping ${
|
|
425
|
+
console.log(`No ${mode} deploy projects; skipping ${stage}.`);
|
|
421
426
|
await setStatus('success');
|
|
422
427
|
return;
|
|
423
428
|
}
|
|
@@ -434,7 +439,7 @@ export async function githubCiNxDeploy(
|
|
|
434
439
|
`--parallel=${NX_PARALLEL}`,
|
|
435
440
|
];
|
|
436
441
|
if (target === 'deploy') {
|
|
437
|
-
nxArgs.push(`--
|
|
442
|
+
nxArgs.push(`--stage=${stage}`);
|
|
438
443
|
}
|
|
439
444
|
const status = await runNx(nxArgs, root);
|
|
440
445
|
if (status !== 0) {
|
|
@@ -443,37 +448,39 @@ export async function githubCiNxDeploy(
|
|
|
443
448
|
}
|
|
444
449
|
}
|
|
445
450
|
|
|
446
|
-
if (
|
|
447
|
-
const url = `https://app.${
|
|
451
|
+
if (isPullRequestStage(stage)) {
|
|
452
|
+
const url = `https://app.${stage}.conloca.com`;
|
|
448
453
|
const summaryPath = processEnv.GITHUB_STEP_SUMMARY;
|
|
449
454
|
if (summaryPath) {
|
|
450
455
|
const appendSummary = dependencies.appendSummary ?? appendFile;
|
|
451
|
-
await appendSummary(
|
|
456
|
+
await appendSummary(
|
|
457
|
+
summaryPath,
|
|
458
|
+
`## ${stage} deployment
|
|
459
|
+
|
|
460
|
+
[View deployment](${url})
|
|
461
|
+
`,
|
|
462
|
+
);
|
|
452
463
|
}
|
|
453
464
|
const publishDeployment =
|
|
454
465
|
dependencies.publishDeployment ??
|
|
455
466
|
((token: `pr${number}`, deploymentUrl: string) => publishGithubDeployment(token, deploymentUrl, processEnv));
|
|
456
|
-
await publishDeployment(
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
await setStatus('failure');
|
|
472
|
-
throw new Error(`nx ${nxArgs.join(' ')} failed with exit code ${status}`);
|
|
473
|
-
}
|
|
467
|
+
await publishDeployment(stage, url);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const outputPath = processEnv.GITHUB_OUTPUT;
|
|
471
|
+
if (outputPath) {
|
|
472
|
+
try {
|
|
473
|
+
const appendOutput = dependencies.appendOutput ?? appendFile;
|
|
474
|
+
await appendOutput(
|
|
475
|
+
outputPath,
|
|
476
|
+
`stage=${stage}
|
|
477
|
+
`,
|
|
478
|
+
);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
await setStatus('failure');
|
|
481
|
+
throw error;
|
|
474
482
|
}
|
|
475
483
|
}
|
|
476
|
-
|
|
477
484
|
await setStatus('success');
|
|
478
485
|
}
|
|
479
486
|
|
|
@@ -508,12 +515,12 @@ function eventDefaultBranch(): string | undefined {
|
|
|
508
515
|
}
|
|
509
516
|
}
|
|
510
517
|
|
|
511
|
-
export function
|
|
518
|
+
export function resolveDeploymentStage(
|
|
512
519
|
explicit: string | undefined,
|
|
513
520
|
environment: NodeJS.ProcessEnv,
|
|
514
521
|
event: GithubActionsEventPayload | undefined,
|
|
515
|
-
):
|
|
516
|
-
if (explicit !== undefined) return
|
|
522
|
+
): DeploymentStage {
|
|
523
|
+
if (explicit !== undefined) return parseDeploymentStage(explicit);
|
|
517
524
|
if (environment.GITHUB_EVENT_NAME === 'pull_request') {
|
|
518
525
|
if (!event?.action || !['opened', 'reopened', 'synchronize'].includes(event.action)) {
|
|
519
526
|
throw new Error('Pull-request deployment runs only for opened, reopened, or synchronize events.');
|
|
@@ -525,7 +532,7 @@ export function resolveDeploymentEnvironment(
|
|
|
525
532
|
}
|
|
526
533
|
const number = event.pull_request?.number;
|
|
527
534
|
if (number === undefined) throw new Error('GitHub pull_request event is missing its PR number.');
|
|
528
|
-
return
|
|
535
|
+
return pullRequestStage(number);
|
|
529
536
|
}
|
|
530
537
|
if (environment.GITHUB_EVENT_NAME === 'push' && environment.GITHUB_REF_NAME === 'private') {
|
|
531
538
|
return 'staging';
|
|
@@ -533,7 +540,7 @@ export function resolveDeploymentEnvironment(
|
|
|
533
540
|
if (environment.GITHUB_EVENT_NAME === 'release') {
|
|
534
541
|
return 'production';
|
|
535
542
|
}
|
|
536
|
-
throw new Error('Cannot resolve a deployment
|
|
543
|
+
throw new Error('Cannot resolve a deployment stage from this GitHub event; pass --stage explicitly.');
|
|
537
544
|
}
|
|
538
545
|
|
|
539
546
|
function readGithubActionsEvent(environment: NodeJS.ProcessEnv): GithubActionsEventPayload | undefined {
|
|
@@ -559,7 +566,7 @@ async function listNxProjectsWithTarget(
|
|
|
559
566
|
const result = await $`nx ${listArgs}`.cwd(root).quiet();
|
|
560
567
|
const candidates = nxProjectList(decode(result.stdout)).sort((left, right) => left.localeCompare(right));
|
|
561
568
|
if (target !== 'deploy') return candidates;
|
|
562
|
-
return
|
|
569
|
+
return selectStageDeployProjects(candidates, async (project) => {
|
|
563
570
|
const projectResult = await $`nx show project ${project} --json`.cwd(root).quiet();
|
|
564
571
|
const parsed = parseNxProjectDeployTarget(decode(projectResult.stdout));
|
|
565
572
|
if (!parsed) throw new Error(`nx show project ${project} returned invalid JSON.`);
|
|
@@ -567,7 +574,7 @@ async function listNxProjectsWithTarget(
|
|
|
567
574
|
});
|
|
568
575
|
}
|
|
569
576
|
|
|
570
|
-
export async function
|
|
577
|
+
export async function selectStageDeployProjects(
|
|
571
578
|
candidates: string[],
|
|
572
579
|
loadProject: (project: string) => Promise<unknown>,
|
|
573
580
|
): Promise<string[]> {
|
|
@@ -577,7 +584,7 @@ export async function selectEnvironmentDeployProjects(
|
|
|
577
584
|
if (!isNxProjectDeployTarget(definition)) continue;
|
|
578
585
|
const deploy = definition.targets?.deploy;
|
|
579
586
|
const commandValue = deploy?.options?.command ?? deploy?.command;
|
|
580
|
-
if (typeof commandValue === 'string' && commandValue.includes('smoo wrangler deploy-
|
|
587
|
+
if (typeof commandValue === 'string' && commandValue.includes('smoo wrangler deploy-stage')) {
|
|
581
588
|
selected.push(project);
|
|
582
589
|
}
|
|
583
590
|
}
|
|
@@ -3,58 +3,109 @@
|
|
|
3
3
|
import { describe, expect, it } from 'bun:test';
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type CiWorkflowDefinitionOptions,
|
|
8
|
+
CiWorkflowStepKind,
|
|
9
|
+
defineCiWorkflow,
|
|
10
|
+
renderCiWorkflowYaml,
|
|
11
|
+
} from '../ci-workflow.js';
|
|
7
12
|
|
|
8
13
|
const nixosRunsOn = ['nixos-latest-x64', 'self-hosted'] as const;
|
|
9
14
|
|
|
15
|
+
function options(overrides: Partial<CiWorkflowDefinitionOptions> = {}): CiWorkflowDefinitionOptions {
|
|
16
|
+
return {
|
|
17
|
+
deploy: false,
|
|
18
|
+
browserTests: false,
|
|
19
|
+
e2eDeployment: false,
|
|
20
|
+
pushBranches: ['main'],
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
describe('CI workflow definition', () => {
|
|
11
26
|
it('renders the checked-in local CI workflow copy', async () => {
|
|
12
|
-
const rendered = renderCiWorkflowYaml({
|
|
13
|
-
deploy: false,
|
|
14
|
-
pushBranches: ['main'],
|
|
15
|
-
runsOn: [...nixosRunsOn],
|
|
16
|
-
});
|
|
27
|
+
const rendered = renderCiWorkflowYaml(options({ runsOn: [...nixosRunsOn] }));
|
|
17
28
|
const packageRoot = join(import.meta.dir, '..', '..', '..');
|
|
18
29
|
|
|
19
30
|
await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/ci.yml'), 'utf8')).resolves.toBe(rendered);
|
|
20
31
|
});
|
|
21
32
|
|
|
22
|
-
it('
|
|
23
|
-
const
|
|
24
|
-
const
|
|
33
|
+
it('deploys immediately after build and renumbers following steps', () => {
|
|
34
|
+
const definition = options({ deploy: true, browserTests: true });
|
|
35
|
+
const steps = defineCiWorkflow(definition);
|
|
36
|
+
const rendered = renderCiWorkflowYaml(definition);
|
|
25
37
|
|
|
26
|
-
expect(steps.map((step) => [step.kind, step.number])).
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
expect(steps.map((step) => [step.kind, step.number])).toEqual([
|
|
39
|
+
[CiWorkflowStepKind.Checkout, 2],
|
|
40
|
+
[CiWorkflowStepKind.SetupDevenv, 3],
|
|
41
|
+
[CiWorkflowStepKind.SetNxShas, 4],
|
|
42
|
+
[CiWorkflowStepKind.RestoreNxCache, 5],
|
|
43
|
+
[CiWorkflowStepKind.Build, 6],
|
|
44
|
+
[CiWorkflowStepKind.Deploy, 7],
|
|
45
|
+
[CiWorkflowStepKind.Lint, 8],
|
|
46
|
+
[CiWorkflowStepKind.UnitTests, 9],
|
|
47
|
+
[CiWorkflowStepKind.BrowserTests, 10],
|
|
48
|
+
[CiWorkflowStepKind.ManagedFilesCheck, 11],
|
|
49
|
+
[CiWorkflowStepKind.ManagedFilesDispatch, 12],
|
|
50
|
+
[CiWorkflowStepKind.SaveNxCache, 13],
|
|
51
|
+
[CiWorkflowStepKind.UploadTraceDbs, 14],
|
|
52
|
+
[CiWorkflowStepKind.SaveNixDevenv, 15],
|
|
53
|
+
]);
|
|
54
|
+
expect(rendered.match(/- name: 🚀 Deploy Stage/g)).toHaveLength(1);
|
|
55
|
+
expect(rendered).toContain('id: deploy');
|
|
56
|
+
expect(rendered).toContain('smoo github-ci nx-deploy --mode run-many --name "Deploy Stage" --step 7');
|
|
57
|
+
expect(rendered).toContain('smoo github-ci nx-smart --target test-browser --name "Browser Tests" --step 10');
|
|
58
|
+
expect(rendered).toContain('group: ${{ github.workflow }}-${{ github.ref }}');
|
|
59
|
+
expect(rendered).toContain('cancel-in-progress: true');
|
|
31
60
|
expect(rendered).toContain('github.event.pull_request.head.repo.full_name == github.repository');
|
|
32
61
|
expect(rendered).toContain("github.ref == 'refs/heads/private'");
|
|
33
|
-
expect(rendered).toContain("# Step
|
|
34
|
-
expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
|
|
35
|
-
expect(rendered).toContain('id: setup');
|
|
36
|
-
expect(rendered).not.toContain('Setup Nix/devenv (fork)');
|
|
37
|
-
expect(rendered).not.toContain('Setup Nix/devenv (nixos)');
|
|
38
|
-
expect(rendered).not.toContain('github-actions-bootstrap.sh');
|
|
39
|
-
expect(rendered).toContain('uses: ./.github/actions/save-nix-devenv');
|
|
40
|
-
expect(rendered).toContain('runs-on: ubuntu-latest');
|
|
62
|
+
expect(rendered).toContain("# Step 13\n # Nx's database cache needs artifact files");
|
|
41
63
|
});
|
|
42
64
|
|
|
43
|
-
it('adds Cloudflare credentials for Wrangler-backed deploys', () => {
|
|
44
|
-
const rendered = renderCiWorkflowYaml({ deploy: true, deployProvider: 'cloudflare'
|
|
65
|
+
it('adds only generic Cloudflare credentials for Wrangler-backed deploys', () => {
|
|
66
|
+
const rendered = renderCiWorkflowYaml(options({ deploy: true, deployProvider: 'cloudflare' }));
|
|
45
67
|
|
|
46
68
|
expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
|
|
47
69
|
expect(rendered).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
70
|
+
for (const appSecret of [
|
|
71
|
+
'GITHUB_CLIENT_SECRET',
|
|
72
|
+
'GITHUB_APP_PRIVATE_KEY',
|
|
73
|
+
'GITHUB_APP_PRIVATE_KEY_PEM',
|
|
74
|
+
'OAUTH_STATE_SIGNING_KEY',
|
|
75
|
+
'MAIL_CAPTURE_CONTROL_TOKEN',
|
|
76
|
+
'TOKEN_ENCRYPTION_KEY',
|
|
77
|
+
'E2E_CONTROL_TOKEN',
|
|
78
|
+
]) {
|
|
79
|
+
expect(rendered).not.toContain(appSecret);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('renders deployment E2E as a dependent job with an independent stage input', () => {
|
|
84
|
+
const rendered = renderCiWorkflowYaml(options({ deploy: true, e2eDeployment: true, runsOn: [...nixosRunsOn] }));
|
|
85
|
+
|
|
86
|
+
expect(rendered).toContain('deployment-stage: ${{ steps.deploy.outputs.stage }}');
|
|
87
|
+
expect(rendered).toContain(' e2e-deployment:\n name: Deployment E2E\n needs: main');
|
|
88
|
+
expect(rendered).toContain(
|
|
89
|
+
"if: ${{ needs.main.result == 'success' && needs.main.outputs.deployment-stage != '' }}",
|
|
90
|
+
);
|
|
91
|
+
expect(rendered).toContain('timeout-minutes: 15');
|
|
92
|
+
expect(rendered).toContain(
|
|
93
|
+
'smoo github-ci nx-smart --target e2e-deployment --mode run-many --stage "${{ needs.main.outputs.deployment-stage }}" --name "Deployment E2E" --step 4',
|
|
94
|
+
);
|
|
95
|
+
expect(rendered.match(/name: Deployment E2E/g)).toHaveLength(2);
|
|
96
|
+
expect(rendered).not.toContain('E2E_CONTROL_TOKEN');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('omits optional browser and deployment-E2E lanes when disabled', () => {
|
|
100
|
+
const rendered = renderCiWorkflowYaml(options({ deploy: true }));
|
|
101
|
+
|
|
102
|
+
expect(rendered).not.toContain('--target test-browser');
|
|
103
|
+
expect(rendered).not.toContain(' e2e-deployment:');
|
|
104
|
+
expect(rendered).not.toContain('deployment-stage:');
|
|
54
105
|
});
|
|
55
106
|
|
|
56
107
|
it('uses the same architecture-scoped key to restore and save the Nx cache', async () => {
|
|
57
|
-
const rendered = renderCiWorkflowYaml(
|
|
108
|
+
const rendered = renderCiWorkflowYaml(options());
|
|
58
109
|
const packageRoot = join(import.meta.dir, '..', '..', '..');
|
|
59
110
|
const restoreAction = await readFile(join(packageRoot, '..', '..', '.github/actions/cache-nx/action.yml'), 'utf8');
|
|
60
111
|
const restoreKey = restoreAction.match(/^\s*key: (.+)$/m)?.[1];
|
|
@@ -64,26 +115,13 @@ describe('CI workflow definition', () => {
|
|
|
64
115
|
expect(saveKey).toBe(restoreKey);
|
|
65
116
|
});
|
|
66
117
|
|
|
67
|
-
it('nixos config
|
|
68
|
-
const rendered = renderCiWorkflowYaml({
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
runsOn: [...nixosRunsOn],
|
|
72
|
-
});
|
|
118
|
+
it('nixos config gates both jobs away from private runners for fork PRs', () => {
|
|
119
|
+
const rendered = renderCiWorkflowYaml(options({ deploy: true, e2eDeployment: true, runsOn: [...nixosRunsOn] }));
|
|
120
|
+
const runnerExpression =
|
|
121
|
+
"runs-on:\n ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&\n fromJSON('[\"nixos-latest-x64\",\"self-hosted\"]') || 'ubuntu-latest' }}";
|
|
73
122
|
|
|
74
|
-
expect(rendered).
|
|
75
|
-
"runs-on:\n ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&\n fromJSON('[\"nixos-latest-x64\",\"self-hosted\"]') || 'ubuntu-latest' }}",
|
|
76
|
-
);
|
|
123
|
+
expect(rendered.match(new RegExp(runnerExpression.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))).toHaveLength(2);
|
|
77
124
|
expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
|
|
78
|
-
expect(rendered).toContain('id: setup');
|
|
79
|
-
expect(rendered).not.toContain('Setup Nix/devenv (fork)');
|
|
80
|
-
expect(rendered).not.toContain('Setup Nix/devenv (nixos)');
|
|
81
125
|
expect(rendered).not.toContain('github-actions-bootstrap.sh');
|
|
82
|
-
expect(rendered).not.toContain('determinate-nix-action');
|
|
83
|
-
expect(rendered).toContain('if: always()');
|
|
84
|
-
expect(rendered).toContain('uses: ./.github/actions/save-nix-devenv');
|
|
85
|
-
// workflow yaml does not embed composite internals
|
|
86
|
-
expect(rendered).not.toContain('cache-node-modules');
|
|
87
|
-
expect(rendered).not.toContain('cache-ttsc-plugins');
|
|
88
126
|
});
|
|
89
127
|
});
|
|
@@ -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');
|
|
@@ -230,7 +232,7 @@ describe('publish workflow definition', () => {
|
|
|
230
232
|
);
|
|
231
233
|
expect(rendered).not.toContain('GITHUB_SHA:');
|
|
232
234
|
expect(finalJob).toContain("needs.linux-release-candidate.outputs.mode != 'none'");
|
|
233
|
-
expect(finalJob).toContain("inputs.
|
|
235
|
+
expect(finalJob).toContain("inputs.deploy_stage == 'production'");
|
|
234
236
|
expect(finalJob).toContain("inputs.dry_run != 'true'");
|
|
235
237
|
expect(finalJob).toContain('smoo release publish --bump "${{ inputs.bump }}" --dry-run "${{ inputs.dry_run }}"');
|
|
236
238
|
expect(finalJob).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
|
|
@@ -248,7 +250,7 @@ describe('publish workflow definition', () => {
|
|
|
248
250
|
it('omits production deploy controls when no production deploy target exists', () => {
|
|
249
251
|
const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
|
|
250
252
|
|
|
251
|
-
expect(rendered).not.toContain('
|
|
253
|
+
expect(rendered).not.toContain('deploy_stage');
|
|
252
254
|
expect(rendered).not.toContain('Deploy production');
|
|
253
255
|
expect(rendered).not.toContain('nx-deploy');
|
|
254
256
|
});
|
|
@@ -256,12 +258,12 @@ describe('publish workflow definition', () => {
|
|
|
256
258
|
it('renders production deploy controls for repos with production deploy targets', () => {
|
|
257
259
|
const rendered = renderPublishWorkflowYaml({ deploy: true, repoName: '@smoothbricks/codebase' });
|
|
258
260
|
|
|
259
|
-
expect(rendered).toContain('
|
|
261
|
+
expect(rendered).toContain('deploy_stage:');
|
|
260
262
|
expect(rendered).toContain('- name: 🚀 Deploy production');
|
|
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 --stage production --mode run-many --verify --name "Deploy Production"',
|
|
265
267
|
);
|
|
266
268
|
});
|
|
267
269
|
|
|
@@ -282,7 +284,7 @@ describe('publish workflow definition', () => {
|
|
|
282
284
|
repairs: [],
|
|
283
285
|
current: [],
|
|
284
286
|
bump: 'auto',
|
|
285
|
-
|
|
287
|
+
deployStage: 'production',
|
|
286
288
|
dryRun: false,
|
|
287
289
|
version: { mode: 'new', projects: ['app'] },
|
|
288
290
|
}).run();
|
|
@@ -291,7 +293,7 @@ describe('publish workflow definition', () => {
|
|
|
291
293
|
repairs: [],
|
|
292
294
|
current: [],
|
|
293
295
|
bump: 'auto',
|
|
294
|
-
|
|
296
|
+
deployStage: 'production',
|
|
295
297
|
dryRun: true,
|
|
296
298
|
version: { mode: 'new', projects: ['app'] },
|
|
297
299
|
}).run();
|
|
@@ -420,7 +422,7 @@ interface WorkflowScenarioConfig {
|
|
|
420
422
|
repairs: ReleaseGap[];
|
|
421
423
|
current: ReleaseGap[];
|
|
422
424
|
bump: PublishWorkflowBump;
|
|
423
|
-
|
|
425
|
+
deployStage?: 'none' | 'production';
|
|
424
426
|
dryRun: boolean;
|
|
425
427
|
version: PublishWorkflowVersionOutputs;
|
|
426
428
|
}
|
|
@@ -446,7 +448,7 @@ function publishWorkflowScenario(config: WorkflowScenarioConfig): { run(): Promi
|
|
|
446
448
|
async run() {
|
|
447
449
|
const state = new WorkflowScenarioState(config);
|
|
448
450
|
await runPublishWorkflow(definePublishWorkflow({ deploy: config.deploy, repoName: config.repoName }), {
|
|
449
|
-
inputs: { bump: config.bump,
|
|
451
|
+
inputs: { bump: config.bump, deployStage: config.deployStage ?? 'none', dryRun: config.dryRun },
|
|
450
452
|
callbacks: state.callbacks(),
|
|
451
453
|
});
|
|
452
454
|
return state.outcome();
|
|
@@ -8,6 +8,7 @@ export enum CiWorkflowStepKind {
|
|
|
8
8
|
SetNxShas = 'set-nx-shas',
|
|
9
9
|
RestoreNxCache = 'restore-nx-cache',
|
|
10
10
|
Build = 'build',
|
|
11
|
+
BrowserTests = 'browser-tests',
|
|
11
12
|
Lint = 'lint',
|
|
12
13
|
UnitTests = 'unit-tests',
|
|
13
14
|
ManagedFilesCheck = 'managed-files-check',
|
|
@@ -26,6 +27,8 @@ export interface CiWorkflowStep {
|
|
|
26
27
|
|
|
27
28
|
export interface CiWorkflowDefinitionOptions {
|
|
28
29
|
deploy: boolean;
|
|
30
|
+
browserTests: boolean;
|
|
31
|
+
e2eDeployment: boolean;
|
|
29
32
|
deployProvider?: 'cloudflare';
|
|
30
33
|
pushBranches: string[];
|
|
31
34
|
/** Default ubuntu-latest when omitted. */
|
|
@@ -41,15 +44,20 @@ export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkfl
|
|
|
41
44
|
{ kind: CiWorkflowStepKind.SetNxShas, name: '🧭 Set Nx SHAs' },
|
|
42
45
|
{ kind: CiWorkflowStepKind.RestoreNxCache, name: '🧠 Restore Nx cache' },
|
|
43
46
|
{ kind: CiWorkflowStepKind.Build, name: '🔨 Build' },
|
|
44
|
-
{ kind: CiWorkflowStepKind.Lint, name: '🔍 Lint' },
|
|
45
|
-
{ kind: CiWorkflowStepKind.UnitTests, name: '🧪 Unit Tests' },
|
|
46
|
-
{ kind: CiWorkflowStepKind.ManagedFilesCheck, name: '🩺 Check managed-file drift' },
|
|
47
|
-
{ kind: CiWorkflowStepKind.ManagedFilesDispatch, name: '🔁 Dispatch managed-file drift healing' },
|
|
48
47
|
];
|
|
49
48
|
if (options.deploy) {
|
|
50
|
-
steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy
|
|
49
|
+
steps.push({ kind: CiWorkflowStepKind.Deploy, name: '🚀 Deploy Stage' });
|
|
51
50
|
}
|
|
52
51
|
steps.push(
|
|
52
|
+
{ kind: CiWorkflowStepKind.Lint, name: '🔍 Lint' },
|
|
53
|
+
{ kind: CiWorkflowStepKind.UnitTests, name: '🧪 Unit Tests' },
|
|
54
|
+
);
|
|
55
|
+
if (options.browserTests) {
|
|
56
|
+
steps.push({ kind: CiWorkflowStepKind.BrowserTests, name: '🌐 Browser Tests' });
|
|
57
|
+
}
|
|
58
|
+
steps.push(
|
|
59
|
+
{ kind: CiWorkflowStepKind.ManagedFilesCheck, name: '🩺 Check managed-file drift' },
|
|
60
|
+
{ kind: CiWorkflowStepKind.ManagedFilesDispatch, name: '🔁 Dispatch managed-file drift healing' },
|
|
53
61
|
{ kind: CiWorkflowStepKind.SaveNxCache, name: '💾 Save Nx cache' },
|
|
54
62
|
{ kind: CiWorkflowStepKind.UploadTraceDbs, name: '📎 Upload trace DBs' },
|
|
55
63
|
{ kind: CiWorkflowStepKind.SaveNixDevenv, name: '🧹 Cleanup and cache Nix/devenv' },
|
|
@@ -59,7 +67,7 @@ export function defineCiWorkflow(options: CiWorkflowDefinitionOptions): CiWorkfl
|
|
|
59
67
|
|
|
60
68
|
export function renderCiWorkflowYaml(options: CiWorkflowDefinitionOptions): string {
|
|
61
69
|
const steps = defineCiWorkflow(options);
|
|
62
|
-
return `${renderCiWorkflowHeader(options)}${renderCiWorkflowSteps(steps, options)}`;
|
|
70
|
+
return `${renderCiWorkflowHeader(options)}${renderCiWorkflowSteps(steps, options)}${renderE2eDeploymentJob(options)}`;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
function renderCiWorkflowHeader(options: CiWorkflowDefinitionOptions): string {
|
|
@@ -77,6 +85,10 @@ permissions:
|
|
|
77
85
|
contents: read
|
|
78
86
|
${options.deploy ? ' deployments: write\n' : ''} statuses: write
|
|
79
87
|
|
|
88
|
+
concurrency:
|
|
89
|
+
group: \${{ github.workflow }}-\${{ github.ref }}
|
|
90
|
+
cancel-in-progress: true
|
|
91
|
+
|
|
80
92
|
defaults:
|
|
81
93
|
run:
|
|
82
94
|
working-directory: tooling/direnv
|
|
@@ -86,7 +98,13 @@ jobs:
|
|
|
86
98
|
name: Validate
|
|
87
99
|
${renderRunsOnLine(options.runsOn)}
|
|
88
100
|
timeout-minutes: 45
|
|
89
|
-
|
|
101
|
+
${
|
|
102
|
+
options.e2eDeployment
|
|
103
|
+
? ` outputs:
|
|
104
|
+
deployment-stage: ${githubExpression('steps.deploy.outputs.stage')}
|
|
105
|
+
`
|
|
106
|
+
: ''
|
|
107
|
+
} env:
|
|
90
108
|
NIX_STORE_NAR: ${githubExpression('github.workspace')}/nix-store.nar
|
|
91
109
|
GH_TOKEN: ${githubExpression('github.token')}
|
|
92
110
|
steps:
|
|
@@ -169,6 +187,8 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
|
|
|
169
187
|
return [` - name: ${step.name}`, ' id: nx-cache', ' uses: ./.github/actions/cache-nx'];
|
|
170
188
|
case CiWorkflowStepKind.Build:
|
|
171
189
|
return nxSmartStep(step, 'build', 'Build');
|
|
190
|
+
case CiWorkflowStepKind.BrowserTests:
|
|
191
|
+
return nxSmartStep(step, 'test-browser', 'Browser Tests');
|
|
172
192
|
case CiWorkflowStepKind.Lint:
|
|
173
193
|
return nxSmartStep(step, 'lint', 'Lint');
|
|
174
194
|
case CiWorkflowStepKind.UnitTests:
|
|
@@ -197,6 +217,7 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
|
|
|
197
217
|
case CiWorkflowStepKind.Deploy:
|
|
198
218
|
return [
|
|
199
219
|
` - name: ${step.name}`,
|
|
220
|
+
' id: deploy',
|
|
200
221
|
' if: >-',
|
|
201
222
|
' ${{',
|
|
202
223
|
" (github.event_name == 'pull_request' &&",
|
|
@@ -205,7 +226,7 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
|
|
|
205
226
|
" (github.event_name == 'push' && github.ref == 'refs/heads/private')",
|
|
206
227
|
' }}',
|
|
207
228
|
...deployEnvLines(options),
|
|
208
|
-
` run: smoo github-ci nx-deploy --mode run-many --name "Deploy
|
|
229
|
+
` run: smoo github-ci nx-deploy --mode run-many --name "Deploy Stage" --step ${step.number}`,
|
|
209
230
|
];
|
|
210
231
|
case CiWorkflowStepKind.SaveNxCache:
|
|
211
232
|
return [
|
|
@@ -255,12 +276,6 @@ function deployEnvLines(options: CiWorkflowDefinitionOptions): string[] {
|
|
|
255
276
|
' env:',
|
|
256
277
|
' CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}',
|
|
257
278
|
' 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 }}',
|
|
264
279
|
];
|
|
265
280
|
}
|
|
266
281
|
|
|
@@ -275,3 +290,41 @@ function renderYamlList(values: string[], spaces: number): string {
|
|
|
275
290
|
const indent = ' '.repeat(spaces);
|
|
276
291
|
return values.map((value) => `${indent}- ${value}`).join('\n');
|
|
277
292
|
}
|
|
293
|
+
|
|
294
|
+
function renderE2eDeploymentJob(options: CiWorkflowDefinitionOptions): string {
|
|
295
|
+
if (!options.e2eDeployment) return '';
|
|
296
|
+
return `
|
|
297
|
+
|
|
298
|
+
e2e-deployment:
|
|
299
|
+
name: Deployment E2E
|
|
300
|
+
needs: main
|
|
301
|
+
${renderRunsOnLine(options.runsOn)}
|
|
302
|
+
timeout-minutes: 15
|
|
303
|
+
if: \${{ needs.main.result == 'success' && needs.main.outputs.deployment-stage != '' }}
|
|
304
|
+
steps:
|
|
305
|
+
# Step 1: GitHub adds "Set up job" automatically
|
|
306
|
+
# Step 2
|
|
307
|
+
- name: 📥 Checkout
|
|
308
|
+
uses: actions/checkout@v6.0.2
|
|
309
|
+
with:
|
|
310
|
+
filter: blob:none
|
|
311
|
+
fetch-depth: 0
|
|
312
|
+
|
|
313
|
+
# Step 3. Composite action internals do not affect top-level job step anchors.
|
|
314
|
+
- name: 🧱 Setup Nix/devenv
|
|
315
|
+
id: setup
|
|
316
|
+
uses: ./.github/actions/setup-devenv
|
|
317
|
+
|
|
318
|
+
# Step 4
|
|
319
|
+
- name: Deployment E2E
|
|
320
|
+
run: smoo github-ci nx-smart --target e2e-deployment --mode run-many --stage "\${{ needs.main.outputs.deployment-stage }}" --name "Deployment E2E" --step 4
|
|
321
|
+
|
|
322
|
+
# Step 5
|
|
323
|
+
- name: 🧹 Cleanup and cache Nix/devenv
|
|
324
|
+
if: always()
|
|
325
|
+
uses: ./.github/actions/save-nix-devenv
|
|
326
|
+
with:
|
|
327
|
+
nix-cache-hit: \${{ steps.setup.outputs.nix-cache-hit }}
|
|
328
|
+
devenv-cache-hit: \${{ steps.setup.outputs.devenv-cache-hit }}
|
|
329
|
+
`;
|
|
330
|
+
}
|