@intentius/chant-lexicon-gitlab 0.124.40 → 0.124.42
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 +147 -12
- 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 +167 -11
- package/src/components/generate-pr-env-pipeline.test.ts +91 -0
- package/src/components/generate-pr-pipeline.before-merge.test.ts +37 -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;AAwBjG,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, GITLAB_APPLY_PR_VAR, 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,9 +259,37 @@ 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";
|
|
282
|
+
/**
|
|
283
|
+
* The rule of the apply before merge (#4301, ws-106): a pipeline of `branch`
|
|
284
|
+
* that a person started from the UI, the API or a trigger with
|
|
285
|
+
* `CHANT_APPLY_PR` set to the merge request's iid. GitLab builds a merge
|
|
286
|
+
* request's own pipeline from its own files, so the apply runs in one of the
|
|
287
|
+
* target branch's. The variable only names the merge request; `pr-apply
|
|
288
|
+
* --before-merge` reads the rest from GitLab.
|
|
289
|
+
*/
|
|
290
|
+
function gitlabApplyPrRule(branch) {
|
|
291
|
+
return `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${GITLAB_APPLY_PR_VAR} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${branch}`;
|
|
292
|
+
}
|
|
261
293
|
/** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
|
|
262
294
|
const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
|
|
263
295
|
function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript, options) {
|
|
@@ -272,7 +304,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
272
304
|
// #4168 step 9: a push to the branch records, and under apply on merge a ci/green/ tag pipeline applies.
|
|
273
305
|
const greens = loop.waveGate
|
|
274
306
|
? waveJobs.map((job) => ({ job, name: named(prApplyWaveJobName(job.wave)), stage: prApplyWaveJobName(job.wave) }))
|
|
275
|
-
: loop.applyOnMerge
|
|
307
|
+
: loop.applyOnMerge || loop.applyBeforeMerge
|
|
276
308
|
? [{ job: apply, name: named("apply"), stage: "apply" }]
|
|
277
309
|
: [];
|
|
278
310
|
const applies = [{ job: record, name: named("record"), stage: "record" }, ...greens];
|
|
@@ -280,28 +312,50 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
280
312
|
if (loop.branch !== undefined)
|
|
281
313
|
assertGitlabBranch("The merge-request pipeline", loop.branch);
|
|
282
314
|
const onMerge = gitlabPushRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
|
|
283
|
-
|
|
315
|
+
// #4301: the apply before merge runs in a pipeline of the target branch a person starts with CHANT_APPLY_PR set.
|
|
316
|
+
const onGreen = loop.applyBeforeMerge ? gitlabApplyPrRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH") : GITLAB_GREEN_TAG_RULE;
|
|
284
317
|
// A member's jobs start in its directory, after the image's install lines.
|
|
285
318
|
const enter = rooted ? [] : [`cd ${memberShellDir(member)}`];
|
|
286
319
|
// The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY variable is set.
|
|
287
320
|
// #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
|
|
288
321
|
const handOff = (job) => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
|
|
289
322
|
const script = (job) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
|
|
290
|
-
|
|
323
|
+
// #4296: each job trades its OIDC token for its cloud identity before anything else in its script.
|
|
324
|
+
const identity = loop.identity;
|
|
325
|
+
const trading = (spec) => spec ? { id_tokens: gitlabTradeIdTokens(spec) } : {};
|
|
326
|
+
const tradeVars = (spec) => (spec ? { [TRADE_SPEC_ENV]: JSON.stringify(spec) } : {});
|
|
327
|
+
const applySpec = (job) => (identity ? (job.jobName === "record" ? identity.plan : identity.apply) : undefined);
|
|
328
|
+
// #4298: a pull request environment's jobs, in a stage of their own after the plan.
|
|
329
|
+
const prEnv = loop.prEnvironment ? prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) : undefined;
|
|
330
|
+
const stages = ["plan", PR_PLAN_NOTE_JOB, ...applies.map((a) => a.stage), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
|
|
331
|
+
const noteJob = named(PR_PLAN_NOTE_JOB);
|
|
332
|
+
const post = prLoopPostJob("gitlab", env, member?.name);
|
|
291
333
|
const doc = {
|
|
292
334
|
// Included files merge their top-level keys, so a member's file carries
|
|
293
335
|
// 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 }] : [])] },
|
|
336
|
+
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
|
|
295
337
|
stages,
|
|
296
338
|
variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
|
|
297
339
|
[planJob]: {
|
|
298
340
|
stage: "plan",
|
|
299
341
|
image,
|
|
300
342
|
rules: [{ if: onMergeRequest }],
|
|
301
|
-
|
|
302
|
-
|
|
343
|
+
...trading(identity?.plan),
|
|
344
|
+
variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
|
|
345
|
+
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
346
|
+
// #4296: with identity, the trade runs before that, and the same unset drops the token variables too.
|
|
347
|
+
script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
|
|
303
348
|
artifacts: { when: "always", paths: [reportPath] },
|
|
304
349
|
},
|
|
350
|
+
// #4295: a fresh job at the target branch's commit posts the note and the status from the plan's report.
|
|
351
|
+
[noteJob]: {
|
|
352
|
+
stage: PR_PLAN_NOTE_JOB,
|
|
353
|
+
image,
|
|
354
|
+
needs: [planJob],
|
|
355
|
+
rules: [{ if: onMergeRequest, when: "always" }],
|
|
356
|
+
variables: post.env,
|
|
357
|
+
script: ['git checkout --quiet --detach "$BASE_SHA"', ...enter, ...beforeScript, post.command],
|
|
358
|
+
},
|
|
305
359
|
...Object.fromEntries(applies.map(({ job, name, stage }, i) => [
|
|
306
360
|
name,
|
|
307
361
|
{
|
|
@@ -309,9 +363,11 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
309
363
|
image,
|
|
310
364
|
...(i > 1 ? { needs: [applies[i - 1].name] } : {}),
|
|
311
365
|
resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
366
|
+
// #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
|
|
367
|
+
rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
|
|
368
|
+
...trading(applySpec(job)),
|
|
369
|
+
variables: { ...job.run.env, ...tradeVars(applySpec(job)) },
|
|
370
|
+
script: [...(applySpec(job) ? gitlabTradeLines() : []), ...script(job)],
|
|
315
371
|
// The attempt record (#3543), kept under the pushed commit even when
|
|
316
372
|
// the job fails, so a retry finishes the apply under the same approval.
|
|
317
373
|
// The record job (#4168) applies nothing and keeps none.
|
|
@@ -327,19 +383,98 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
|
|
|
327
383
|
artifacts: { when: "always", paths: [reportPath], ...(job.wave ? { reports: { dotenv: WAVE_GREEN_DOTENV } } : {}) },
|
|
328
384
|
},
|
|
329
385
|
])),
|
|
386
|
+
...(prEnv ? prEnv.jobs : {}),
|
|
330
387
|
};
|
|
331
388
|
const jobs = [
|
|
332
389
|
{ jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
|
|
390
|
+
{ jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [planJob] },
|
|
333
391
|
...applies.map(({ job, name, stage }, i) => ({
|
|
334
392
|
jobName: name,
|
|
335
|
-
component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "green apply",
|
|
393
|
+
component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
|
|
336
394
|
stage,
|
|
337
395
|
needs: i > 1 ? [applies[i - 1].name] : [],
|
|
338
396
|
})),
|
|
397
|
+
...(prEnv ? prEnv.listed : []),
|
|
339
398
|
];
|
|
340
|
-
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, ...applies.map((a) => a.name)].map((key) => [key, doc[key]]));
|
|
399
|
+
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, noteJob, ...applies.map((a) => a.name), ...Object.keys(prEnv?.jobs ?? {})].map((key) => [key, doc[key]]));
|
|
341
400
|
return { yaml, stages, jobs, env };
|
|
342
401
|
}
|
|
402
|
+
/** The stage a pull request environment's jobs run in (#4298). */
|
|
403
|
+
const PR_ENV_STAGE = "env";
|
|
404
|
+
/** The rule of a scheduled pipeline, which runs `env-sweep` (#4298). */
|
|
405
|
+
const GITLAB_SCHEDULE_RULE = '$CI_PIPELINE_SOURCE == "schedule"';
|
|
406
|
+
/**
|
|
407
|
+
* A pull request environment's jobs on GitLab (#4298). `env-up` runs in the
|
|
408
|
+
* merge request pipeline and runs the change's code, so its script drops the
|
|
409
|
+
* forge tokens first, as the plan job's does (#4295), and it keeps its report
|
|
410
|
+
* and record as the artifact. `env-post`, at the target branch's commit,
|
|
411
|
+
* keeps the record on chant/lifecycle and posts the note. `env-up` starts a
|
|
412
|
+
* GitLab environment `pr-<iid>` whose stop job is `env-down`, so the
|
|
413
|
+
* environment's Stop button tears it down. GitLab starts no pipeline when a
|
|
414
|
+
* merge request closes, so `env-sweep`, from a pipeline schedule, is what
|
|
415
|
+
* tears down a closed merge request's environment unattended, as it does one
|
|
416
|
+
* whose TTL passed.
|
|
417
|
+
*/
|
|
418
|
+
function prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) {
|
|
419
|
+
const [up, post, down, sweep] = prEnvJobs("gitlab", loop, member?.name);
|
|
420
|
+
const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
|
|
421
|
+
const environment = "pr-$CI_MERGE_REQUEST_IID";
|
|
422
|
+
const group = `chant-pr-env-${member ? `${member.name}-` : ""}$CI_MERGE_REQUEST_IID`;
|
|
423
|
+
const script = (j) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
|
|
424
|
+
const artifacts = { when: "always", paths: [reportPath] };
|
|
425
|
+
return {
|
|
426
|
+
jobs: {
|
|
427
|
+
[named("env-up")]: {
|
|
428
|
+
stage: PR_ENV_STAGE,
|
|
429
|
+
image,
|
|
430
|
+
rules: [{ if: onMergeRequest }],
|
|
431
|
+
resource_group: group,
|
|
432
|
+
environment: { name: environment, on_stop: named("env-down") },
|
|
433
|
+
variables: up.env,
|
|
434
|
+
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
435
|
+
script: [GITLAB_DROP_FORGE_TOKENS, ...script(up)],
|
|
436
|
+
artifacts,
|
|
437
|
+
},
|
|
438
|
+
[named(PR_ENV_POST_JOB)]: {
|
|
439
|
+
stage: PR_ENV_POST_STAGE,
|
|
440
|
+
image,
|
|
441
|
+
needs: [named("env-up")],
|
|
442
|
+
rules: [{ if: onMergeRequest, when: "always" }],
|
|
443
|
+
variables: post.env,
|
|
444
|
+
script: ['git checkout --quiet --detach "$BASE_SHA"', ...enter, ...beforeScript, post.command],
|
|
445
|
+
artifacts,
|
|
446
|
+
},
|
|
447
|
+
[named("env-down")]: {
|
|
448
|
+
stage: PR_ENV_STAGE,
|
|
449
|
+
image,
|
|
450
|
+
rules: [{ if: onMergeRequest, when: "manual" }],
|
|
451
|
+
allow_failure: true,
|
|
452
|
+
resource_group: group,
|
|
453
|
+
environment: { name: environment, action: "stop" },
|
|
454
|
+
variables: down.env,
|
|
455
|
+
script: script(down),
|
|
456
|
+
artifacts,
|
|
457
|
+
},
|
|
458
|
+
[named("env-sweep")]: {
|
|
459
|
+
stage: PR_ENV_STAGE,
|
|
460
|
+
image,
|
|
461
|
+
rules: [{ if: GITLAB_SCHEDULE_RULE }],
|
|
462
|
+
resource_group: `chant-pr-env-sweep${member ? `-${member.name}` : ""}`,
|
|
463
|
+
variables: sweep.env,
|
|
464
|
+
script: script(sweep),
|
|
465
|
+
artifacts,
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
listed: [
|
|
469
|
+
{ jobName: named("env-up"), component: "merge request environment", stage: PR_ENV_STAGE, needs: [] },
|
|
470
|
+
{ jobName: named(PR_ENV_POST_JOB), component: "merge request environment note", stage: PR_ENV_POST_STAGE, needs: [named("env-up")] },
|
|
471
|
+
{ jobName: named("env-down"), component: "merge request environment teardown", stage: PR_ENV_STAGE, needs: [] },
|
|
472
|
+
{ jobName: named("env-sweep"), component: "merge request environment sweep", stage: PR_ENV_STAGE, needs: [] },
|
|
473
|
+
],
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/** The stage the post job runs in, after `env-up` (#4295). */
|
|
477
|
+
const PR_ENV_POST_STAGE = "env-post";
|
|
343
478
|
/**
|
|
344
479
|
* Scope a pipeline to one workspace member (#2542, #2524 D19), in place on
|
|
345
480
|
* `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": "5eadeadbe17078f803f0afde8b3d6153f381305adae156307271887f23355170",
|
|
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": "fcd516d1ec373a23058e366255e162fc527da7d2e62ce01a399255a37d893c91"
|
|
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.42",
|
|
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.42",
|
|
71
|
+
"@intentius/chant-lexicon-github": "^0.124.42"
|
|
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,19 @@ import {
|
|
|
52
52
|
prApplyRecordKey,
|
|
53
53
|
prApplyWaveJobName,
|
|
54
54
|
prLoopJobs,
|
|
55
|
+
GITLAB_APPLY_PR_VAR,
|
|
56
|
+
prLoopPostJob,
|
|
55
57
|
prLoopSetup,
|
|
58
|
+
PR_PLAN_NOTE_JOB,
|
|
56
59
|
prLoopWaveJobs,
|
|
57
60
|
prLoopWithWaves,
|
|
61
|
+
prEnvJobs,
|
|
62
|
+
PR_ENV_REPORT_DIR,
|
|
63
|
+
PR_ENV_POST_JOB,
|
|
58
64
|
} from "@intentius/chant/components/pr-pipeline";
|
|
59
65
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
66
|
+
import { FORGE_TOKEN_VARS, GITLAB_DROP_FORGE_TOKENS } from "@intentius/chant/ci-handover";
|
|
67
|
+
import { gitlabTradeIdTokens, gitlabTradeLines, TRADE_SPEC_ENV, type CloudTradeSpec } from "@intentius/chant/ci-identity";
|
|
60
68
|
import type {
|
|
61
69
|
ComponentPipelineJob as GeneratedJob,
|
|
62
70
|
ComponentPipelineOptions as GenerateGitlabOptions,
|
|
@@ -258,6 +266,8 @@ function gatedWaveGitlabPipeline(
|
|
|
258
266
|
variables: { ...GITLAB_FULL_HISTORY },
|
|
259
267
|
script: [...prLoopSetup(image), ...beforeScript, job.command.join(" "), ...extraScript],
|
|
260
268
|
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
269
|
+
// #4291: a wave runs after one that failed, and blocks only what depends on the failure.
|
|
270
|
+
...(job.needs.length > 0 ? { when: "always" } : {}),
|
|
261
271
|
artifacts: { when: "always", paths: [GATED_WAVE_RECORD] },
|
|
262
272
|
};
|
|
263
273
|
}
|
|
@@ -299,10 +309,39 @@ function gatedWaveGitlabPipeline(
|
|
|
299
309
|
* before. They share the commit's cache key, so wave k pulls the record wave
|
|
300
310
|
* k-1 pushed, and a retry of a wave that stopped at its gate pulls the one it
|
|
301
311
|
* pushed itself.
|
|
312
|
+
*
|
|
313
|
+
* The plan job holds no forge token while the merge request's code runs
|
|
314
|
+
* (#4295, `@intentius/chant/ci-handover`): its script starts by unsetting
|
|
315
|
+
* the token variables, and `pr-plan --no-status` writes the note and the
|
|
316
|
+
* status into its report. The `plan-note` job after it, which GitLab hands
|
|
317
|
+
* the report artifact, checks out the target branch's commit and posts them
|
|
318
|
+
* with `chant components pr-post`. A CI/CD variable reaches every job of a
|
|
319
|
+
* merge request pipeline, so `unset` keeps it from the plan's code, and
|
|
320
|
+
* `CHANT_FORGE_TOKEN` as a protected variable keeps it from the pipeline
|
|
321
|
+
* altogether.
|
|
322
|
+
*
|
|
323
|
+
* With `identity` (#4296, `@intentius/chant/ci-identity`) the plan, record
|
|
324
|
+
* and apply jobs take an `id_tokens:` entry per cloud, and each script
|
|
325
|
+
* starts by trading it for the job's cloud identity: the plan and record
|
|
326
|
+
* jobs for the plan identity, the apply jobs for the apply identity. The
|
|
327
|
+
* plan job trades before any of the merge request's code runs, then unsets
|
|
328
|
+
* the token variables with the forge tokens and sources the credentials.
|
|
302
329
|
*/
|
|
303
330
|
/** The dotenv report a gated apply wave hands the green commit it applies through (#4221). */
|
|
304
331
|
const WAVE_GREEN_DOTENV = "chant-green.env";
|
|
305
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The rule of the apply before merge (#4301, ws-106): a pipeline of `branch`
|
|
335
|
+
* that a person started from the UI, the API or a trigger with
|
|
336
|
+
* `CHANT_APPLY_PR` set to the merge request's iid. GitLab builds a merge
|
|
337
|
+
* request's own pipeline from its own files, so the apply runs in one of the
|
|
338
|
+
* target branch's. The variable only names the merge request; `pr-apply
|
|
339
|
+
* --before-merge` reads the rest from GitLab.
|
|
340
|
+
*/
|
|
341
|
+
function gitlabApplyPrRule(branch: string): string {
|
|
342
|
+
return `$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $${GITLAB_APPLY_PR_VAR} =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == ${branch}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
306
345
|
/** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
|
|
307
346
|
const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
|
|
308
347
|
|
|
@@ -325,35 +364,58 @@ function prLoopGitlabPipeline(
|
|
|
325
364
|
// #4168 step 9: a push to the branch records, and under apply on merge a ci/green/ tag pipeline applies.
|
|
326
365
|
const greens = loop.waveGate
|
|
327
366
|
? waveJobs.map((job) => ({ job, name: named(prApplyWaveJobName(job.wave!)), stage: prApplyWaveJobName(job.wave!) }))
|
|
328
|
-
: loop.applyOnMerge
|
|
367
|
+
: loop.applyOnMerge || loop.applyBeforeMerge
|
|
329
368
|
? [{ job: apply, name: named("apply"), stage: "apply" }]
|
|
330
369
|
: [];
|
|
331
370
|
const applies = [{ job: record, name: named("record"), stage: "record" }, ...greens];
|
|
332
371
|
const onMergeRequest = GITLAB_MERGE_REQUEST_SOURCE;
|
|
333
372
|
if (loop.branch !== undefined) assertGitlabBranch("The merge-request pipeline", loop.branch);
|
|
334
373
|
const onMerge = gitlabPushRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
|
|
335
|
-
|
|
374
|
+
// #4301: the apply before merge runs in a pipeline of the target branch a person starts with CHANT_APPLY_PR set.
|
|
375
|
+
const onGreen = loop.applyBeforeMerge ? gitlabApplyPrRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH") : GITLAB_GREEN_TAG_RULE;
|
|
336
376
|
// A member's jobs start in its directory, after the image's install lines.
|
|
337
377
|
const enter = rooted ? [] : [`cd ${memberShellDir(member!)}`];
|
|
338
378
|
// The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY variable is set.
|
|
339
379
|
// #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
|
|
340
380
|
const handOff = (job: typeof plan): string[] => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
|
|
341
381
|
const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
|
|
342
|
-
|
|
382
|
+
// #4296: each job trades its OIDC token for its cloud identity before anything else in its script.
|
|
383
|
+
const identity = loop.identity;
|
|
384
|
+
const trading = (spec: CloudTradeSpec | undefined): Record<string, unknown> =>
|
|
385
|
+
spec ? { id_tokens: gitlabTradeIdTokens(spec) } : {};
|
|
386
|
+
const tradeVars = (spec: CloudTradeSpec | undefined): Record<string, string> => (spec ? { [TRADE_SPEC_ENV]: JSON.stringify(spec) } : {});
|
|
387
|
+
const applySpec = (job: typeof plan): CloudTradeSpec | undefined => (identity ? (job.jobName === "record" ? identity.plan : identity.apply) : undefined);
|
|
388
|
+
// #4298: a pull request environment's jobs, in a stage of their own after the plan.
|
|
389
|
+
const prEnv = loop.prEnvironment ? prEnvGitlabJobs(image, beforeScript, extraScript, enter, loop, member, named, onMergeRequest) : undefined;
|
|
390
|
+
const stages = ["plan", PR_PLAN_NOTE_JOB, ...applies.map((a) => a.stage), ...(prEnv ? [PR_ENV_STAGE, PR_ENV_POST_STAGE] : [])];
|
|
391
|
+
const noteJob = named(PR_PLAN_NOTE_JOB);
|
|
392
|
+
const post = prLoopPostJob("gitlab", env, member?.name);
|
|
343
393
|
const doc: Record<string, unknown> = {
|
|
344
394
|
// Included files merge their top-level keys, so a member's file carries
|
|
345
395
|
// 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 }] : [])] },
|
|
396
|
+
workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, ...(greens.length > 0 ? [{ if: onGreen }] : []), ...(prEnv ? [{ if: GITLAB_SCHEDULE_RULE }] : [])] },
|
|
347
397
|
stages,
|
|
348
398
|
variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
|
|
349
399
|
[planJob]: {
|
|
350
400
|
stage: "plan",
|
|
351
401
|
image,
|
|
352
402
|
rules: [{ if: onMergeRequest }],
|
|
353
|
-
|
|
354
|
-
|
|
403
|
+
...trading(identity?.plan),
|
|
404
|
+
variables: { ...plan.run.env, ...tradeVars(identity?.plan) },
|
|
405
|
+
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
406
|
+
// #4296: with identity, the trade runs before that, and the same unset drops the token variables too.
|
|
407
|
+
script: [...(identity ? gitlabTradeLines(FORGE_TOKEN_VARS) : [GITLAB_DROP_FORGE_TOKENS]), ...script(plan)],
|
|
355
408
|
artifacts: { when: "always", paths: [reportPath] },
|
|
356
409
|
},
|
|
410
|
+
// #4295: a fresh job at the target branch's commit posts the note and the status from the plan's report.
|
|
411
|
+
[noteJob]: {
|
|
412
|
+
stage: PR_PLAN_NOTE_JOB,
|
|
413
|
+
image,
|
|
414
|
+
needs: [planJob],
|
|
415
|
+
rules: [{ if: onMergeRequest, when: "always" }],
|
|
416
|
+
variables: post.env,
|
|
417
|
+
script: ['git checkout --quiet --detach "$BASE_SHA"', ...enter, ...beforeScript, post.command],
|
|
418
|
+
},
|
|
357
419
|
...Object.fromEntries(
|
|
358
420
|
applies.map(({ job, name, stage }, i) => [
|
|
359
421
|
name,
|
|
@@ -362,9 +424,11 @@ function prLoopGitlabPipeline(
|
|
|
362
424
|
image,
|
|
363
425
|
...(i > 1 ? { needs: [applies[i - 1]!.name] } : {}),
|
|
364
426
|
resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
427
|
+
// #4291: a wave after the first runs after one that failed, and blocks only what depends on the failure.
|
|
428
|
+
rules: [{ if: job.jobName === "record" ? onMerge : onGreen, ...(job.wave && i > 1 ? { when: "always" } : {}) }],
|
|
429
|
+
...trading(applySpec(job)),
|
|
430
|
+
variables: { ...job.run.env, ...tradeVars(applySpec(job)) },
|
|
431
|
+
script: [...(applySpec(job) ? gitlabTradeLines() : []), ...script(job)],
|
|
368
432
|
// The attempt record (#3543), kept under the pushed commit even when
|
|
369
433
|
// the job fails, so a retry finishes the apply under the same approval.
|
|
370
434
|
// The record job (#4168) applies nothing and keeps none.
|
|
@@ -381,20 +445,112 @@ function prLoopGitlabPipeline(
|
|
|
381
445
|
},
|
|
382
446
|
]),
|
|
383
447
|
),
|
|
448
|
+
...(prEnv ? prEnv.jobs : {}),
|
|
384
449
|
};
|
|
385
450
|
const jobs: GeneratedJob[] = [
|
|
386
451
|
{ jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
|
|
452
|
+
{ jobName: noteJob, component: "merge request plan note", stage: PR_PLAN_NOTE_JOB, needs: [planJob] },
|
|
387
453
|
...applies.map(({ job, name, stage }, i) => ({
|
|
388
454
|
jobName: name,
|
|
389
|
-
component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "green apply",
|
|
455
|
+
component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : loop.applyBeforeMerge ? "apply before merge" : "green apply",
|
|
390
456
|
stage,
|
|
391
457
|
needs: i > 1 ? [applies[i - 1]!.name] : [],
|
|
392
458
|
})),
|
|
459
|
+
...(prEnv ? prEnv.listed : []),
|
|
393
460
|
];
|
|
394
|
-
const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, ...applies.map((a) => a.name)].map((key) => [key, doc[key]] as const));
|
|
461
|
+
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
462
|
return { yaml, stages, jobs, env };
|
|
396
463
|
}
|
|
397
464
|
|
|
465
|
+
/** The stage a pull request environment's jobs run in (#4298). */
|
|
466
|
+
const PR_ENV_STAGE = "env";
|
|
467
|
+
|
|
468
|
+
/** The rule of a scheduled pipeline, which runs `env-sweep` (#4298). */
|
|
469
|
+
const GITLAB_SCHEDULE_RULE = '$CI_PIPELINE_SOURCE == "schedule"';
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* A pull request environment's jobs on GitLab (#4298). `env-up` runs in the
|
|
473
|
+
* merge request pipeline and runs the change's code, so its script drops the
|
|
474
|
+
* forge tokens first, as the plan job's does (#4295), and it keeps its report
|
|
475
|
+
* and record as the artifact. `env-post`, at the target branch's commit,
|
|
476
|
+
* keeps the record on chant/lifecycle and posts the note. `env-up` starts a
|
|
477
|
+
* GitLab environment `pr-<iid>` whose stop job is `env-down`, so the
|
|
478
|
+
* environment's Stop button tears it down. GitLab starts no pipeline when a
|
|
479
|
+
* merge request closes, so `env-sweep`, from a pipeline schedule, is what
|
|
480
|
+
* tears down a closed merge request's environment unattended, as it does one
|
|
481
|
+
* whose TTL passed.
|
|
482
|
+
*/
|
|
483
|
+
function prEnvGitlabJobs(
|
|
484
|
+
image: string,
|
|
485
|
+
beforeScript: string[],
|
|
486
|
+
extraScript: string[],
|
|
487
|
+
enter: string[],
|
|
488
|
+
loop: NonNullable<GenerateGitlabOptions["prLoop"]>,
|
|
489
|
+
member: GenerateGitlabOptions["member"],
|
|
490
|
+
named: (job: string) => string,
|
|
491
|
+
onMergeRequest: string,
|
|
492
|
+
): { jobs: Record<string, unknown>; listed: GeneratedJob[] } {
|
|
493
|
+
const [up, post, down, sweep] = prEnvJobs("gitlab", loop, member?.name);
|
|
494
|
+
const reportPath = member ? memberRepoPath(member, PR_ENV_REPORT_DIR) : PR_ENV_REPORT_DIR;
|
|
495
|
+
const environment = "pr-$CI_MERGE_REQUEST_IID";
|
|
496
|
+
const group = `chant-pr-env-${member ? `${member.name}-` : ""}$CI_MERGE_REQUEST_IID`;
|
|
497
|
+
const script = (j: typeof up): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(j.build ?? []), j.command, ...extraScript];
|
|
498
|
+
const artifacts = { when: "always", paths: [reportPath] };
|
|
499
|
+
return {
|
|
500
|
+
jobs: {
|
|
501
|
+
[named("env-up")]: {
|
|
502
|
+
stage: PR_ENV_STAGE,
|
|
503
|
+
image,
|
|
504
|
+
rules: [{ if: onMergeRequest }],
|
|
505
|
+
resource_group: group,
|
|
506
|
+
environment: { name: environment, on_stop: named("env-down") },
|
|
507
|
+
variables: up.env,
|
|
508
|
+
// #4295: the merge request's code runs in this job, so the job's shell drops the forge tokens first.
|
|
509
|
+
script: [GITLAB_DROP_FORGE_TOKENS, ...script(up)],
|
|
510
|
+
artifacts,
|
|
511
|
+
},
|
|
512
|
+
[named(PR_ENV_POST_JOB)]: {
|
|
513
|
+
stage: PR_ENV_POST_STAGE,
|
|
514
|
+
image,
|
|
515
|
+
needs: [named("env-up")],
|
|
516
|
+
rules: [{ if: onMergeRequest, when: "always" }],
|
|
517
|
+
variables: post.env,
|
|
518
|
+
script: ['git checkout --quiet --detach "$BASE_SHA"', ...enter, ...beforeScript, post.command],
|
|
519
|
+
artifacts,
|
|
520
|
+
},
|
|
521
|
+
[named("env-down")]: {
|
|
522
|
+
stage: PR_ENV_STAGE,
|
|
523
|
+
image,
|
|
524
|
+
rules: [{ if: onMergeRequest, when: "manual" }],
|
|
525
|
+
allow_failure: true,
|
|
526
|
+
resource_group: group,
|
|
527
|
+
environment: { name: environment, action: "stop" },
|
|
528
|
+
variables: down.env,
|
|
529
|
+
script: script(down),
|
|
530
|
+
artifacts,
|
|
531
|
+
},
|
|
532
|
+
[named("env-sweep")]: {
|
|
533
|
+
stage: PR_ENV_STAGE,
|
|
534
|
+
image,
|
|
535
|
+
rules: [{ if: GITLAB_SCHEDULE_RULE }],
|
|
536
|
+
resource_group: `chant-pr-env-sweep${member ? `-${member.name}` : ""}`,
|
|
537
|
+
variables: sweep.env,
|
|
538
|
+
script: script(sweep),
|
|
539
|
+
artifacts,
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
listed: [
|
|
543
|
+
{ jobName: named("env-up"), component: "merge request environment", stage: PR_ENV_STAGE, needs: [] },
|
|
544
|
+
{ jobName: named(PR_ENV_POST_JOB), component: "merge request environment note", stage: PR_ENV_POST_STAGE, needs: [named("env-up")] },
|
|
545
|
+
{ jobName: named("env-down"), component: "merge request environment teardown", stage: PR_ENV_STAGE, needs: [] },
|
|
546
|
+
{ jobName: named("env-sweep"), component: "merge request environment sweep", stage: PR_ENV_STAGE, needs: [] },
|
|
547
|
+
],
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The stage the post job runs in, after `env-up` (#4295). */
|
|
552
|
+
const PR_ENV_POST_STAGE = "env-post";
|
|
553
|
+
|
|
398
554
|
/**
|
|
399
555
|
* Scope a pipeline to one workspace member (#2542, #2524 D19), in place on
|
|
400
556
|
* `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,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge-request pipeline with an apply before merge (#4301, ws-106).
|
|
3
|
+
* GitLab builds a merge request's own pipeline from its own files, so the
|
|
4
|
+
* apply runs in a pipeline of the target branch a person starts with
|
|
5
|
+
* CHANT_APPLY_PR set, and checks out the merge request's head there.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
10
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
11
|
+
import { pullRequestCheckout } from "@intentius/chant/components/pr-pipeline";
|
|
12
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
13
|
+
|
|
14
|
+
const ESTATE: DriverComponent[] = [
|
|
15
|
+
{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
interface Job { rules: Array<{ if: string }>; variables: Record<string, string>; script: string[]; resource_group?: string }
|
|
19
|
+
|
|
20
|
+
describe("the GitLab pipeline with --apply-before-merge", () => {
|
|
21
|
+
const doc = parseYAML(generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyBeforeMerge: true } }).yaml) as { workflow: { rules: Array<{ if: string }> }; record: Job; apply: Job };
|
|
22
|
+
const RULE = '$CI_PIPELINE_SOURCE =~ /^(web|api|trigger)$/ && $CHANT_APPLY_PR =~ /^[0-9]+$/ && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH';
|
|
23
|
+
|
|
24
|
+
test("the apply runs in a target-branch pipeline started with CHANT_APPLY_PR, and a push still records", () => {
|
|
25
|
+
expect(doc.apply.rules).toEqual([{ if: RULE }]);
|
|
26
|
+
expect(doc.workflow.rules.map((r) => r.if)).toContain(RULE);
|
|
27
|
+
expect(doc.record.rules[0]!.if).toContain('$CI_PIPELINE_SOURCE == "push"');
|
|
28
|
+
expect(doc.apply.resource_group).toBe("chant-apply-prod");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("it checks out the merge request's head and applies before merge from the pipeline's commit", () => {
|
|
32
|
+
expect(doc.apply.variables).toMatchObject({ BASE_SHA: "$CI_COMMIT_SHA", PR_NUMBER: "$CHANT_APPLY_PR" });
|
|
33
|
+
const at = doc.apply.script.indexOf(pullRequestCheckout("gitlab"));
|
|
34
|
+
expect(at).toBeGreaterThanOrEqual(0);
|
|
35
|
+
expect(doc.apply.script.at(-1)).toBe('chant ci run apply --in-place --base "$BASE_SHA" --pr "$PR_NUMBER" --apply-before-merge --env prod --gate pr-apply --forge gitlab');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -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"]],
|