@intentius/chant-lexicon-gitlab 0.124.39 → 0.124.41
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/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +132 -9
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +3 -3
- package/src/components/__fixtures__/pr-loop-build.gitlab.golden.yml +18 -0
- package/src/components/__fixtures__/pr-loop-record.gitlab.golden.yml +18 -0
- package/src/components/__fixtures__/pr-loop-waves.gitlab.golden.yml +20 -0
- package/src/components/__fixtures__/pr-loop.gitlab.golden.yml +18 -0
- package/src/components/generate-pipeline.test.ts +3 -0
- package/src/components/generate-pipeline.ts +150 -8
- package/src/components/generate-pr-env-pipeline.test.ts +91 -0
- package/src/components/generate-pr-pipeline.identity.test.ts +137 -0
- package/src/components/generate-pr-pipeline.test.ts +38 -3
- package/src/components/generate-pr-waves-pipeline.test.ts +3 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAWH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;
|
|
1
|
+
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAWH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAuBjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAkJtB"}
|
|
@@ -35,8 +35,10 @@ import { assertGitlabBranch, CI_IMAGE, ciJobName, fillCommand, GITLAB_FULL_HISTO
|
|
|
35
35
|
import { resolveComponentGraph } from "@intentius/chant/components/driver";
|
|
36
36
|
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
37
37
|
import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
|
|
38
|
-
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopSetup, prLoopWaveJobs, prLoopWithWaves, } from "@intentius/chant/components/pr-pipeline";
|
|
38
|
+
import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopPostJob, prLoopSetup, PR_PLAN_NOTE_JOB, prLoopWaveJobs, prLoopWithWaves, prEnvJobs, PR_ENV_REPORT_DIR, PR_ENV_POST_JOB, } from "@intentius/chant/components/pr-pipeline";
|
|
39
39
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
40
|
+
import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
41
|
+
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV } from "@intentius/chant/ci-identity";
|
|
40
42
|
import { emitGitlabFile } from "./ci-jobs.js";
|
|
41
43
|
/**
|
|
42
44
|
* Synthesize a `.gitlab-ci.yml` pipeline from a set of components: one stage
|
|
@@ -214,6 +216,8 @@ function gatedWaveGitlabPipeline(components, env, image, beforeScript, extraScri
|
|
|
214
216
|
variables: { ...GITLAB_FULL_HISTORY },
|
|
215
217
|
script: [...prLoopSetup(image), ...beforeScript, job.command.join(" "), ...extraScript],
|
|
216
218
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
219
|
+
// #4291: a wave runs after one that failed, and blocks only what depends on the failure.
|
|
220
|
+
...(job.needs.length > 0 ? { when: "always" } : {}),
|
|
217
221
|
artifacts: { when: "always", paths: [GATED_WAVE_RECORD] },
|
|
218
222
|
};
|
|
219
223
|
}
|
|
@@ -255,6 +259,23 @@ function gatedWaveGitlabPipeline(components, env, image, beforeScript, extraScri
|
|
|
255
259
|
* before. They share the commit's cache key, so wave k pulls the record wave
|
|
256
260
|
* k-1 pushed, and a retry of a wave that stopped at its gate pulls the one it
|
|
257
261
|
* pushed itself.
|
|
262
|
+
*
|
|
263
|
+
* The plan job holds no forge token while the merge request's code runs
|
|
264
|
+
* (#4295, `@intentius/chant/ci-handover`): its script starts by unsetting
|
|
265
|
+
* the token variables, and `pr-plan --no-status` writes the note and the
|
|
266
|
+
* status into its report. The `plan-note` job after it, which GitLab hands
|
|
267
|
+
* the report artifact, checks out the target branch's commit and posts them
|
|
268
|
+
* with `chant components pr-post`. A CI/CD variable reaches every job of a
|
|
269
|
+
* merge request pipeline, so `unset` keeps it from the plan's code, and
|
|
270
|
+
* `CHANT_FORGE_TOKEN` as a protected variable keeps it from the pipeline
|
|
271
|
+
* altogether.
|
|
272
|
+
*
|
|
273
|
+
* With `identity` (#4296, `@intentius/chant/ci-identity`) the plan, record
|
|
274
|
+
* and apply jobs take an `id_tokens:` entry per cloud, and each script
|
|
275
|
+
* starts by trading it for the job's cloud identity: the plan and record
|
|
276
|
+
* jobs for the plan identity, the apply jobs for the apply identity. The
|
|
277
|
+
* plan job trades before any of the merge request's code runs, then unsets
|
|
278
|
+
* the token variables with the forge tokens and sources the credentials.
|
|
258
279
|
*/
|
|
259
280
|
/** The dotenv report a gated apply wave hands the green commit it applies through (#4221). */
|
|
260
281
|
const WAVE_GREEN_DOTENV = "chant-green.env";
|
|
@@ -287,21 +308,42 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
287
308
|
// #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
|
|
288
309
|
const handOff = (job) => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
|
|
289
310
|
const script = (job) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
|
|
290
|
-
|
|
311
|
+
// #4296: each job trades its OIDC token for its cloud identity before anything else in its script.
|
|
312
|
+
const identity = loop.identity;
|
|
313
|
+
const trading = (spec) => spec ? { id_tokens: gitlabTradeIdTokens(spec) } : {};
|
|
314
|
+
const tradeVars = (spec) => (spec ? { [TRADE_SPEC_ENV]: JSON.stringify(spec) } : {});
|
|
315
|
+
const applySpec = (job) => (identity ? (job.jobName === "record" ? identity.plan : identity.apply) : undefined);
|
|
316
|
+
// #4298: a pull request environment's jobs, in a stage of their own after the plan.
|
|
317
|
+
const prEnv = loop.prEnvironment ? prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) : undefined;
|
|
318
|
+
const stages = ["plan", PR_PLAN_NOTE_JOB, ...applies.map((a) => a.stage), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
|
|
319
|
+
const noteJob = named(PR_PLAN_NOTE_JOB);
|
|
320
|
+
const post = prLoopPostJob("gitlab", env, member?.name);
|
|
291
321
|
const doc = {
|
|
292
322
|
// Included files merge their top-level keys, so a member's file carries
|
|
293
323
|
// the rules every member shares and no name: two names would collide.
|
|
294
|
-
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, ...(greens.length > 0 ? [{ if: onGreen }] : [])] },
|
|
324
|
+
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
|
|
295
325
|
stages,
|
|
296
326
|
variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
|
|
297
327
|
[planJob]: {
|
|
298
328
|
stage: "plan",
|
|
299
329
|
image,
|
|
300
330
|
rules: [{ if: onMergeRequest }],
|
|
301
|
-
|
|
302
|
-
|
|
331
|
+
...trading(identity?.plan),
|
|
332
|
+
variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
|
|
333
|
+
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
334
|
+
// #4296: with identity, the trade runs before that, and the same unset drops the token variables too.
|
|
335
|
+
script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
|
|
303
336
|
artifacts: { when: "always", paths: [reportPath] },
|
|
304
337
|
},
|
|
338
|
+
// #4295: a fresh job at the target branch's commit posts the note and the status from the plan's report.
|
|
339
|
+
[noteJob]: {
|
|
340
|
+
stage: PR_PLAN_NOTE_JOB,
|
|
341
|
+
image,
|
|
342
|
+
needs: [planJob],
|
|
343
|
+
rules: [{ if: onMergeRequest, when: "always" }],
|
|
344
|
+
variables: post.env,
|
|
345
|
+
script: ['git checkout --quiet --detach "$BASE_SHA"', ...enter, ...beforeScript, post.command],
|
|
346
|
+
},
|
|
305
347
|
...Object.fromEntries(applies.map(({ job, name, stage }, i) => [
|
|
306
348
|
name,
|
|
307
349
|
{
|
|
@@ -309,9 +351,11 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
309
351
|
image,
|
|
310
352
|
...(i > 1 ? { needs: [applies[i - 1].name] } : {}),
|
|
311
353
|
resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
354
|
+
// #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
|
|
355
|
+
rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
|
|
356
|
+
...trading(applySpec(job)),
|
|
357
|
+
variables: { ...job.run.env, ...tradeVars(applySpec(job)) },
|
|
358
|
+
script: [...(applySpec(job) ? gitlabTradeLines() : []), ...script(job)],
|
|
315
359
|
// The attempt record (#3543), kept under the pushed commit even when
|
|
316
360
|
// the job fails, so a retry finishes the apply under the same approval.
|
|
317
361
|
// The record job (#4168) applies nothing and keeps none.
|
|
@@ -327,19 +371,98 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
327
371
|
artifacts: { when: "always", paths: [reportPath], ...(job.wave ? { reports: { dotenv: WAVE_GREEN_DOTENV } } : {}) },
|
|
328
372
|
},
|
|
329
373
|
])),
|
|
374
|
+
...(prEnv ? prEnv.jobs : {}),
|
|
330
375
|
};
|
|
331
376
|
const jobs = [
|
|
332
377
|
{ jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
|
|
378
|
+
{ jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [planJob] },
|
|
333
379
|
...applies.map(({ job, name, stage }, i) => ({
|
|
334
380
|
jobName: name,
|
|
335
381
|
component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "green apply",
|
|
336
382
|
stage,
|
|
337
383
|
needs: i > 1 ? [applies[i - 1].name] : [],
|
|
338
384
|
})),
|
|
385
|
+
...(prEnv ? prEnv.listed : []),
|
|
339
386
|
];
|
|
340
|
-
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, ...applies.map((a) => a.name)].map((key) => [key, doc[key]]));
|
|
387
|
+
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...applies.map((a) => a.name), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]]));
|
|
341
388
|
return { yaml, stages, jobs, env };
|
|
342
389
|
}
|
|
390
|
+
/** The stage a pull request environment's jobs run in (#4298). */
|
|
391
|
+
const PR_ENV_STAGE = "env";
|
|
392
|
+
/** The rule of a scheduled pipeline, which runs `env-sweep` (#4298). */
|
|
393
|
+
const GITLAB_SCHEDULE_RULE = '$CI_PIPELINE_SOURCE == "schedule"';
|
|
394
|
+
/**
|
|
395
|
+
* A pull request environment's jobs on GitLab (#4298). `env-up` runs in the
|
|
396
|
+
* merge request pipeline and runs the change's code, so its script drops the
|
|
397
|
+
* forge tokens first, as the plan job's does (#4295), and it keeps its report
|
|
398
|
+
* and record as the artifact. `env-post`, at the target branch's commit,
|
|
399
|
+
* keeps the record on chant/lifecycle and posts the note. `env-up` starts a
|
|
400
|
+
* GitLab environment `pr-<iid>` whose stop job is `env-down`, so the
|
|
401
|
+
* environment's Stop button tears it down. GitLab starts no pipeline when a
|
|
402
|
+
* merge request closes, so `env-sweep`, from a pipeline schedule, is what
|
|
403
|
+
* tears down a closed merge request's environment unattended, as it does one
|
|
404
|
+
* whose TTL passed.
|
|
405
|
+
*/
|
|
406
|
+
function prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) {
|
|
407
|
+
const [up, post, down, sweep] = prEnvJobs("gitlab", loop, member?.name);
|
|
408
|
+
const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
|
|
409
|
+
const environment = "pr-$CI_MERGE_REQUEST_IID";
|
|
410
|
+
const group = `chant-pr-env-${member ? `${member.name}-` : ""}$CI_MERGE_REQUEST_IID`;
|
|
411
|
+
const script = (j) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
|
|
412
|
+
const artifacts = { when: "always", paths: [reportPath] };
|
|
413
|
+
return {
|
|
414
|
+
jobs: {
|
|
415
|
+
[named("env-up")]: {
|
|
416
|
+
stage: PR_ENV_STAGE,
|
|
417
|
+
image,
|
|
418
|
+
rules: [{ if: onMergeRequest }],
|
|
419
|
+
resource_group: group,
|
|
420
|
+
environment: { name: environment, on_stop: named("env-down") },
|
|
421
|
+
variables: up.env,
|
|
422
|
+
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
423
|
+
script: [GITLAB_DROP_FORGE_TOKENS, ...script(up)],
|
|
424
|
+
artifacts,
|
|
425
|
+
},
|
|
426
|
+
[named(PR_ENV_POST_JOB)]: {
|
|
427
|
+
stage: PR_ENV_POST_STAGE,
|
|
428
|
+
image,
|
|
429
|
+
needs: [named("env-up")],
|
|
430
|
+
rules: [{ if: onMergeRequest, when: "always" }],
|
|
431
|
+
variables: post.env,
|
|
432
|
+
script: ['git checkout --quiet --detach "$BASE_SHA"', ...enter, ...beforeScript, post.command],
|
|
433
|
+
artifacts,
|
|
434
|
+
},
|
|
435
|
+
[named("env-down")]: {
|
|
436
|
+
stage: PR_ENV_STAGE,
|
|
437
|
+
image,
|
|
438
|
+
rules: [{ if: onMergeRequest, when: "manual" }],
|
|
439
|
+
allow_failure: true,
|
|
440
|
+
resource_group: group,
|
|
441
|
+
environment: { name: environment, action: "stop" },
|
|
442
|
+
variables: down.env,
|
|
443
|
+
script: script(down),
|
|
444
|
+
artifacts,
|
|
445
|
+
},
|
|
446
|
+
[named("env-sweep")]: {
|
|
447
|
+
stage: PR_ENV_STAGE,
|
|
448
|
+
image,
|
|
449
|
+
rules: [{ if: GITLAB_SCHEDULE_RULE }],
|
|
450
|
+
resource_group: `chant-pr-env-sweep${member ? `-${member.name}` : ""}`,
|
|
451
|
+
variables: sweep.env,
|
|
452
|
+
script: script(sweep),
|
|
453
|
+
artifacts,
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
listed: [
|
|
457
|
+
{ jobName: named("env-up"), component: "merge request environment", stage: PR_ENV_STAGE, needs: [] },
|
|
458
|
+
{ jobName: named(PR_ENV_POST_JOB), component: "merge request environment note", stage: PR_ENV_POST_STAGE, needs: [named("env-up")] },
|
|
459
|
+
{ jobName: named("env-down"), component: "merge request environment teardown", stage: PR_ENV_STAGE, needs: [] },
|
|
460
|
+
{ jobName: named("env-sweep"), component: "merge request environment sweep", stage: PR_ENV_STAGE, needs: [] },
|
|
461
|
+
],
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/** The stage the post job runs in, after `env-up` (#4295). */
|
|
465
|
+
const PR_ENV_POST_STAGE = "env-post";
|
|
343
466
|
/**
|
|
344
467
|
* Scope a pipeline to one workspace member (#2542, #2524 D19), in place on
|
|
345
468
|
* `doc` and `jobs`. Returns the promote job's new name.
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "f0aa9cd82d2d03eb7ed4f027c87c39f5a6b1b8ca2f6e58b635249f82e99884f9",
|
|
5
5
|
"meta.json": "db082fbf90c5941c498b2bee0f106103449c1a4c257716ca2220f0dd490450b2",
|
|
6
6
|
"types/index.d.ts": "a048a66ebe20da0cb5fc889054cac69044d9bf882e2c56b5c5bf113d1f3ba57c",
|
|
7
7
|
"rules/artifact-no-expiry.ts": "0ca5bb972d4a542126e8a0229d43b55658394cb7dd9b9923b2c590bec1c2ad7d",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
|
|
56
56
|
"skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
|
|
57
57
|
},
|
|
58
|
-
"composite": "
|
|
58
|
+
"composite": "bc6817f93592903583a564a620c6bbf849d732d2ad59c960c4159c6294aaf55c"
|
|
59
59
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-gitlab",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.41",
|
|
4
4
|
"description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"typescript": "^5.9.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@intentius/chant": "^0.124.
|
|
71
|
-
"@intentius/chant-lexicon-github": "^0.124.
|
|
70
|
+
"@intentius/chant": "^0.124.41",
|
|
71
|
+
"@intentius/chant-lexicon-github": "^0.124.41"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@intentius/chant-lexicon-github": {
|
|
@@ -7,6 +7,7 @@ workflow:
|
|
|
7
7
|
|
|
8
8
|
stages:
|
|
9
9
|
- plan
|
|
10
|
+
- plan-note
|
|
10
11
|
- record
|
|
11
12
|
- apply
|
|
12
13
|
|
|
@@ -23,6 +24,7 @@ plan:
|
|
|
23
24
|
BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
|
|
24
25
|
PR_NUMBER: '$CI_MERGE_REQUEST_IID'
|
|
25
26
|
script:
|
|
27
|
+
- unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
|
|
26
28
|
- command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
|
|
27
29
|
- chant build --env prod
|
|
28
30
|
- chant lint
|
|
@@ -32,6 +34,22 @@ plan:
|
|
|
32
34
|
paths:
|
|
33
35
|
- .chant/pr
|
|
34
36
|
|
|
37
|
+
plan-note:
|
|
38
|
+
stage: plan-note
|
|
39
|
+
image: node:22
|
|
40
|
+
needs:
|
|
41
|
+
- plan
|
|
42
|
+
rules:
|
|
43
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
44
|
+
when: always
|
|
45
|
+
variables:
|
|
46
|
+
PR_NUMBER: '$CI_MERGE_REQUEST_IID'
|
|
47
|
+
HEAD_SHA: '$CI_COMMIT_SHA'
|
|
48
|
+
BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
|
|
49
|
+
script:
|
|
50
|
+
- git checkout --quiet --detach "$BASE_SHA"
|
|
51
|
+
- chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab
|
|
52
|
+
|
|
35
53
|
record:
|
|
36
54
|
stage: record
|
|
37
55
|
image: node:22
|
|
@@ -6,6 +6,7 @@ workflow:
|
|
|
6
6
|
|
|
7
7
|
stages:
|
|
8
8
|
- plan
|
|
9
|
+
- plan-note
|
|
9
10
|
- record
|
|
10
11
|
|
|
11
12
|
variables:
|
|
@@ -21,6 +22,7 @@ plan:
|
|
|
21
22
|
BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
|
|
22
23
|
PR_NUMBER: '$CI_MERGE_REQUEST_IID'
|
|
23
24
|
script:
|
|
25
|
+
- unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
|
|
24
26
|
- command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
|
|
25
27
|
- chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge gitlab
|
|
26
28
|
artifacts:
|
|
@@ -28,6 +30,22 @@ plan:
|
|
|
28
30
|
paths:
|
|
29
31
|
- .chant/pr
|
|
30
32
|
|
|
33
|
+
plan-note:
|
|
34
|
+
stage: plan-note
|
|
35
|
+
image: node:22
|
|
36
|
+
needs:
|
|
37
|
+
- plan
|
|
38
|
+
rules:
|
|
39
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
40
|
+
when: always
|
|
41
|
+
variables:
|
|
42
|
+
PR_NUMBER: '$CI_MERGE_REQUEST_IID'
|
|
43
|
+
HEAD_SHA: '$CI_COMMIT_SHA'
|
|
44
|
+
BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
|
|
45
|
+
script:
|
|
46
|
+
- git checkout --quiet --detach "$BASE_SHA"
|
|
47
|
+
- chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab
|
|
48
|
+
|
|
31
49
|
record:
|
|
32
50
|
stage: record
|
|
33
51
|
image: node:22
|
|
@@ -7,6 +7,7 @@ workflow:
|
|
|
7
7
|
|
|
8
8
|
stages:
|
|
9
9
|
- plan
|
|
10
|
+
- plan-note
|
|
10
11
|
- record
|
|
11
12
|
- apply-wave-1
|
|
12
13
|
- apply-wave-2
|
|
@@ -25,6 +26,7 @@ plan:
|
|
|
25
26
|
BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
|
|
26
27
|
PR_NUMBER: '$CI_MERGE_REQUEST_IID'
|
|
27
28
|
script:
|
|
29
|
+
- unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
|
|
28
30
|
- command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
|
|
29
31
|
- chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge gitlab --wave-gate release
|
|
30
32
|
artifacts:
|
|
@@ -32,6 +34,22 @@ plan:
|
|
|
32
34
|
paths:
|
|
33
35
|
- .chant/pr
|
|
34
36
|
|
|
37
|
+
plan-note:
|
|
38
|
+
stage: plan-note
|
|
39
|
+
image: node:22
|
|
40
|
+
needs:
|
|
41
|
+
- plan
|
|
42
|
+
rules:
|
|
43
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
44
|
+
when: always
|
|
45
|
+
variables:
|
|
46
|
+
PR_NUMBER: '$CI_MERGE_REQUEST_IID'
|
|
47
|
+
HEAD_SHA: '$CI_COMMIT_SHA'
|
|
48
|
+
BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
|
|
49
|
+
script:
|
|
50
|
+
- git checkout --quiet --detach "$BASE_SHA"
|
|
51
|
+
- chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab
|
|
52
|
+
|
|
35
53
|
record:
|
|
36
54
|
stage: record
|
|
37
55
|
image: node:22
|
|
@@ -81,6 +99,7 @@ apply-wave-2:
|
|
|
81
99
|
resource_group: chant-apply-prod
|
|
82
100
|
rules:
|
|
83
101
|
- if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
|
|
102
|
+
when: always
|
|
84
103
|
variables: {}
|
|
85
104
|
script:
|
|
86
105
|
- command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
|
|
@@ -107,6 +126,7 @@ apply-wave-3:
|
|
|
107
126
|
resource_group: chant-apply-prod
|
|
108
127
|
rules:
|
|
109
128
|
- if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
|
|
129
|
+
when: always
|
|
110
130
|
variables: {}
|
|
111
131
|
script:
|
|
112
132
|
- command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
|
|
@@ -7,6 +7,7 @@ workflow:
|
|
|
7
7
|
|
|
8
8
|
stages:
|
|
9
9
|
- plan
|
|
10
|
+
- plan-note
|
|
10
11
|
- record
|
|
11
12
|
- apply
|
|
12
13
|
|
|
@@ -23,6 +24,7 @@ plan:
|
|
|
23
24
|
BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
|
|
24
25
|
PR_NUMBER: '$CI_MERGE_REQUEST_IID'
|
|
25
26
|
script:
|
|
27
|
+
- unset CHANT_FORGE_TOKEN CHANT_GITHUB_TOKEN CHANT_FORGEJO_TOKEN CHANT_GITLAB_TOKEN CHANT_CHECKOUT_TOKEN GITHUB_TOKEN GH_TOKEN FORGEJO_TOKEN GITEA_TOKEN GITLAB_TOKEN CI_JOB_TOKEN ACTIONS_RUNTIME_TOKEN
|
|
26
28
|
- command -v tofu >/dev/null 2>&1 || (curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh && sh /tmp/install-opentofu.sh --install-method standalone)
|
|
27
29
|
- chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge gitlab
|
|
28
30
|
artifacts:
|
|
@@ -30,6 +32,22 @@ plan:
|
|
|
30
32
|
paths:
|
|
31
33
|
- .chant/pr
|
|
32
34
|
|
|
35
|
+
plan-note:
|
|
36
|
+
stage: plan-note
|
|
37
|
+
image: node:22
|
|
38
|
+
needs:
|
|
39
|
+
- plan
|
|
40
|
+
rules:
|
|
41
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
42
|
+
when: always
|
|
43
|
+
variables:
|
|
44
|
+
PR_NUMBER: '$CI_MERGE_REQUEST_IID'
|
|
45
|
+
HEAD_SHA: '$CI_COMMIT_SHA'
|
|
46
|
+
BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
|
|
47
|
+
script:
|
|
48
|
+
- git checkout --quiet --detach "$BASE_SHA"
|
|
49
|
+
- chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab
|
|
50
|
+
|
|
33
51
|
record:
|
|
34
52
|
stage: record
|
|
35
53
|
image: node:22
|
|
@@ -343,6 +343,9 @@ describe("generateGitlabPipeline: gated waves (#3049)", () => {
|
|
|
343
343
|
expect(result.stages).toEqual(["wave-1", "wave-2", "wave-3"]);
|
|
344
344
|
expect(parsed["wave-2"].needs).toEqual(["wave-1"]);
|
|
345
345
|
expect(parsed["wave-1"].needs).toBeUndefined();
|
|
346
|
+
// #4291: wave 2 runs after wave 1 failed, so a failure blocks only its dependents.
|
|
347
|
+
expect(parsed["wave-2"].when).toBe("always");
|
|
348
|
+
expect(parsed["wave-1"].when).toBeUndefined();
|
|
346
349
|
expect(parsed["wave-3"].image).toBe("node:22");
|
|
347
350
|
expect(parsed["wave-3"].script).toEqual([
|
|
348
351
|
expect.stringMatching(/^command -v tofu /),
|
|
@@ -52,11 +52,18 @@ import {
|
|
|
52
52
|
prApplyRecordKey,
|
|
53
53
|
prApplyWaveJobName,
|
|
54
54
|
prLoopJobs,
|
|
55
|
+
prLoopPostJob,
|
|
55
56
|
prLoopSetup,
|
|
57
|
+
PR_PLAN_NOTE_JOB,
|
|
56
58
|
prLoopWaveJobs,
|
|
57
59
|
prLoopWithWaves,
|
|
60
|
+
prEnvJobs,
|
|
61
|
+
PR_ENV_REPORT_DIR,
|
|
62
|
+
PR_ENV_POST_JOB,
|
|
58
63
|
} from "@intentius/chant/components/pr-pipeline";
|
|
59
64
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
65
|
+
import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
66
|
+
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV, type CloudTradeSpec } from "@intentius/chant/ci-identity";
|
|
60
67
|
import type {
|
|
61
68
|
ComponentPipelineJob as GeneratedJob,
|
|
62
69
|
ComponentPipelineOptions as GenerateGitlabOptions,
|
|
@@ -258,6 +265,8 @@ function gatedWaveGitlabPipeline(
|
|
|
258
265
|
variables: { ...GITLAB_FULL_HISTORY },
|
|
259
266
|
script: [...prLoopSetup(image), ...beforeScript, job.command.join(" "), ...extraScript],
|
|
260
267
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
268
|
+
// #4291: a wave runs after one that failed, and blocks only what depends on the failure.
|
|
269
|
+
...(job.needs.length > 0 ? { when: "always" } : {}),
|
|
261
270
|
artifacts: { when: "always", paths: [GATED_WAVE_RECORD] },
|
|
262
271
|
};
|
|
263
272
|
}
|
|
@@ -299,6 +308,23 @@ function gatedWaveGitlabPipeline(
|
|
|
299
308
|
* before. They share the commit's cache key, so wave k pulls the record wave
|
|
300
309
|
* k-1 pushed, and a retry of a wave that stopped at its gate pulls the one it
|
|
301
310
|
* pushed itself.
|
|
311
|
+
*
|
|
312
|
+
* The plan job holds no forge token while the merge request's code runs
|
|
313
|
+
* (#4295, `@intentius/chant/ci-handover`): its script starts by unsetting
|
|
314
|
+
* the token variables, and `pr-plan --no-status` writes the note and the
|
|
315
|
+
* status into its report. The `plan-note` job after it, which GitLab hands
|
|
316
|
+
* the report artifact, checks out the target branch's commit and posts them
|
|
317
|
+
* with `chant components pr-post`. A CI/CD variable reaches every job of a
|
|
318
|
+
* merge request pipeline, so `unset` keeps it from the plan's code, and
|
|
319
|
+
* `CHANT_FORGE_TOKEN` as a protected variable keeps it from the pipeline
|
|
320
|
+
* altogether.
|
|
321
|
+
*
|
|
322
|
+
* With `identity` (#4296, `@intentius/chant/ci-identity`) the plan, record
|
|
323
|
+
* and apply jobs take an `id_tokens:` entry per cloud, and each script
|
|
324
|
+
* starts by trading it for the job's cloud identity: the plan and record
|
|
325
|
+
* jobs for the plan identity, the apply jobs for the apply identity. The
|
|
326
|
+
* plan job trades before any of the merge request's code runs, then unsets
|
|
327
|
+
* the token variables with the forge tokens and sources the credentials.
|
|
302
328
|
*/
|
|
303
329
|
/** The dotenv report a gated apply wave hands the green commit it applies through (#4221). */
|
|
304
330
|
const WAVE_GREEN_DOTENV = "chant-green.env";
|
|
@@ -339,21 +365,43 @@ function prLoopGitlabPipeline(
|
|
|
339
365
|
// #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
|
|
340
366
|
const handOff = (job: typeof plan): string[] => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
|
|
341
367
|
const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
|
|
342
|
-
|
|
368
|
+
// #4296: each job trades its OIDC token for its cloud identity before anything else in its script.
|
|
369
|
+
const identity = loop.identity;
|
|
370
|
+
const trading = (spec: CloudTradeSpec | undefined): Record<string, unknown> =>
|
|
371
|
+
spec ? { id_tokens: gitlabTradeIdTokens(spec) } : {};
|
|
372
|
+
const tradeVars = (spec: CloudTradeSpec | undefined): Record<string, string> => (spec ? { [TRADE_SPEC_ENV]: JSON.stringify(spec) } : {});
|
|
373
|
+
const applySpec = (job: typeof plan): CloudTradeSpec | undefined => (identity ? (job.jobName === "record" ? identity.plan : identity.apply) : undefined);
|
|
374
|
+
// #4298: a pull request environment's jobs, in a stage of their own after the plan.
|
|
375
|
+
const prEnv = loop.prEnvironment ? prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) : undefined;
|
|
376
|
+
const stages = ["plan", PR_PLAN_NOTE_JOB, ...applies.map((a) => a.stage), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
|
|
377
|
+
const noteJob = named(PR_PLAN_NOTE_JOB);
|
|
378
|
+
const post = prLoopPostJob("gitlab", env, member?.name);
|
|
343
379
|
const doc: Record<string, unknown> = {
|
|
344
380
|
// Included files merge their top-level keys, so a member's file carries
|
|
345
381
|
// the rules every member shares and no name: two names would collide.
|
|
346
|
-
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, ...(greens.length > 0 ? [{ if: onGreen }] : [])] },
|
|
382
|
+
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
|
|
347
383
|
stages,
|
|
348
384
|
variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
|
|
349
385
|
[planJob]: {
|
|
350
386
|
stage: "plan",
|
|
351
387
|
image,
|
|
352
388
|
rules: [{ if: onMergeRequest }],
|
|
353
|
-
|
|
354
|
-
|
|
389
|
+
...trading(identity?.plan),
|
|
390
|
+
variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
|
|
391
|
+
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
392
|
+
// #4296: with identity, the trade runs before that, and the same unset drops the token variables too.
|
|
393
|
+
script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
|
|
355
394
|
artifacts: { when: "always", paths: [reportPath] },
|
|
356
395
|
},
|
|
396
|
+
// #4295: a fresh job at the target branch's commit posts the note and the status from the plan's report.
|
|
397
|
+
[noteJob]: {
|
|
398
|
+
stage: PR_PLAN_NOTE_JOB,
|
|
399
|
+
image,
|
|
400
|
+
needs: [planJob],
|
|
401
|
+
rules: [{ if: onMergeRequest, when: "always" }],
|
|
402
|
+
variables: post.env,
|
|
403
|
+
script: ['git checkout --quiet --detach "$BASE_SHA"', ...enter, ...beforeScript, post.command],
|
|
404
|
+
},
|
|
357
405
|
...Object.fromEntries(
|
|
358
406
|
applies.map(({ job, name, stage }, i) => [
|
|
359
407
|
name,
|
|
@@ -362,9 +410,11 @@ function prLoopGitlabPipeline(
|
|
|
362
410
|
image,
|
|
363
411
|
...(i > 1 ? { needs: [applies[i - 1]!.name] } : {}),
|
|
364
412
|
resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
413
|
+
// #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
|
|
414
|
+
rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
|
|
415
|
+
...trading(applySpec(job)),
|
|
416
|
+
variables: { ...job.run.env, ...tradeVars(applySpec(job)) },
|
|
417
|
+
script: [...(applySpec(job) ? gitlabTradeLines() : []), ...script(job)],
|
|
368
418
|
// The attempt record (#3543), kept under the pushed commit even when
|
|
369
419
|
// the job fails, so a retry finishes the apply under the same approval.
|
|
370
420
|
// The record job (#4168) applies nothing and keeps none.
|
|
@@ -381,20 +431,112 @@ function prLoopGitlabPipeline(
|
|
|
381
431
|
},
|
|
382
432
|
]),
|
|
383
433
|
),
|
|
434
|
+
...(prEnv ? prEnv.jobs : {}),
|
|
384
435
|
};
|
|
385
436
|
const jobs: GeneratedJob[] = [
|
|
386
437
|
{ jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
|
|
438
|
+
{ jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [planJob] },
|
|
387
439
|
...applies.map(({ job, name, stage }, i) => ({
|
|
388
440
|
jobName: name,
|
|
389
441
|
component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "green apply",
|
|
390
442
|
stage,
|
|
391
443
|
needs: i > 1 ? [applies[i - 1]!.name] : [],
|
|
392
444
|
})),
|
|
445
|
+
...(prEnv ? prEnv.listed : []),
|
|
393
446
|
];
|
|
394
|
-
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, ...applies.map((a) => a.name)].map((key) => [key, doc[key]] as const));
|
|
447
|
+
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...applies.map((a) => a.name), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]] as const));
|
|
395
448
|
return { yaml, stages, jobs, env };
|
|
396
449
|
}
|
|
397
450
|
|
|
451
|
+
/** The stage a pull request environment's jobs run in (#4298). */
|
|
452
|
+
const PR_ENV_STAGE = "env";
|
|
453
|
+
|
|
454
|
+
/** The rule of a scheduled pipeline, which runs `env-sweep` (#4298). */
|
|
455
|
+
const GITLAB_SCHEDULE_RULE = '$CI_PIPELINE_SOURCE == "schedule"';
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* A pull request environment's jobs on GitLab (#4298). `env-up` runs in the
|
|
459
|
+
* merge request pipeline and runs the change's code, so its script drops the
|
|
460
|
+
* forge tokens first, as the plan job's does (#4295), and it keeps its report
|
|
461
|
+
* and record as the artifact. `env-post`, at the target branch's commit,
|
|
462
|
+
* keeps the record on chant/lifecycle and posts the note. `env-up` starts a
|
|
463
|
+
* GitLab environment `pr-<iid>` whose stop job is `env-down`, so the
|
|
464
|
+
* environment's Stop button tears it down. GitLab starts no pipeline when a
|
|
465
|
+
* merge request closes, so `env-sweep`, from a pipeline schedule, is what
|
|
466
|
+
* tears down a closed merge request's environment unattended, as it does one
|
|
467
|
+
* whose TTL passed.
|
|
468
|
+
*/
|
|
469
|
+
function prEnvGitlabJobs(
|
|
470
|
+
image: string,
|
|
471
|
+
beforeScript: string[],
|
|
472
|
+
extraScript: string[],
|
|
473
|
+
enter: string[],
|
|
474
|
+
loop: NonNullable<GenerateGitlabOptions["prLoop"]>,
|
|
475
|
+
member: GenerateGitlabOptions["member"],
|
|
476
|
+
named: (job: string) => string,
|
|
477
|
+
onMergeRequest: string,
|
|
478
|
+
): { jobs: Record<string, unknown>; listed: GeneratedJob[] } {
|
|
479
|
+
const [up, post, down, sweep] = prEnvJobs("gitlab", loop, member?.name);
|
|
480
|
+
const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
|
|
481
|
+
const environment = "pr-$CI_MERGE_REQUEST_IID";
|
|
482
|
+
const group = `chant-pr-env-${member ? `${member.name}-` : ""}$CI_MERGE_REQUEST_IID`;
|
|
483
|
+
const script = (j: typeof up): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
|
|
484
|
+
const artifacts = { when: "always", paths: [reportPath] };
|
|
485
|
+
return {
|
|
486
|
+
jobs: {
|
|
487
|
+
[named("env-up")]: {
|
|
488
|
+
stage: PR_ENV_STAGE,
|
|
489
|
+
image,
|
|
490
|
+
rules: [{ if: onMergeRequest }],
|
|
491
|
+
resource_group: group,
|
|
492
|
+
environment: { name: environment, on_stop: named("env-down") },
|
|
493
|
+
variables: up.env,
|
|
494
|
+
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
495
|
+
script: [GITLAB_DROP_FORGE_TOKENS, ...script(up)],
|
|
496
|
+
artifacts,
|
|
497
|
+
},
|
|
498
|
+
[named(PR_ENV_POST_JOB)]: {
|
|
499
|
+
stage: PR_ENV_POST_STAGE,
|
|
500
|
+
image,
|
|
501
|
+
needs: [named("env-up")],
|
|
502
|
+
rules: [{ if: onMergeRequest, when: "always" }],
|
|
503
|
+
variables: post.env,
|
|
504
|
+
script: ['git checkout --quiet --detach "$BASE_SHA"', ...enter, ...beforeScript, post.command],
|
|
505
|
+
artifacts,
|
|
506
|
+
},
|
|
507
|
+
[named("env-down")]: {
|
|
508
|
+
stage: PR_ENV_STAGE,
|
|
509
|
+
image,
|
|
510
|
+
rules: [{ if: onMergeRequest, when: "manual" }],
|
|
511
|
+
allow_failure: true,
|
|
512
|
+
resource_group: group,
|
|
513
|
+
environment: { name: environment, action: "stop" },
|
|
514
|
+
variables: down.env,
|
|
515
|
+
script: script(down),
|
|
516
|
+
artifacts,
|
|
517
|
+
},
|
|
518
|
+
[named("env-sweep")]: {
|
|
519
|
+
stage: PR_ENV_STAGE,
|
|
520
|
+
image,
|
|
521
|
+
rules: [{ if: GITLAB_SCHEDULE_RULE }],
|
|
522
|
+
resource_group: `chant-pr-env-sweep${member ? `-${member.name}` : ""}`,
|
|
523
|
+
variables: sweep.env,
|
|
524
|
+
script: script(sweep),
|
|
525
|
+
artifacts,
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
listed: [
|
|
529
|
+
{ jobName: named("env-up"), component: "merge request environment", stage: PR_ENV_STAGE, needs: [] },
|
|
530
|
+
{ jobName: named(PR_ENV_POST_JOB), component: "merge request environment note", stage: PR_ENV_POST_STAGE, needs: [named("env-up")] },
|
|
531
|
+
{ jobName: named("env-down"), component: "merge request environment teardown", stage: PR_ENV_STAGE, needs: [] },
|
|
532
|
+
{ jobName: named("env-sweep"), component: "merge request environment sweep", stage: PR_ENV_STAGE, needs: [] },
|
|
533
|
+
],
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The stage the post job runs in, after `env-up` (#4295). */
|
|
538
|
+
const PR_ENV_POST_STAGE = "env-post";
|
|
539
|
+
|
|
398
540
|
/**
|
|
399
541
|
* Scope a pipeline to one workspace member (#2542, #2524 D19), in place on
|
|
400
542
|
* `doc` and `jobs`. Returns the promote job's new name.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A merge request's environment in the generated GitLab pipeline (#4298):
|
|
3
|
+
* `env-up` starts the GitLab environment `pr-<iid>` and drops the forge
|
|
4
|
+
* tokens before the merge request's code runs (#4295), `env-post` keeps the
|
|
5
|
+
* record and posts the note from the target branch's commit, `env-down` is
|
|
6
|
+
* the environment's stop job, and `env-sweep` runs from a pipeline schedule,
|
|
7
|
+
* since GitLab starts no pipeline when a merge request closes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { describe, expect, test } from "vitest";
|
|
15
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
16
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
17
|
+
import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
18
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
19
|
+
|
|
20
|
+
const ESTATE: DriverComponent[] = [{ name: "app", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "kubectl-apply", manifest: "app.yaml" } as never] }] }];
|
|
21
|
+
const SCHEMA = join(import.meta.dirname, "..", "codegen", "vendored", "gitlab-ci-schema.json");
|
|
22
|
+
|
|
23
|
+
interface Job {
|
|
24
|
+
stage: string;
|
|
25
|
+
needs?: string[];
|
|
26
|
+
rules: Array<{ if: string; when?: string }>;
|
|
27
|
+
variables: Record<string, string>;
|
|
28
|
+
script: string[];
|
|
29
|
+
environment?: Record<string, string>;
|
|
30
|
+
resource_group?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("the GitLab merge-request pipeline with a merge request environment", () => {
|
|
34
|
+
const result = generateGitlabPipeline(ESTATE, { env: "staging", image: "node:22", beforeScript: ["npm ci"], prLoop: { prEnvironment: true, build: true } });
|
|
35
|
+
const doc = parseYAML(result.yaml) as { workflow: { rules: Array<{ if: string }> }; stages: string[] } & Record<string, Job>;
|
|
36
|
+
|
|
37
|
+
test("validates against the vendored GitLab CI schema", () => {
|
|
38
|
+
const req = createRequire(import.meta.url);
|
|
39
|
+
const mod = req("ajv") as { default?: unknown };
|
|
40
|
+
const Ajv = (mod.default ?? mod) as new (opts: object) => { compile(s: object): ((d: unknown) => boolean) & { errors?: unknown } };
|
|
41
|
+
const validate = new Ajv({ strict: false, allErrors: true }).compile(JSON.parse(readFileSync(SCHEMA, "utf-8")));
|
|
42
|
+
expect(validate(doc), JSON.stringify(validate.errors, null, 2)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("env-up starts pr-<iid> with env-down as its stop job, one run at a time per merge request", () => {
|
|
46
|
+
const up = doc["env-up"]!;
|
|
47
|
+
expect(doc.stages).toEqual(expect.arrayContaining(["env", "env-post"]));
|
|
48
|
+
expect(up.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]);
|
|
49
|
+
expect(up.environment).toEqual({ name: "pr-$CI_MERGE_REQUEST_IID", on_stop: "env-down" });
|
|
50
|
+
expect(up.variables).toEqual({ BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", CHANT_ENV: "pr-$CI_MERGE_REQUEST_IID" });
|
|
51
|
+
expect(up.script.at(-1)).toBe('chant components pr-env up --base "$BASE_SHA" --pr "$PR_NUMBER" --output .chant/pr-env --no-push');
|
|
52
|
+
const down = doc["env-down"]!;
|
|
53
|
+
expect(down.environment).toEqual({ name: "pr-$CI_MERGE_REQUEST_IID", action: "stop" });
|
|
54
|
+
expect(down.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"', when: "manual" }]);
|
|
55
|
+
expect(down.resource_group).toBe(up.resource_group);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("env-sweep runs from a pipeline schedule, which the workflow rules let start", () => {
|
|
59
|
+
expect(doc["env-sweep"]!.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "schedule"' }]);
|
|
60
|
+
expect(doc.workflow.rules).toContainEqual({ if: '$CI_PIPELINE_SOURCE == "schedule"' });
|
|
61
|
+
expect(doc["env-sweep"]!.script.at(-1)).toBe("chant components pr-env sweep --forge gitlab --output .chant/pr-env");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("the GitLab env-up job holds no forge token while the merge request's code runs (#4295)", () => {
|
|
66
|
+
const result = generateGitlabPipeline(ESTATE, { env: "staging", image: "example/ci:1", beforeScript: ["npm ci"], prLoop: { prEnvironment: true, build: true } });
|
|
67
|
+
const doc = parseYAML(result.yaml) as Record<string, Job>;
|
|
68
|
+
|
|
69
|
+
test("env-up's script drops the tokens first, and every later line runs without them", () => {
|
|
70
|
+
const up = doc["env-up"]!;
|
|
71
|
+
expect(up.script[0]).toBe(GITLAB_DROP_FORGE_TOKENS);
|
|
72
|
+
const SECRET = "glpat-env-handover-4298";
|
|
73
|
+
const tokens = { GITLAB_TOKEN: SECRET, CHANT_FORGE_TOKEN: SECRET, CHANT_GITLAB_TOKEN: SECRET, CI_JOB_TOKEN: SECRET };
|
|
74
|
+
const lines = up.script.map((line, i) => (i === 0 ? line : `env | grep -c ${SECRET} || true`));
|
|
75
|
+
const out = execFileSync("sh", ["-c", lines.join("\n")], { env: { PATH: process.env.PATH ?? "/usr/bin:/bin", ...tokens }, encoding: "utf-8" });
|
|
76
|
+
expect(out.trim().split("\n").every((n) => n === "0")).toBe(true);
|
|
77
|
+
expect(up.variables).not.toHaveProperty("GITLAB_TOKEN");
|
|
78
|
+
expect(up.script.join("\n")).not.toContain("--forge");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("env-post takes env-up's artifact, checks out the target branch's commit, and keeps the record and posts", () => {
|
|
82
|
+
const post = doc["env-post"]!;
|
|
83
|
+
expect(post.stage).toBe("env-post");
|
|
84
|
+
expect(post.needs).toEqual(["env-up"]);
|
|
85
|
+
expect(post.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"', when: "always" }]);
|
|
86
|
+
expect(post.variables).toEqual({ PR_NUMBER: "$CI_MERGE_REQUEST_IID", HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" });
|
|
87
|
+
expect(post.script[0]).toBe('git checkout --quiet --detach "$BASE_SHA"');
|
|
88
|
+
expect(post.script.at(-1)).toBe('chant components pr-env post --pr "$PR_NUMBER" --head "$HEAD_SHA" --forge gitlab --output .chant/pr-env');
|
|
89
|
+
expect(post.script.join("\n")).not.toMatch(/chant (build|lint)|pr-env up/);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4296: the merge-request pipeline's plan job trades its `id_tokens` for
|
|
3
|
+
* the plan identity before any of the merge request's code runs, then
|
|
4
|
+
* unsets the token variables, so that code never holds a token or the apply
|
|
5
|
+
* identity.
|
|
6
|
+
*
|
|
7
|
+
* The behaviour test runs the plan job's script in one shell, as GitLab
|
|
8
|
+
* does, with the job's id token variables and forge tokens set, against a
|
|
9
|
+
* local emulator of AWS STS (@intentius/chant/__fixtures__/oidc-emulator),
|
|
10
|
+
* and every line after the trade replaced by a probe. Test values only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
19
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
20
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
21
|
+
import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
22
|
+
import { gitlabTradeLines, idTokenVar, OIDC_REQUEST_VARS, resolveCloudIdentity, TRADE_SPEC_ENV, tradeIdentities, type PipelineCloudIdentity } from "@intentius/chant/ci-identity";
|
|
23
|
+
import { emulatedCredentials, EMULATED_JWT_PREFIX, startOidcEmulator, type OidcEmulator } from "@intentius/chant/__fixtures__/oidc-emulator";
|
|
24
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
25
|
+
|
|
26
|
+
const run = promisify(execFile);
|
|
27
|
+
|
|
28
|
+
const ESTATE: DriverComponent[] = [{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] }];
|
|
29
|
+
|
|
30
|
+
const PLAN_ROLE = "arn:aws:iam::111111111111:role/prod-plan";
|
|
31
|
+
const APPLY_ROLE = "arn:aws:iam::111111111111:role/prod-apply";
|
|
32
|
+
const IDENTITY = resolveCloudIdentity(
|
|
33
|
+
{
|
|
34
|
+
aws: { rules: [{ plan: PLAN_ROLE, apply: APPLY_ROLE }] },
|
|
35
|
+
azure: { tenantId: "test-tenant", rules: [{ plan: "plan-client", apply: "apply-client" }] },
|
|
36
|
+
},
|
|
37
|
+
"prod",
|
|
38
|
+
)!;
|
|
39
|
+
|
|
40
|
+
interface GitlabJob {
|
|
41
|
+
id_tokens?: Record<string, { aud: string }>;
|
|
42
|
+
variables?: Record<string, string>;
|
|
43
|
+
script: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pipeline(identity: PipelineCloudIdentity | undefined): Record<string, GitlabJob> {
|
|
47
|
+
const { yaml } = generateGitlabPipeline(ESTATE, { env: "prod", image: "example/ci:1", beforeScript: ["npm ci"], prLoop: { applyOnMerge: true, ...(identity ? { identity } : {}) } });
|
|
48
|
+
return parseYAML(yaml) as Record<string, GitlabJob>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("the generated GitLab jobs' cloud identities (#4296)", () => {
|
|
52
|
+
const doc = pipeline(IDENTITY);
|
|
53
|
+
const plan = doc.plan!;
|
|
54
|
+
|
|
55
|
+
test("the plan job asks for one id token per cloud, and its script trades before anything else, then unsets the tokens", () => {
|
|
56
|
+
expect(plan.id_tokens).toEqual({ [idTokenVar("aws")]: { aud: "sts.amazonaws.com" }, [idTokenVar("azure")]: { aud: "api://AzureADTokenExchange" } });
|
|
57
|
+
expect(plan.script.slice(0, 3)).toEqual(gitlabTradeLines(FORGE_TOKEN_VARS));
|
|
58
|
+
expect(JSON.parse(plan.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.plan);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the plan job names no apply identity", () => {
|
|
62
|
+
const text = JSON.stringify(plan);
|
|
63
|
+
for (const id of tradeIdentities(IDENTITY.apply)) expect(text).not.toContain(id);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("the record job takes the plan identity and the apply job the apply identity", () => {
|
|
67
|
+
expect(JSON.parse(doc.record!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.plan);
|
|
68
|
+
expect(JSON.parse(doc.apply!.variables![TRADE_SPEC_ENV]!)).toEqual(IDENTITY.apply);
|
|
69
|
+
expect(doc.apply!.script.slice(0, 3)).toEqual(gitlabTradeLines());
|
|
70
|
+
expect(doc.apply!.id_tokens).toEqual(plan.id_tokens);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the plan-note job asks for no id token", () => {
|
|
74
|
+
expect(doc["plan-note"]!.id_tokens).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("without identity the plan script starts as #4295 left it, and no job asks for an id token", () => {
|
|
78
|
+
const plain = pipeline(undefined);
|
|
79
|
+
expect(plain.plan!.script[0]).toBe(GITLAB_DROP_FORGE_TOKENS);
|
|
80
|
+
for (const [name, job] of Object.entries(plain)) if (typeof job === "object" && job && "script" in job) expect((job as GitlabJob).id_tokens, name).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const dirs: string[] = [];
|
|
85
|
+
let emu: OidcEmulator;
|
|
86
|
+
beforeAll(async () => {
|
|
87
|
+
emu = await startOidcEmulator();
|
|
88
|
+
});
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await emu.close();
|
|
91
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const BASE = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" };
|
|
95
|
+
const FORGE_SECRET = "glpat_identity_secret_4296";
|
|
96
|
+
|
|
97
|
+
describe("the generated plan script, run: the merge request's code never holds a token or the apply identity (#4296)", () => {
|
|
98
|
+
test("after the trade, the shell and its children see plan credentials and none of the id tokens", async () => {
|
|
99
|
+
const identity: PipelineCloudIdentity = { plan: emu.pointAt(IDENTITY.plan), apply: emu.pointAt(IDENTITY.apply) };
|
|
100
|
+
const plan = pipeline(identity).plan!;
|
|
101
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-identity-gitlab-"));
|
|
102
|
+
dirs.push(dir);
|
|
103
|
+
const seen = join(dir, "seen");
|
|
104
|
+
const jwt = (cloud: string) => `${EMULATED_JWT_PREFIX}.${cloud}.gitlab`;
|
|
105
|
+
// GitLab runs the job's script lines in one shell, with the job's variables and id tokens set.
|
|
106
|
+
const script = [...plan.script.slice(0, 3), `env > "${seen}"`, `sh -c 'env' >> "${seen}"`].join("\n");
|
|
107
|
+
const file = join(dir, "job.sh");
|
|
108
|
+
writeFileSync(file, script);
|
|
109
|
+
const env = {
|
|
110
|
+
...BASE,
|
|
111
|
+
...Object.fromEntries(FORGE_TOKEN_VARS.map((v) => [v, `${FORGE_SECRET}_${v}`])),
|
|
112
|
+
...plan.variables,
|
|
113
|
+
[idTokenVar("aws")]: jwt("aws"),
|
|
114
|
+
[idTokenVar("azure")]: jwt("azure"),
|
|
115
|
+
CI_JOB_ID: "4296",
|
|
116
|
+
TMPDIR: dir,
|
|
117
|
+
};
|
|
118
|
+
const before = emu.log.assumed.length;
|
|
119
|
+
const r = await run("sh", ["-e", file], { cwd: dir, env }).catch((e: { stderr: string }) => {
|
|
120
|
+
throw new Error(e.stderr);
|
|
121
|
+
});
|
|
122
|
+
expect(r.stderr).toContain(`traded the job's OIDC token for ${PLAN_ROLE}`);
|
|
123
|
+
expect(emu.log.assumed.slice(before)).toEqual([{ role: PLAN_ROLE, token: jwt("aws") }]);
|
|
124
|
+
expect(emu.log.clients.at(-1)).toEqual({ clientId: "plan-client", token: jwt("azure") });
|
|
125
|
+
const text = readFileSync(seen, "utf-8");
|
|
126
|
+
expect(text).toContain(`AWS_ACCESS_KEY_ID=${emulatedCredentials(PLAN_ROLE).accessKeyId}`);
|
|
127
|
+
expect(text).toContain("AZURE_CLIENT_ID=plan-client");
|
|
128
|
+
expect(text).not.toContain(EMULATED_JWT_PREFIX);
|
|
129
|
+
expect(text).not.toContain(FORGE_SECRET);
|
|
130
|
+
expect(text).not.toContain(APPLY_ROLE);
|
|
131
|
+
expect(text).not.toContain("apply-client");
|
|
132
|
+
for (const v of OIDC_REQUEST_VARS) expect(text).not.toMatch(new RegExp(`^${v}=`, "m"));
|
|
133
|
+
// The credentials file is gone once sourced.
|
|
134
|
+
expect(readFileSync(seen, "utf-8")).not.toContain("chant-cloud-4296.sh=");
|
|
135
|
+
expect(() => readFileSync(join(dir, "chant-cloud-4296.sh"))).toThrow();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* CI schema this lexicon vendors.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
8
9
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
10
|
import { createRequire } from "node:module";
|
|
10
11
|
import { join } from "node:path";
|
|
@@ -13,6 +14,7 @@ import { parseYAML } from "@intentius/chant/yaml";
|
|
|
13
14
|
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
14
15
|
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
15
16
|
import { greenCheckout, PR_LOOP_SETUP, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
|
|
17
|
+
import { GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
16
18
|
|
|
17
19
|
const ESTATE: DriverComponent[] = [
|
|
18
20
|
{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
|
|
@@ -54,7 +56,9 @@ describe("the GitLab merge-request pipeline", () => {
|
|
|
54
56
|
test("plans in a merge request pipeline from its diff base, records on a push to the default branch, applies on a ci/green/ tag, one at a time", () => {
|
|
55
57
|
expect(doc.plan.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]);
|
|
56
58
|
expect(doc.plan.variables).toEqual({ BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID" });
|
|
59
|
+
// #4295: the merge request's code runs here, so the script drops the forge tokens first.
|
|
57
60
|
expect(doc.plan.script).toEqual([
|
|
61
|
+
GITLAB_DROP_FORGE_TOKENS,
|
|
58
62
|
...PR_LOOP_SETUP,'chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge gitlab']);
|
|
59
63
|
expect(doc.record.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
|
|
60
64
|
expect(doc.record.variables).toEqual({ BASE_SHA: "$CI_COMMIT_BEFORE_SHA" });
|
|
@@ -121,7 +125,7 @@ describe("the GitLab merge-request pipeline", () => {
|
|
|
121
125
|
const member = { name: "network", dir: "infra/network", file: ".gitlab/ci/chant-pr.network.prod.gitlab-ci.yml" };
|
|
122
126
|
const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, member });
|
|
123
127
|
const mdoc = parseYAML(scoped.yaml) as Record<string, unknown> & { workflow: { name?: string; rules: unknown[] } };
|
|
124
|
-
expect(scoped.jobs.map((j) => j.jobName)).toEqual(["network-plan", "network-record", "network-apply"]);
|
|
128
|
+
expect(scoped.jobs.map((j) => j.jobName)).toEqual(["network-plan", "network-plan-note", "network-record", "network-apply"]);
|
|
125
129
|
expect(mdoc.plan).toBeUndefined();
|
|
126
130
|
// Included files merge top-level keys: no name, so members do not collide on it.
|
|
127
131
|
expect(mdoc.workflow.name).toBeUndefined();
|
|
@@ -162,7 +166,7 @@ describe("the gitlab pull-request pipeline with build (#4088)", () => {
|
|
|
162
166
|
const script = (job: string) => (doc[job] as { script: string[] }).script;
|
|
163
167
|
for (const job of ["plan", "apply"]) {
|
|
164
168
|
const lines = script(job);
|
|
165
|
-
const i = lines.findIndex((l) => l.startsWith(
|
|
169
|
+
const i = lines.findIndex((l) => l.startsWith(`chant ci run ${job}`));
|
|
166
170
|
expect(lines.slice(i - 2, i)).toEqual(["chant build --env prod", "chant lint"]);
|
|
167
171
|
}
|
|
168
172
|
});
|
|
@@ -199,7 +203,7 @@ describe("the GitLab merge-request pipeline records on merge by default (#4168)"
|
|
|
199
203
|
});
|
|
200
204
|
|
|
201
205
|
test("a push to the default branch runs the record job, which applies nothing and keeps no cache", () => {
|
|
202
|
-
expect(doc.stages).toEqual(["plan", "record"]);
|
|
206
|
+
expect(doc.stages).toEqual(["plan", "plan-note", "record"]);
|
|
203
207
|
expect(doc.apply).toBeUndefined();
|
|
204
208
|
expect(doc.record.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
|
|
205
209
|
expect(doc.record.script.at(-1)).toBe('chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab');
|
|
@@ -217,3 +221,34 @@ describe("the GitLab merge-request pipeline records on merge by default (#4168)"
|
|
|
217
221
|
expect(scoped["network-record"].script.at(-1)).toContain("chant ci run record");
|
|
218
222
|
});
|
|
219
223
|
});
|
|
224
|
+
|
|
225
|
+
describe("the GitLab plan job holds no forge token while the merge request's code runs (#4295)", () => {
|
|
226
|
+
const result = generateGitlabPipeline(ESTATE, { env: "prod", image: "example/ci:1", beforeScript: ["npm ci"], prLoop: { build: true } });
|
|
227
|
+
const doc = parseYAML(result.yaml) as Record<string, Job & { stage: string; rules: Array<{ if: string; when?: string }> }>;
|
|
228
|
+
|
|
229
|
+
test("the plan's script drops the tokens first, and every later line runs without them", () => {
|
|
230
|
+
const plan = doc.plan!;
|
|
231
|
+
expect(plan.script[0]).toBe(GITLAB_DROP_FORGE_TOKENS);
|
|
232
|
+
// GitLab runs the script in one shell: each line after the first, run there with the tokens set, sees none of them.
|
|
233
|
+
const SECRET = "glpat-handover-4295";
|
|
234
|
+
const tokens = { GITLAB_TOKEN: SECRET, CHANT_FORGE_TOKEN: SECRET, CHANT_GITLAB_TOKEN: SECRET, CI_JOB_TOKEN: SECRET };
|
|
235
|
+
const lines = plan.script.map((line, i) => (i === 0 ? line : `env | grep -c ${SECRET} || true`));
|
|
236
|
+
const out = execFileSync("sh", ["-c", lines.join("\n")], { env: { PATH: process.env.PATH ?? "/usr/bin:/bin", ...tokens }, encoding: "utf-8" });
|
|
237
|
+
expect(out.trim().split("\n").every((n) => n === "0")).toBe(true);
|
|
238
|
+
expect(plan.variables).not.toHaveProperty("GITLAB_TOKEN");
|
|
239
|
+
expect(prLoopJobs("gitlab", "prod")[0]!.command.endsWith(" --no-status")).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("the plan-note job takes the plan's artifact, checks out the target branch's commit, and posts", () => {
|
|
243
|
+
const note = doc["plan-note"]!;
|
|
244
|
+
expect(note.stage).toBe("plan-note");
|
|
245
|
+
expect(note.needs).toEqual(["plan"]);
|
|
246
|
+
expect(note.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"', when: "always" }]);
|
|
247
|
+
expect(note.variables).toEqual({ PR_NUMBER: "$CI_MERGE_REQUEST_IID", HEAD_SHA: "$CI_COMMIT_SHA", BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA" });
|
|
248
|
+
expect(note.script[0]).toBe('git checkout --quiet --detach "$BASE_SHA"');
|
|
249
|
+
expect(note.script.at(-1)).toBe('chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge gitlab');
|
|
250
|
+
// It runs no build, lint or plan: none of the merge request's code.
|
|
251
|
+
expect(note.script.join("\n")).not.toMatch(/chant (build|lint|ci run)/);
|
|
252
|
+
expect(result.stages).toEqual(["plan", "plan-note", "record"]);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -62,9 +62,10 @@ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () =>
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
test("one apply job and stage per wave, each needing the one before, on the merge, one at a time", () => {
|
|
65
|
-
expect(doc.stages).toEqual(["plan", "record", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
|
|
65
|
+
expect(doc.stages).toEqual(["plan", "plan-note", "record", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
|
|
66
66
|
expect(result.jobs.map((j) => [j.jobName, j.needs])).toEqual([
|
|
67
67
|
["plan", []],
|
|
68
|
+
["plan-note", ["plan"]],
|
|
68
69
|
["record", []],
|
|
69
70
|
["apply-wave-1", []],
|
|
70
71
|
["apply-wave-2", ["apply-wave-1"]],
|
|
@@ -89,6 +90,7 @@ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () =>
|
|
|
89
90
|
const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, gatedWaves: { gate: "release" }, member });
|
|
90
91
|
expect(scoped.jobs.map((j) => [j.jobName, j.needs])).toEqual([
|
|
91
92
|
["network-plan", []],
|
|
93
|
+
["network-plan-note", ["network-plan"]],
|
|
92
94
|
["network-record", []],
|
|
93
95
|
["network-apply-wave-1", []],
|
|
94
96
|
["network-apply-wave-2", ["network-apply-wave-1"]],
|