@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.
@@ -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;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
- const onGreen = GITLAB_GREEN_TAG_RULE;
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
- const stages = ["plan", ...applies.map((a) => a.stage)];
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
- variables: plan.run.env,
302
- script: script(plan),
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
- rules: [{ if: job.jobName === "record" ? onMerge : onGreen }],
313
- variables: job.run.env,
314
- script: script(job),
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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "524274c7aeb4edfd8d865037c7706c1fefaf33027d00fd2ce7afa4c809633a5d",
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": "56212233f186cc26ade3222cb96cfe5719dea084a89f4de37493fa9f292cf965"
58
+ "composite": "fcd516d1ec373a23058e366255e162fc527da7d2e62ce01a399255a37d893c91"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.124.40",
3
+ "version": "0.124.42",
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.40",
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.40",
71
- "@intentius/chant-lexicon-github": "^0.124.40"
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
- const onGreen = GITLAB_GREEN_TAG_RULE;
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
- const stages = ["plan", ...applies.map((a) => a.stage)];
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
- variables: plan.run.env,
354
- script: script(plan),
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
- rules: [{ if: job.jobName === "record" ? onMerge : onGreen }],
366
- variables: job.run.env,
367
- script: script(job),
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("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"]],