@smoothbricks/cli 0.11.16 → 0.11.18
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/README.md +65 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +47 -0
- package/dist/github-ci/index.d.ts +1 -1
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +7 -10
- package/dist/lib/secret-names.d.ts +35 -0
- package/dist/lib/secret-names.d.ts.map +1 -0
- package/dist/lib/secret-names.js +61 -0
- package/dist/monorepo/ci-workflow.d.ts +56 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +214 -8
- package/dist/monorepo/managed-files.d.ts +23 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +39 -1
- package/dist/monorepo/publish-workflow.d.ts +10 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +58 -20
- package/dist/release/github-release.d.ts +10 -0
- package/dist/release/github-release.d.ts.map +1 -1
- package/dist/release/github-release.js +11 -5
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +4 -5
- package/dist/secrets/commands.d.ts +84 -0
- package/dist/secrets/commands.d.ts.map +1 -0
- package/dist/secrets/commands.js +375 -0
- package/dist/secrets/index.d.ts +103 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +255 -0
- package/dist/secrets/repository.d.ts +60 -0
- package/dist/secrets/repository.d.ts.map +1 -0
- package/dist/secrets/repository.js +145 -0
- package/dist/wrangler/cloudflare.d.ts +7 -0
- package/dist/wrangler/cloudflare.d.ts.map +1 -1
- package/dist/wrangler/cloudflare.js +10 -0
- package/dist/wrangler/deploy-stage.d.ts +13 -2
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +89 -78
- package/dist/wrangler/deployed-version.d.ts +44 -0
- package/dist/wrangler/deployed-version.d.ts.map +1 -0
- package/dist/wrangler/deployed-version.js +87 -0
- package/dist/wrangler/live-version.d.ts +146 -0
- package/dist/wrangler/live-version.d.ts.map +1 -0
- package/dist/wrangler/live-version.js +326 -0
- package/dist/wrangler/stage-secrets.d.ts +57 -0
- package/dist/wrangler/stage-secrets.d.ts.map +1 -0
- package/dist/wrangler/stage-secrets.js +178 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
- package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
- package/package.json +2 -2
- package/src/cli.ts +69 -1
- package/src/github-ci/index.test.ts +92 -8
- package/src/github-ci/index.ts +7 -10
- package/src/lib/secret-names.ts +70 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +188 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
- package/src/monorepo/ci-workflow.ts +294 -8
- package/src/monorepo/managed-files.test.ts +28 -1
- package/src/monorepo/managed-files.ts +56 -2
- package/src/monorepo/package-policy.test.ts +1 -1
- package/src/monorepo/publish-workflow.ts +65 -19
- package/src/monorepo/secret-references.test.ts +1 -1
- package/src/release/__tests__/github-release.test.ts +8 -4
- package/src/release/__tests__/private-npm-status.test.ts +2 -2
- package/src/release/github-release.ts +13 -5
- package/src/release/index.ts +4 -4
- package/src/secrets/commands.test.ts +28 -0
- package/src/secrets/commands.ts +412 -0
- package/src/secrets/index.test.ts +209 -0
- package/src/secrets/index.ts +287 -0
- package/src/secrets/repository.test.ts +98 -0
- package/src/secrets/repository.ts +164 -0
- package/src/wrangler/cloudflare.ts +18 -0
- package/src/wrangler/deploy-stage.test.ts +448 -23
- package/src/wrangler/deploy-stage.ts +142 -83
- package/src/wrangler/deployed-version.test.ts +150 -0
- package/src/wrangler/deployed-version.ts +130 -0
- package/src/wrangler/live-version.ts +363 -0
- package/src/wrangler/stage-secrets.ts +146 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smoothbricks/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.18",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SmoothBricks monorepo automation CLI",
|
|
6
6
|
"bin": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
],
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@arethetypeswrong/core": "^0.18.2",
|
|
67
|
-
"@smoothbricks/nx-plugin": "0.4.
|
|
67
|
+
"@smoothbricks/nx-plugin": "0.4.12",
|
|
68
68
|
"@smoothbricks/validation": "0.1.8",
|
|
69
69
|
"commander": "^14.0.3",
|
|
70
70
|
"make-synchronized": "^0.8.0",
|
package/src/cli.ts
CHANGED
|
@@ -6,7 +6,9 @@ import { cliPackageVersion } from './lib/cli-package.js';
|
|
|
6
6
|
import { decode, findRepoRoot, printCommandOutput } from './lib/run.js';
|
|
7
7
|
import { ensureChromium } from './playwright/index.js';
|
|
8
8
|
import { resolvePrConflicts } from './pr/index.js';
|
|
9
|
+
import { secretsSet, secretsStatus, secretsSync } from './secrets/commands.js';
|
|
9
10
|
import { cleanupPullRequest, deployStage } from './wrangler/deploy-stage.js';
|
|
11
|
+
import { deployedVersion } from './wrangler/deployed-version.js';
|
|
10
12
|
import { scaffold } from './wrangler/scaffold.js';
|
|
11
13
|
|
|
12
14
|
export async function runCli(argv = process.argv.slice(2)): Promise<void> {
|
|
@@ -525,6 +527,54 @@ function buildProgram(): Command {
|
|
|
525
527
|
await ensureChromium();
|
|
526
528
|
});
|
|
527
529
|
|
|
530
|
+
const secrets = program
|
|
531
|
+
.command('secrets')
|
|
532
|
+
.description('Reconcile declared secrets: what Workers need, what workflows pass, what the repository holds');
|
|
533
|
+
secrets
|
|
534
|
+
.command('status')
|
|
535
|
+
.description(
|
|
536
|
+
'Show every declared secret with the scopes holding it, and refuse when a workflow passes one none has',
|
|
537
|
+
)
|
|
538
|
+
.option(
|
|
539
|
+
'-R, --repo <owner/name|remote>',
|
|
540
|
+
"repository or remote name; defaults to the current branch's upstream remote",
|
|
541
|
+
)
|
|
542
|
+
.option(
|
|
543
|
+
'--env <environment>',
|
|
544
|
+
"also read this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
|
|
545
|
+
)
|
|
546
|
+
.action(async (options: { repo?: string; env?: string }) => {
|
|
547
|
+
process.exitCode = secretsStatus(await findRepoRoot(), options);
|
|
548
|
+
});
|
|
549
|
+
secrets
|
|
550
|
+
.command('set [name]')
|
|
551
|
+
.description('Set secrets from pasted values; with no name, prompts for every secret the target scope still lacks')
|
|
552
|
+
.option(
|
|
553
|
+
'-R, --repo <owner/name|remote>',
|
|
554
|
+
"repository or remote name; defaults to the current branch's upstream remote",
|
|
555
|
+
)
|
|
556
|
+
.option(
|
|
557
|
+
'--env <environment>',
|
|
558
|
+
"write into this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
|
|
559
|
+
)
|
|
560
|
+
.action(async (name: string | undefined, options: { repo?: string; env?: string }) => {
|
|
561
|
+
process.exitCode = await secretsSet(await findRepoRoot(), name, options);
|
|
562
|
+
});
|
|
563
|
+
secrets
|
|
564
|
+
.command('sync')
|
|
565
|
+
.description('Push every secret smoo.secrets can fetch locally to the repository')
|
|
566
|
+
.option(
|
|
567
|
+
'-R, --repo <owner/name|remote>',
|
|
568
|
+
"repository or remote name; defaults to the current branch's upstream remote",
|
|
569
|
+
)
|
|
570
|
+
.option(
|
|
571
|
+
'--env <environment>',
|
|
572
|
+
"write into this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
|
|
573
|
+
)
|
|
574
|
+
.action(async (options: { repo?: string; env?: string }) => {
|
|
575
|
+
process.exitCode = await secretsSync(await findRepoRoot(), options);
|
|
576
|
+
});
|
|
577
|
+
|
|
528
578
|
const wrangler = program.command('wrangler').description('Cloudflare wrangler project helpers');
|
|
529
579
|
wrangler
|
|
530
580
|
.command('scaffold <project>')
|
|
@@ -537,11 +587,29 @@ function buildProgram(): Command {
|
|
|
537
587
|
.command('deploy-stage')
|
|
538
588
|
.requiredOption('--stage <stage>', 'staging, production, or prN')
|
|
539
589
|
.option('--config <path>', 'deploy a build-generated flat wrangler.json instead of ./wrangler.toml')
|
|
540
|
-
.
|
|
590
|
+
.option('--version-endpoint <url>', 'URL served by this worker whose trimmed body is the running version tag')
|
|
591
|
+
.action(async (options: { stage: string; config?: string; versionEndpoint?: string }) => {
|
|
541
592
|
await deployStage(process.cwd(), {
|
|
542
593
|
stage: options.stage,
|
|
543
594
|
...(options.config ? { config: resolve(options.config) } : {}),
|
|
595
|
+
...(options.versionEndpoint ? { versionEndpoint: options.versionEndpoint } : {}),
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
wrangler
|
|
599
|
+
.command('deployed-version')
|
|
600
|
+
.description('Print the version tag serving all traffic for this project\u2019s worker on a stage')
|
|
601
|
+
.requiredOption('--stage <stage>', 'staging, production, or prN')
|
|
602
|
+
.option('--config <path>', 'resolve the worker name from a build-generated flat wrangler.json')
|
|
603
|
+
.option('--refresh', 'ask Cloudflare even when a fresh cached answer exists')
|
|
604
|
+
.action(async (options: { stage: string; config?: string; refresh?: boolean }) => {
|
|
605
|
+
const report = await deployedVersion(process.cwd(), {
|
|
606
|
+
stage: options.stage,
|
|
607
|
+
...(options.config ? { config: resolve(options.config) } : {}),
|
|
608
|
+
...(options.refresh ? { refresh: true } : {}),
|
|
544
609
|
});
|
|
610
|
+
// The tag alone on stdout: this is read by humans and by `$(...)`, and a version deployed
|
|
611
|
+
// outside Nx genuinely has no tag, which `untagged` says without pretending to be one.
|
|
612
|
+
console.log(report.versionTag ?? 'untagged');
|
|
545
613
|
});
|
|
546
614
|
wrangler
|
|
547
615
|
.command('cleanup-pr')
|
|
@@ -954,7 +954,7 @@ describe('event-aware stage deployment', () => {
|
|
|
954
954
|
|
|
955
955
|
it('deploys app/backend, publishes PR metadata, and emits the resolved stage', async () => {
|
|
956
956
|
const nxCalls: string[][] = [];
|
|
957
|
-
const listCalls: Array<[string, string, string
|
|
957
|
+
const listCalls: Array<[string, string, string | undefined]> = [];
|
|
958
958
|
const summaries: string[] = [];
|
|
959
959
|
const deployments: Array<[string, string]> = [];
|
|
960
960
|
const outputs: string[] = [];
|
|
@@ -975,8 +975,8 @@ describe('event-aware stage deployment', () => {
|
|
|
975
975
|
repository: { full_name: 'owner/repo' },
|
|
976
976
|
pull_request: { number: 123, head: { repo: { full_name: 'owner/repo' } } },
|
|
977
977
|
},
|
|
978
|
-
listProjects: async (_root,
|
|
979
|
-
listCalls.push([
|
|
978
|
+
listProjects: async (_root, mode, stage, selectTag) => {
|
|
979
|
+
listCalls.push([mode, stage, selectTag]);
|
|
980
980
|
return ['app', 'app-backend'];
|
|
981
981
|
},
|
|
982
982
|
runNx: async (args) => {
|
|
@@ -995,7 +995,7 @@ describe('event-aware stage deployment', () => {
|
|
|
995
995
|
},
|
|
996
996
|
);
|
|
997
997
|
|
|
998
|
-
expect(listCalls).toEqual([['
|
|
998
|
+
expect(listCalls).toEqual([['run-many', 'pr123', undefined]]);
|
|
999
999
|
expect(nxCalls).toHaveLength(1);
|
|
1000
1000
|
expect(nxCalls[0]).toContain('--projects=app,app-backend');
|
|
1001
1001
|
expect(nxCalls[0]).toContain('--exclude=tag:permanent-deploy-target,tag:staging-deploy-target');
|
|
@@ -1077,7 +1077,7 @@ describe('event-aware stage deployment', () => {
|
|
|
1077
1077
|
});
|
|
1078
1078
|
|
|
1079
1079
|
it('passes --select-tag through to the project selection of the production deploy', async () => {
|
|
1080
|
-
const listCalls: Array<[string, string, string
|
|
1080
|
+
const listCalls: Array<[string, string, string | undefined]> = [];
|
|
1081
1081
|
const nxCalls: string[][] = [];
|
|
1082
1082
|
|
|
1083
1083
|
await githubCiNxDeploy(
|
|
@@ -1086,8 +1086,8 @@ describe('event-aware stage deployment', () => {
|
|
|
1086
1086
|
{
|
|
1087
1087
|
processEnv: {},
|
|
1088
1088
|
setStatus: async () => {},
|
|
1089
|
-
listProjects: async (_root,
|
|
1090
|
-
listCalls.push([
|
|
1089
|
+
listProjects: async (_root, mode, stage, selectTag) => {
|
|
1090
|
+
listCalls.push([mode, stage, selectTag]);
|
|
1091
1091
|
return ['website'];
|
|
1092
1092
|
},
|
|
1093
1093
|
runNx: async (args) => {
|
|
@@ -1097,7 +1097,7 @@ describe('event-aware stage deployment', () => {
|
|
|
1097
1097
|
},
|
|
1098
1098
|
);
|
|
1099
1099
|
|
|
1100
|
-
expect(listCalls).toEqual([['
|
|
1100
|
+
expect(listCalls).toEqual([['run-many', 'production', 'production-push-deploy-target']]);
|
|
1101
1101
|
expect(nxCalls).toHaveLength(1);
|
|
1102
1102
|
expect(nxCalls[0]).toContain('--projects=website');
|
|
1103
1103
|
expect(nxCalls[0]).toContain('--stage=production');
|
|
@@ -1193,6 +1193,90 @@ describe('event-aware stage deployment', () => {
|
|
|
1193
1193
|
|
|
1194
1194
|
expect(statuses).toEqual(['pending', 'success']);
|
|
1195
1195
|
});
|
|
1196
|
+
|
|
1197
|
+
it('deploys the whole selection in one run-many, leaving the order to the graph', async () => {
|
|
1198
|
+
const nxCalls: string[][] = [];
|
|
1199
|
+
await githubCiNxDeploy(
|
|
1200
|
+
'/repo',
|
|
1201
|
+
{ stage: 'staging' },
|
|
1202
|
+
{
|
|
1203
|
+
processEnv: {},
|
|
1204
|
+
setStatus: async () => {},
|
|
1205
|
+
listProjects: async () => ['app-backend', 'website'],
|
|
1206
|
+
runNx: async (args) => {
|
|
1207
|
+
nxCalls.push(args);
|
|
1208
|
+
return 0;
|
|
1209
|
+
},
|
|
1210
|
+
},
|
|
1211
|
+
);
|
|
1212
|
+
expect(nxCalls.map((args) => args.find((arg) => arg.startsWith('--projects=')))).toEqual([
|
|
1213
|
+
'--projects=app-backend,website',
|
|
1214
|
+
]);
|
|
1215
|
+
expect(nxCalls[0]).toContain('--stage=staging');
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it('publishes nothing when the deploy fails', async () => {
|
|
1219
|
+
const statuses: string[] = [];
|
|
1220
|
+
const summaries: string[] = [];
|
|
1221
|
+
const deployments: Array<[string, string]> = [];
|
|
1222
|
+
const outputs: string[] = [];
|
|
1223
|
+
await expect(
|
|
1224
|
+
githubCiNxDeploy(
|
|
1225
|
+
'/repo',
|
|
1226
|
+
{ mode: 'run-many' },
|
|
1227
|
+
{
|
|
1228
|
+
processEnv: { GITHUB_EVENT_NAME: 'pull_request', GITHUB_OUTPUT: '/output', GITHUB_STEP_SUMMARY: '/summary' },
|
|
1229
|
+
github: { previewUrls: ['https://app.{stage}.example.com'] },
|
|
1230
|
+
eventPayload: {
|
|
1231
|
+
action: 'opened',
|
|
1232
|
+
repository: { full_name: 'owner/repo' },
|
|
1233
|
+
pull_request: { number: 12, head: { repo: { full_name: 'owner/repo' } } },
|
|
1234
|
+
},
|
|
1235
|
+
setStatus: async (status) => {
|
|
1236
|
+
statuses.push(status);
|
|
1237
|
+
},
|
|
1238
|
+
listProjects: async () => ['app-backend', 'website'],
|
|
1239
|
+
runNx: async () => 1,
|
|
1240
|
+
appendSummary: async (_path, content) => {
|
|
1241
|
+
summaries.push(content);
|
|
1242
|
+
},
|
|
1243
|
+
appendOutput: async (_path, content) => {
|
|
1244
|
+
outputs.push(content);
|
|
1245
|
+
},
|
|
1246
|
+
publishDeployment: async (stage, url) => {
|
|
1247
|
+
deployments.push([stage, url]);
|
|
1248
|
+
},
|
|
1249
|
+
},
|
|
1250
|
+
),
|
|
1251
|
+
).rejects.toThrow(/--projects=app-backend,website .*failed with exit code 1/);
|
|
1252
|
+
expect(statuses).toEqual(['pending', 'failure']);
|
|
1253
|
+
expect(outputs).toEqual([]);
|
|
1254
|
+
expect(summaries).toEqual([]);
|
|
1255
|
+
expect(deployments).toEqual([]);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it('verifies and deploys the whole selection with one run-many each', async () => {
|
|
1259
|
+
const nxCalls: string[][] = [];
|
|
1260
|
+
await githubCiNxDeploy(
|
|
1261
|
+
'/repo',
|
|
1262
|
+
{ stage: 'production', verify: true },
|
|
1263
|
+
{
|
|
1264
|
+
processEnv: {},
|
|
1265
|
+
setStatus: async () => {},
|
|
1266
|
+
listProjects: async () => ['app-backend', 'website'],
|
|
1267
|
+
runNx: async (args) => {
|
|
1268
|
+
nxCalls.push(args);
|
|
1269
|
+
return 0;
|
|
1270
|
+
},
|
|
1271
|
+
},
|
|
1272
|
+
);
|
|
1273
|
+
expect(nxCalls.map((args) => `${args[2]} ${args[3]}`)).toEqual([
|
|
1274
|
+
'build --projects=app-backend,website',
|
|
1275
|
+
'lint --projects=app-backend,website',
|
|
1276
|
+
'test --projects=app-backend,website',
|
|
1277
|
+
'deploy --projects=app-backend,website',
|
|
1278
|
+
]);
|
|
1279
|
+
});
|
|
1196
1280
|
});
|
|
1197
1281
|
|
|
1198
1282
|
describe('resolveDeploymentStage with a configured push branch', () => {
|
package/src/github-ci/index.ts
CHANGED
|
@@ -338,7 +338,6 @@ export interface GithubCiNxDeployOptions {
|
|
|
338
338
|
export interface GithubCiNxDeployDependencies {
|
|
339
339
|
listProjects?: (
|
|
340
340
|
root: string,
|
|
341
|
-
target: string,
|
|
342
341
|
mode: 'affected' | 'run-many',
|
|
343
342
|
stage: DeploymentStage,
|
|
344
343
|
selectTag?: string,
|
|
@@ -371,9 +370,9 @@ export async function githubCiNxDeploy(
|
|
|
371
370
|
((state: 'pending' | 'success' | 'failure') =>
|
|
372
371
|
state === 'pending' ? createGithubStatus(name, step) : updateGithubStatus(name, state, step));
|
|
373
372
|
const mode = resolveNxSmartMode(options.mode ?? 'run-many');
|
|
374
|
-
const listProjects = dependencies.listProjects ??
|
|
373
|
+
const listProjects = dependencies.listProjects ?? listNxDeployProjects;
|
|
375
374
|
const runNx = dependencies.runNx ?? ((args: string[], commandRoot: string) => runStatus('nx', args, commandRoot));
|
|
376
|
-
const projects = await listProjects(root,
|
|
375
|
+
const projects = await listProjects(root, mode, stage, options.selectTag);
|
|
377
376
|
if (projects.length === 0) {
|
|
378
377
|
console.log(`No ${mode} deploy projects; skipping ${stage}.`);
|
|
379
378
|
await setStatus('pending');
|
|
@@ -387,8 +386,8 @@ export async function githubCiNxDeploy(
|
|
|
387
386
|
: undefined;
|
|
388
387
|
await setStatus('pending');
|
|
389
388
|
|
|
390
|
-
const projectList = projects.join(',');
|
|
391
389
|
const targets = options.verify === true ? ['build', 'lint', 'test', 'deploy'] : ['deploy'];
|
|
390
|
+
const projectList = projects.join(',');
|
|
392
391
|
for (const target of targets) {
|
|
393
392
|
const nxArgs = [
|
|
394
393
|
'run-many',
|
|
@@ -398,6 +397,8 @@ export async function githubCiNxDeploy(
|
|
|
398
397
|
`--exclude=${deployExclusions(stage)}`,
|
|
399
398
|
`--parallel=${NX_PARALLEL}`,
|
|
400
399
|
];
|
|
400
|
+
// Deploy ordering is a `dependsOn` edge in the project graph, not a second round here: one run-many
|
|
401
|
+
// already sequences any depth of `a:deploy -> b:deploy -> c:deploy`.
|
|
401
402
|
if (target === 'deploy') {
|
|
402
403
|
nxArgs.push(`--stage=${stage}`);
|
|
403
404
|
}
|
|
@@ -540,22 +541,18 @@ function previewUrlFromTemplate(template: string, stage: string): string {
|
|
|
540
541
|
return template.replaceAll('{stage}', stage);
|
|
541
542
|
}
|
|
542
543
|
|
|
543
|
-
async function
|
|
544
|
+
async function listNxDeployProjects(
|
|
544
545
|
root: string,
|
|
545
|
-
target: string,
|
|
546
546
|
mode: 'affected' | 'run-many',
|
|
547
547
|
stage: DeploymentStage,
|
|
548
548
|
selectTag?: string,
|
|
549
549
|
): Promise<string[]> {
|
|
550
550
|
const listArgs = ['show', 'projects'];
|
|
551
551
|
if (mode === 'affected') listArgs.push('--affected');
|
|
552
|
-
listArgs.push('--withTarget',
|
|
553
|
-
if (target === 'deploy') listArgs.push(`--exclude=${deployExclusions(stage)}`);
|
|
554
|
-
listArgs.push('--json');
|
|
552
|
+
listArgs.push('--withTarget', 'deploy', `--exclude=${deployExclusions(stage)}`, '--json');
|
|
555
553
|
const candidates = nxProjectList(await runText('nx', listArgs, root)).sort((left, right) =>
|
|
556
554
|
left.localeCompare(right),
|
|
557
555
|
);
|
|
558
|
-
if (target !== 'deploy') return candidates;
|
|
559
556
|
return selectStageDeployProjects(candidates, stage, selectTag, async (project) => {
|
|
560
557
|
const parsed = parseNxProjectDeployTarget(await runText('nx', ['show', 'project', project, '--json'], root));
|
|
561
558
|
if (!parsed) throw new Error(`nx show project ${project} returned invalid JSON.`);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One rule for naming the repository secret behind an environment variable, so
|
|
3
|
+
* the workflow renderer, the reconciler and an operator all derive the same
|
|
4
|
+
* name instead of maintaining a map.
|
|
5
|
+
*
|
|
6
|
+
* GitHub refuses to create a secret whose name starts with `GITHUB_` (hence
|
|
7
|
+
* `RepositorySecretName`'s pattern), which is why a Worker's
|
|
8
|
+
* `GITHUB_CLIENT_SECRET` cannot be stored under its own name. Prefixing with
|
|
9
|
+
* the repository owner is the workaround operators already reach for by hand
|
|
10
|
+
* (`ACME_GITHUB_CLIENT_SECRET` for `acme/app`); making it the convention means
|
|
11
|
+
* nothing has to declare it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Names GitHub reserves: it rejects `gh secret set GITHUB_*` outright. */
|
|
15
|
+
export function isReservedRepositorySecretName(envName: string): boolean {
|
|
16
|
+
return /^GITHUB_/i.test(envName);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The repository secret an env name lives in. Identity for every name GitHub
|
|
21
|
+
* accepts; owner-prefixed for the reserved ones. `owner` comes from the
|
|
22
|
+
* repository URL, so a fork or a rename derives its own names rather than
|
|
23
|
+
* inheriting a hardcoded prefix.
|
|
24
|
+
*/
|
|
25
|
+
export function conventionalRepositorySecretName(envName: string, owner: string): string {
|
|
26
|
+
if (!isReservedRepositorySecretName(envName)) return envName;
|
|
27
|
+
const prefix = owner
|
|
28
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
29
|
+
.replace(/^_+|_+$/g, '')
|
|
30
|
+
.toUpperCase();
|
|
31
|
+
if (prefix.length === 0) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Cannot derive a repository secret name for ${envName}: GitHub reserves the GITHUB_ prefix and the repository owner is empty.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return `${prefix}_${envName}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the whole env -> secret mapping for a set of declared env names.
|
|
41
|
+
* Explicit entries win: an exception stays expressible, but a repository that
|
|
42
|
+
* follows the convention declares nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function repositorySecretMapping(
|
|
45
|
+
envNames: readonly string[],
|
|
46
|
+
owner: string,
|
|
47
|
+
declared: Readonly<Record<string, string>> = {},
|
|
48
|
+
): Record<string, string> {
|
|
49
|
+
const mapping: Record<string, string> = {};
|
|
50
|
+
for (const envName of [...envNames].sort((left, right) => left.localeCompare(right))) {
|
|
51
|
+
mapping[envName] = declared[envName] ?? conventionalRepositorySecretName(envName, owner);
|
|
52
|
+
}
|
|
53
|
+
for (const [envName, secretName] of Object.entries(declared)) {
|
|
54
|
+
mapping[envName] = secretName;
|
|
55
|
+
}
|
|
56
|
+
return mapping;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Repository owner from a repository URL — `acme` from
|
|
61
|
+
* `https://github.com/acme/app.git`, and the same for an ssh or forge
|
|
62
|
+
* URL. The owner is derived rather than configured so a fork or a rename
|
|
63
|
+
* carries its own prefix instead of inheriting one.
|
|
64
|
+
*/
|
|
65
|
+
export function repositoryOwnerFromUrl(url: string): string | null {
|
|
66
|
+
const withoutProtocol = url.replace(/^[a-z+]+:\/\//i, '').replace(/^git@/i, '');
|
|
67
|
+
const path = withoutProtocol.replace(/^[^/:]+[/:]/, '');
|
|
68
|
+
const owner = path.split('/')[0];
|
|
69
|
+
return owner && owner.length > 0 ? owner.replace(/\.git$/i, '') : null;
|
|
70
|
+
}
|
|
@@ -6,6 +6,8 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
6
6
|
import { readFile } from 'node:fs/promises';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
+
import { format } from 'prettier';
|
|
10
|
+
import typia from 'typia';
|
|
9
11
|
import type { PackageCargoGitOrigin } from '../../lib/json.js';
|
|
10
12
|
import {
|
|
11
13
|
type CiWorkflowDefinitionOptions,
|
|
@@ -396,9 +398,9 @@ describe('CI workflow definition', () => {
|
|
|
396
398
|
for (const sshOrigins of [
|
|
397
399
|
[],
|
|
398
400
|
// scp syntax is not a URL prefix git can rewrite from a Cargo pin.
|
|
399
|
-
['forgejo@forge.example.net:
|
|
401
|
+
['forgejo@forge.example.net:acme/widgets.git'],
|
|
400
402
|
// A path would rewrite one repository, not the forge.
|
|
401
|
-
['ssh://forge.example.net:2223/
|
|
403
|
+
['ssh://forge.example.net:2223/acme/widgets.git'],
|
|
402
404
|
// A password in a declared origin is a secret in package.json.
|
|
403
405
|
['ssh://forgejo:hunter2@forge.example.net:2223/'],
|
|
404
406
|
['https://git.example.net/'],
|
|
@@ -769,6 +771,30 @@ describe('renderCiWorkflowYaml with deploy configuration', () => {
|
|
|
769
771
|
expect(productionJob).not.toContain('needs.e2e-deployment');
|
|
770
772
|
});
|
|
771
773
|
|
|
774
|
+
it('renders a production job the repository Prettier config keeps byte for byte', async () => {
|
|
775
|
+
const withoutE2e = renderCiWorkflowYaml(
|
|
776
|
+
options({ deploy: true, deployProvider: 'cloudflare', pushBranches: ['trunk'], productionOnPush: true }),
|
|
777
|
+
);
|
|
778
|
+
for (const workflow of [rendered, withoutE2e]) {
|
|
779
|
+
const productionJob = workflow.slice(workflow.indexOf(' deploy-production:'));
|
|
780
|
+
expect(productionJob).toContain(' # prettier-ignore\n if: ${{ !cancelled() ');
|
|
781
|
+
expect(productionJob).toContain(
|
|
782
|
+
' # prettier-ignore\n run: smoo github-ci nx-deploy --stage production ',
|
|
783
|
+
);
|
|
784
|
+
// A consuming repo's commit hook formats staged YAML with Prettier; a rewrapped line reads as drift forever.
|
|
785
|
+
await expect(
|
|
786
|
+
format(workflow, { parser: 'yaml', printWidth: 120, proseWrap: 'always', singleQuote: true }),
|
|
787
|
+
).resolves.toBe(workflow);
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('cancels superseded pushes when the workflow has no deploy job to protect', () => {
|
|
792
|
+
const validateOnly = renderCiWorkflowYaml(options());
|
|
793
|
+
|
|
794
|
+
expect(validateOnly).toContain('cancel-in-progress: true');
|
|
795
|
+
expect(validateOnly).not.toContain("cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}");
|
|
796
|
+
});
|
|
797
|
+
|
|
772
798
|
it('keeps a protected staging environment off CI runs that do not deploy', () => {
|
|
773
799
|
const validateOnly = renderCiWorkflowYaml(options({ environments: { staging: 'staging' } }));
|
|
774
800
|
|
|
@@ -873,3 +899,163 @@ describe('renderCiWorkflowYaml with deploy configuration', () => {
|
|
|
873
899
|
expect(e2eJob).not.toContain('CARGO_REGISTRIES_EXAMPLE_TOKEN');
|
|
874
900
|
});
|
|
875
901
|
});
|
|
902
|
+
|
|
903
|
+
describe('renderCiWorkflowYaml with cross-built test archives', () => {
|
|
904
|
+
const darwin = {
|
|
905
|
+
triple: 'aarch64-apple-darwin',
|
|
906
|
+
path: 'target/nextest/archive-aarch64-apple-darwin.tar.zst',
|
|
907
|
+
};
|
|
908
|
+
const declared = options({
|
|
909
|
+
runsOn: [...nixosRunsOn],
|
|
910
|
+
macosRunsOn: ['macos-arm64', 'self-hosted'],
|
|
911
|
+
crossTestArchives: [darwin],
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('renders nothing for a repository that declares no cross archives', () => {
|
|
915
|
+
const bare = renderCiWorkflowYaml(options({ runsOn: [...nixosRunsOn] }));
|
|
916
|
+
|
|
917
|
+
// An empty declaration is the same repository as an absent one: no step, no
|
|
918
|
+
// job, and above all no renumbering of the steps that were already there.
|
|
919
|
+
expect(renderCiWorkflowYaml(options({ runsOn: [...nixosRunsOn], crossTestArchives: [] }))).toBe(bare);
|
|
920
|
+
expect(bare).not.toContain('macos-cross-tests');
|
|
921
|
+
expect(bare).not.toContain('cross-target test archives');
|
|
922
|
+
expect(bare).not.toContain('cargo-cross-test');
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it('builds and uploads each archive in Validate, then executes it in a job that needs Validate', () => {
|
|
926
|
+
const rendered = renderCiWorkflowYaml(declared);
|
|
927
|
+
const workflow = typia.assert<{
|
|
928
|
+
jobs: Record<string, { needs?: string; 'runs-on'?: unknown; 'timeout-minutes'?: number; steps: unknown[] }>;
|
|
929
|
+
}>(Bun.YAML.parse(rendered));
|
|
930
|
+
const validate = workflow.jobs.main;
|
|
931
|
+
const execute = workflow.jobs['macos-cross-tests'];
|
|
932
|
+
|
|
933
|
+
expect(execute?.needs).toBe('main');
|
|
934
|
+
expect(execute?.['runs-on']).toEqual(['macos-arm64', 'self-hosted']);
|
|
935
|
+
expect(execute?.['timeout-minutes']).toBe(30);
|
|
936
|
+
// The Linux job COMPILES the darwin binaries; that is what proves the cross
|
|
937
|
+
// build works at all.
|
|
938
|
+
expect(rendered).toContain(
|
|
939
|
+
'run: smoo github-ci nx-run-many --targets "cargo-cross-test-archive-aarch64-apple-darwin"',
|
|
940
|
+
);
|
|
941
|
+
expect(JSON.stringify(validate?.steps)).toContain('cross-test-archives-${{ github.run_id }}');
|
|
942
|
+
// ...and the macOS job only RUNS them: every step is checkout, the shell,
|
|
943
|
+
// the download, the archive run, or the cache save. Nothing invokes cargo,
|
|
944
|
+
// a toolchain install, or an SDK.
|
|
945
|
+
const executeSteps = execute?.steps ?? [];
|
|
946
|
+
expect(executeSteps).toContainEqual({
|
|
947
|
+
name: '🧪 Cross-Target Unit Tests',
|
|
948
|
+
run: 'smoo github-ci nx-run-many --targets "cargo-cross-test-aarch64-apple-darwin"',
|
|
949
|
+
});
|
|
950
|
+
const executeText = JSON.stringify(executeSteps);
|
|
951
|
+
expect(executeText).toContain('cross-test-archives-${{ github.run_id }}');
|
|
952
|
+
expect(executeText).toContain('actions/download-artifact');
|
|
953
|
+
expect(executeText).not.toContain('cargo ');
|
|
954
|
+
expect(executeText).not.toContain('rustup');
|
|
955
|
+
expect(executeText).not.toContain('cargo-cross-test-archive');
|
|
956
|
+
expect(executeText).not.toContain('--target build');
|
|
957
|
+
expect(executeText).not.toContain('SDK');
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it('reuses the declared cross producer for the archive step, step-scoped', () => {
|
|
961
|
+
const rendered = renderCiWorkflowYaml(
|
|
962
|
+
options({
|
|
963
|
+
...declared,
|
|
964
|
+
platformProducer: {
|
|
965
|
+
kind: 'linux-cross',
|
|
966
|
+
preflight: 'sh scripts/prepare-macos-sdk.sh',
|
|
967
|
+
env: { ACME_CROSS: '1', SDKROOT: '${{ runner.temp }}/apple-sdk/MacOSX.sdk' },
|
|
968
|
+
},
|
|
969
|
+
}),
|
|
970
|
+
);
|
|
971
|
+
const validate = rendered.slice(0, rendered.indexOf(' macos-cross-tests:'));
|
|
972
|
+
|
|
973
|
+
expect(validate).toContain('- name: Check cross-platform toolchain prerequisites');
|
|
974
|
+
expect(validate).toContain(' working-directory: .\n');
|
|
975
|
+
expect(validate).toContain(' set -euo pipefail\n sh scripts/prepare-macos-sdk.sh');
|
|
976
|
+
// Step-scoped, so Validate's host builds stay host builds: the pair appears
|
|
977
|
+
// once per cross step and never in the job's own env block.
|
|
978
|
+
expect(validate.match(/ACME_CROSS: "1"/g)).toHaveLength(2);
|
|
979
|
+
const jobEnv = validate.slice(validate.indexOf(' env:'), validate.indexOf(' steps:'));
|
|
980
|
+
expect(jobEnv).not.toContain('ACME_CROSS');
|
|
981
|
+
expect(jobEnv).not.toContain('SDKROOT');
|
|
982
|
+
// A declared producer with no preflight is a mechanism error at render time.
|
|
983
|
+
expect(() =>
|
|
984
|
+
renderCiWorkflowYaml(options({ ...declared, platformProducer: { kind: 'linux-cross', preflight: ' ' } })),
|
|
985
|
+
).toThrow('nonempty toolchain preflight');
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it('numbers the execute job download before the run and keeps the cleanup anchor after both', () => {
|
|
989
|
+
const execute = crossTestJob(renderCiWorkflowYaml(declared));
|
|
990
|
+
|
|
991
|
+
// Checkout 2, setup-devenv 3, download 4, run 5, cleanup 6.
|
|
992
|
+
expect(execute).toContain('# Step 4\n - name: 📥 Download cross-target test archives');
|
|
993
|
+
expect(execute).toContain('# Step 5\n - name: 🧪 Cross-Target Unit Tests');
|
|
994
|
+
expect(execute).toContain('# Step 6');
|
|
995
|
+
expect(execute).toContain('uses: ./.github/actions/save-nix-devenv');
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('builds a non-darwin triple without an execution job it has no runner for', () => {
|
|
999
|
+
const rendered = renderCiWorkflowYaml(
|
|
1000
|
+
options({
|
|
1001
|
+
crossTestArchives: [
|
|
1002
|
+
{ triple: 'x86_64-unknown-linux-musl', path: 'target/nextest/archive-x86_64-unknown-linux-musl.tar.zst' },
|
|
1003
|
+
],
|
|
1004
|
+
}),
|
|
1005
|
+
);
|
|
1006
|
+
|
|
1007
|
+
expect(rendered).toContain(
|
|
1008
|
+
'run: smoo github-ci nx-run-many --targets "cargo-cross-test-archive-x86_64-unknown-linux-musl"',
|
|
1009
|
+
);
|
|
1010
|
+
expect(rendered).not.toContain('macos-cross-tests');
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it('downloads a nested cargo workspace archive back to the directory its target writes', () => {
|
|
1014
|
+
const execute = crossTestJob(
|
|
1015
|
+
renderCiWorkflowYaml(
|
|
1016
|
+
options({
|
|
1017
|
+
crossTestArchives: [{ ...darwin, path: `packages/ferris/${darwin.path}` }],
|
|
1018
|
+
}),
|
|
1019
|
+
),
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
expect(execute).toContain('path: packages/ferris/target/nextest');
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it('refuses declarations the Nx graph could not have produced, at render time', () => {
|
|
1026
|
+
expect(() =>
|
|
1027
|
+
renderCiWorkflowYaml(options({ crossTestArchives: [{ ...darwin, path: 'target/nextest/archive.tar.zst' }] })),
|
|
1028
|
+
).toThrow('must be a repository-relative target/nextest/archive-aarch64-apple-darwin.tar.zst');
|
|
1029
|
+
expect(() =>
|
|
1030
|
+
renderCiWorkflowYaml(options({ crossTestArchives: [{ ...darwin, path: `../${darwin.path}` }] })),
|
|
1031
|
+
).toThrow('must stay inside the repository');
|
|
1032
|
+
expect(() =>
|
|
1033
|
+
renderCiWorkflowYaml(
|
|
1034
|
+
options({ crossTestArchives: [{ triple: 'Bad Triple; rm -rf /', path: 'target/nextest/x.tar.zst' }] }),
|
|
1035
|
+
),
|
|
1036
|
+
).toThrow('must be a target triple');
|
|
1037
|
+
// Two cargo workspaces cannot share one artifact: upload-artifact roots it
|
|
1038
|
+
// at the least common ancestor, so the restored paths would be wrong for
|
|
1039
|
+
// both. Refuse here rather than on the runner.
|
|
1040
|
+
expect(() =>
|
|
1041
|
+
renderCiWorkflowYaml(
|
|
1042
|
+
options({
|
|
1043
|
+
crossTestArchives: [
|
|
1044
|
+
darwin,
|
|
1045
|
+
{ triple: 'x86_64-apple-darwin', path: `packages/ferris/${cargoArchive('x86_64-apple-darwin')}` },
|
|
1046
|
+
],
|
|
1047
|
+
}),
|
|
1048
|
+
),
|
|
1049
|
+
).toThrow('must share one directory');
|
|
1050
|
+
});
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
function crossTestJob(rendered: string): string {
|
|
1054
|
+
const start = rendered.indexOf(' macos-cross-tests:');
|
|
1055
|
+
expect(start).toBeGreaterThan(-1);
|
|
1056
|
+
return rendered.slice(start);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function cargoArchive(triple: string): string {
|
|
1060
|
+
return `target/nextest/archive-${triple}.tar.zst`;
|
|
1061
|
+
}
|