@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.
@@ -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;AAgBjG,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"}
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
- const stages = ["plan", ...applies.map((a) => a.stage)];
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
- variables: plan.run.env,
302
- script: script(plan),
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
- rules: [{ if: job.jobName === "record" ? onMerge : onGreen }],
313
- variables: job.run.env,
314
- script: script(job),
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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "ed4d3cf1c92e3d7673b0f31a5b173dd1921f1d3b50a3127bb6a2b52603b6881a",
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": "f3f5ffc6d06a9e401b7e57295ecbc72bfa2ec37d00b18f01df61d20ad5968191"
58
+ "composite": "bc6817f93592903583a564a620c6bbf849d732d2ad59c960c4159c6294aaf55c"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.124.39",
3
+ "version": "0.124.41",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.124.39",
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.39",
71
- "@intentius/chant-lexicon-github": "^0.124.39"
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
- const stages = ["plan", ...applies.map((a) => a.stage)];
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
- variables: plan.run.env,
354
- script: script(plan),
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
- rules: [{ if: job.jobName === "record" ? onMerge : onGreen }],
366
- variables: job.run.env,
367
- script: script(job),
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("chant components pr-"));
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"]],