@intentius/chant-lexicon-gitlab 0.124.34 → 0.124.36

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.
@@ -256,6 +256,10 @@ function gatedWaveGitlabPipeline(components, env, image, beforeScript, extraScri
256
256
  * k-1 pushed, and a retry of a wave that stopped at its gate pulls the one it
257
257
  * pushed itself.
258
258
  */
259
+ /** The dotenv report a gated apply wave hands the green commit it applies through (#4221). */
260
+ const WAVE_GREEN_DOTENV = "chant-green.env";
261
+ /** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
262
+ const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
259
263
  function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript, options) {
260
264
  const loop = options.prLoop;
261
265
  const member = options.member;
@@ -265,24 +269,29 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
265
269
  const planJob = member ? `${member.name}-plan` : "plan";
266
270
  const named = (job) => (member ? `${member.name}-${job}` : job);
267
271
  const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
268
- const applies = loop.waveGate
272
+ // #4168 step 9: a push to the branch records, and under apply on merge a ci/green/ tag pipeline applies.
273
+ const greens = loop.waveGate
269
274
  ? waveJobs.map((job) => ({ job, name: named(prApplyWaveJobName(job.wave)), stage: prApplyWaveJobName(job.wave) }))
270
275
  : loop.applyOnMerge
271
276
  ? [{ job: apply, name: named("apply"), stage: "apply" }]
272
- : [{ job: record, name: named("record"), stage: "record" }];
277
+ : [];
278
+ const applies = [{ job: record, name: named("record"), stage: "record" }, ...greens];
273
279
  const onMergeRequest = GITLAB_MERGE_REQUEST_SOURCE;
274
280
  if (loop.branch !== undefined)
275
281
  assertGitlabBranch("The merge-request pipeline", loop.branch);
276
282
  const onMerge = gitlabPushRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
283
+ const onGreen = GITLAB_GREEN_TAG_RULE;
277
284
  // A member's jobs start in its directory, after the image's install lines.
278
285
  const enter = rooted ? [] : [`cd ${memberShellDir(member)}`];
279
286
  // The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY variable is set.
280
- const script = (job) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
287
+ // #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
288
+ const handOff = (job) => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
289
+ const script = (job) => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
281
290
  const stages = ["plan", ...applies.map((a) => a.stage)];
282
291
  const doc = {
283
292
  // Included files merge their top-level keys, so a member's file carries
284
293
  // the rules every member shares and no name: two names would collide.
285
- workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }] },
294
+ workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, ...(greens.length > 0 ? [{ if: onGreen }] : [])] },
286
295
  stages,
287
296
  variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
288
297
  [planJob]: {
@@ -298,9 +307,9 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
298
307
  {
299
308
  stage,
300
309
  image,
301
- ...(i > 0 ? { needs: [applies[i - 1].name] } : {}),
310
+ ...(i > 1 ? { needs: [applies[i - 1].name] } : {}),
302
311
  resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
303
- rules: [{ if: onMerge }],
312
+ rules: [{ if: job.jobName === "record" ? onMerge : onGreen }],
304
313
  variables: job.run.env,
305
314
  script: script(job),
306
315
  // The attempt record (#3543), kept under the pushed commit even when
@@ -315,7 +324,7 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
315
324
  when: "always",
316
325
  },
317
326
  }),
318
- artifacts: { when: "always", paths: [reportPath] },
327
+ artifacts: { when: "always", paths: [reportPath], ...(job.wave ? { reports: { dotenv: WAVE_GREEN_DOTENV } } : {}) },
319
328
  },
320
329
  ])),
321
330
  };
@@ -323,9 +332,9 @@ function prLoopGitlabPipeline(components, env, image, beforeScript, extraScript,
323
332
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
324
333
  ...applies.map(({ job, name, stage }, i) => ({
325
334
  jobName: name,
326
- component: job.wave ? `merge request apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "merge request apply",
335
+ component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "green apply",
327
336
  stage,
328
- needs: i > 0 ? [applies[i - 1].name] : [],
337
+ needs: i > 1 ? [applies[i - 1].name] : [],
329
338
  })),
330
339
  ];
331
340
  const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, ...applies.map((a) => a.name)].map((key) => [key, doc[key]]));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "4f62190704fd273b241b9d720bc4c540ba40ec64fb082eaea1efa22c7716e890",
4
+ "manifest.json": "010ddb2b4be32c44dd4143e0edf74c071bd7172f669963e566fcf0f9bb98ee35",
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": "dbe731086396814e3bf4e1930b0c5d31ca7c0a4405044141643a616009002d10"
58
+ "composite": "6417e0110043d03afc8b64c4ec5e2b971f41d3e14b74df94ac1a5dfa5c192d34"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.124.34",
3
+ "version": "0.124.36",
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.34",
3
+ "version": "0.124.36",
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.34",
71
- "@intentius/chant-lexicon-github": "^0.124.34"
70
+ "@intentius/chant": "^0.124.36",
71
+ "@intentius/chant-lexicon-github": "^0.124.36"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@intentius/chant-lexicon-github": {
@@ -3,9 +3,11 @@ workflow:
3
3
  rules:
4
4
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
5
5
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
+ - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
6
7
 
7
8
  stages:
8
9
  - plan
10
+ - record
9
11
  - apply
10
12
 
11
13
  variables:
@@ -30,10 +32,10 @@ plan:
30
32
  paths:
31
33
  - .chant/pr
32
34
 
33
- apply:
34
- stage: apply
35
+ record:
36
+ stage: record
35
37
  image: node:22
36
- resource_group: chant-apply-prod
38
+ resource_group: chant-record-prod
37
39
  rules:
38
40
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
39
41
  variables:
@@ -43,7 +45,25 @@ apply:
43
45
  - if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then BASE_SHA="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}"; if [ -z "$BASE_SHA" ]; then git fetch origin $CI_DEFAULT_BRANCH && BASE_SHA="$(git merge-base FETCH_HEAD "$CI_COMMIT_SHA")"; if [ "$BASE_SHA" = "$CI_COMMIT_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$CI_COMMIT_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; fi; export BASE_SHA
44
46
  - chant build --env prod
45
47
  - chant lint
46
- - chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab
48
+ - chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab
49
+ artifacts:
50
+ when: always
51
+ paths:
52
+ - .chant/pr
53
+
54
+ apply:
55
+ stage: apply
56
+ image: node:22
57
+ resource_group: chant-apply-prod
58
+ rules:
59
+ - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
60
+ variables: {}
61
+ script:
62
+ - 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)
63
+ - git fetch --quiet --tags origin "+refs/heads/$CI_DEFAULT_BRANCH:refs/remotes/origin/$CI_DEFAULT_BRANCH" && GREEN_SHA="$(chant ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"
64
+ - chant build --env prod
65
+ - chant lint
66
+ - chant ci run apply --in-place --env prod --gate pr-apply --forge gitlab
47
67
  cache:
48
68
  key: chant-apply-prod-$CI_COMMIT_SHA
49
69
  paths:
@@ -3,9 +3,11 @@ workflow:
3
3
  rules:
4
4
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
5
5
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
+ - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
6
7
 
7
8
  stages:
8
9
  - plan
10
+ - record
9
11
  - apply-wave-1
10
12
  - apply-wave-2
11
13
  - apply-wave-3
@@ -30,10 +32,10 @@ plan:
30
32
  paths:
31
33
  - .chant/pr
32
34
 
33
- apply-wave-1:
34
- stage: apply-wave-1
35
+ record:
36
+ stage: record
35
37
  image: node:22
36
- resource_group: chant-apply-prod
38
+ resource_group: chant-record-prod
37
39
  rules:
38
40
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
39
41
  variables:
@@ -41,7 +43,24 @@ apply-wave-1:
41
43
  script:
42
44
  - 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)
43
45
  - if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then BASE_SHA="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}"; if [ -z "$BASE_SHA" ]; then git fetch origin $CI_DEFAULT_BRANCH && BASE_SHA="$(git merge-base FETCH_HEAD "$CI_COMMIT_SHA")"; if [ "$BASE_SHA" = "$CI_COMMIT_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$CI_COMMIT_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; fi; export BASE_SHA
44
- - chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab --wave-gate release --wave 1
46
+ - chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab
47
+ artifacts:
48
+ when: always
49
+ paths:
50
+ - .chant/pr
51
+
52
+ apply-wave-1:
53
+ stage: apply-wave-1
54
+ image: node:22
55
+ resource_group: chant-apply-prod
56
+ rules:
57
+ - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
58
+ variables: {}
59
+ script:
60
+ - 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)
61
+ - git fetch --quiet --tags origin "+refs/heads/$CI_DEFAULT_BRANCH:refs/remotes/origin/$CI_DEFAULT_BRANCH" && GREEN_SHA="$(chant ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"
62
+ - echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/chant-green.env"
63
+ - chant ci run apply --in-place --env prod --gate pr-apply --forge gitlab --wave-gate release --wave 1
45
64
  cache:
46
65
  key: chant-apply-prod-$CI_COMMIT_SHA
47
66
  paths:
@@ -51,6 +70,8 @@ apply-wave-1:
51
70
  when: always
52
71
  paths:
53
72
  - .chant/pr
73
+ reports:
74
+ dotenv: chant-green.env
54
75
 
55
76
  apply-wave-2:
56
77
  stage: apply-wave-2
@@ -59,13 +80,13 @@ apply-wave-2:
59
80
  - apply-wave-1
60
81
  resource_group: chant-apply-prod
61
82
  rules:
62
- - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
63
- variables:
64
- BASE_SHA: '$CI_COMMIT_BEFORE_SHA'
83
+ - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
84
+ variables: {}
65
85
  script:
66
86
  - 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)
67
- - if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then BASE_SHA="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}"; if [ -z "$BASE_SHA" ]; then git fetch origin $CI_DEFAULT_BRANCH && BASE_SHA="$(git merge-base FETCH_HEAD "$CI_COMMIT_SHA")"; if [ "$BASE_SHA" = "$CI_COMMIT_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$CI_COMMIT_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; fi; export BASE_SHA
68
- - chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab --wave-gate release --wave 2
87
+ - test -n "$GREEN_SHA" && git fetch --quiet --tags origin "+refs/heads/$CI_DEFAULT_BRANCH:refs/remotes/origin/$CI_DEFAULT_BRANCH" && git checkout --quiet --detach "$GREEN_SHA"
88
+ - echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/chant-green.env"
89
+ - chant ci run apply --in-place --env prod --gate pr-apply --forge gitlab --wave-gate release --wave 2
69
90
  cache:
70
91
  key: chant-apply-prod-$CI_COMMIT_SHA
71
92
  paths:
@@ -75,6 +96,8 @@ apply-wave-2:
75
96
  when: always
76
97
  paths:
77
98
  - .chant/pr
99
+ reports:
100
+ dotenv: chant-green.env
78
101
 
79
102
  apply-wave-3:
80
103
  stage: apply-wave-3
@@ -83,13 +106,13 @@ apply-wave-3:
83
106
  - apply-wave-2
84
107
  resource_group: chant-apply-prod
85
108
  rules:
86
- - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
87
- variables:
88
- BASE_SHA: '$CI_COMMIT_BEFORE_SHA'
109
+ - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
110
+ variables: {}
89
111
  script:
90
112
  - 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)
91
- - if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then BASE_SHA="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}"; if [ -z "$BASE_SHA" ]; then git fetch origin $CI_DEFAULT_BRANCH && BASE_SHA="$(git merge-base FETCH_HEAD "$CI_COMMIT_SHA")"; if [ "$BASE_SHA" = "$CI_COMMIT_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$CI_COMMIT_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; fi; export BASE_SHA
92
- - chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab --wave-gate release --wave 3
113
+ - test -n "$GREEN_SHA" && git fetch --quiet --tags origin "+refs/heads/$CI_DEFAULT_BRANCH:refs/remotes/origin/$CI_DEFAULT_BRANCH" && git checkout --quiet --detach "$GREEN_SHA"
114
+ - echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/chant-green.env"
115
+ - chant ci run apply --in-place --env prod --gate pr-apply --forge gitlab --wave-gate release --wave 3
93
116
  cache:
94
117
  key: chant-apply-prod-$CI_COMMIT_SHA
95
118
  paths:
@@ -99,3 +122,5 @@ apply-wave-3:
99
122
  when: always
100
123
  paths:
101
124
  - .chant/pr
125
+ reports:
126
+ dotenv: chant-green.env
@@ -3,9 +3,11 @@ workflow:
3
3
  rules:
4
4
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
5
5
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
+ - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
6
7
 
7
8
  stages:
8
9
  - plan
10
+ - record
9
11
  - apply
10
12
 
11
13
  variables:
@@ -28,10 +30,10 @@ plan:
28
30
  paths:
29
31
  - .chant/pr
30
32
 
31
- apply:
32
- stage: apply
33
+ record:
34
+ stage: record
33
35
  image: node:22
34
- resource_group: chant-apply-prod
36
+ resource_group: chant-record-prod
35
37
  rules:
36
38
  - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
37
39
  variables:
@@ -39,7 +41,23 @@ apply:
39
41
  script:
40
42
  - 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)
41
43
  - if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then BASE_SHA="${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}"; if [ -z "$BASE_SHA" ]; then git fetch origin $CI_DEFAULT_BRANCH && BASE_SHA="$(git merge-base FETCH_HEAD "$CI_COMMIT_SHA")"; if [ "$BASE_SHA" = "$CI_COMMIT_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$CI_COMMIT_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; fi; export BASE_SHA
42
- - chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab
44
+ - chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab
45
+ artifacts:
46
+ when: always
47
+ paths:
48
+ - .chant/pr
49
+
50
+ apply:
51
+ stage: apply
52
+ image: node:22
53
+ resource_group: chant-apply-prod
54
+ rules:
55
+ - if: '$CI_COMMIT_TAG =~ /^ci\/green\//'
56
+ variables: {}
57
+ script:
58
+ - 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)
59
+ - git fetch --quiet --tags origin "+refs/heads/$CI_DEFAULT_BRANCH:refs/remotes/origin/$CI_DEFAULT_BRANCH" && GREEN_SHA="$(chant ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"
60
+ - chant ci run apply --in-place --env prod --gate pr-apply --forge gitlab
43
61
  cache:
44
62
  key: chant-apply-prod-$CI_COMMIT_SHA
45
63
  paths:
@@ -300,6 +300,12 @@ function gatedWaveGitlabPipeline(
300
300
  * k-1 pushed, and a retry of a wave that stopped at its gate pulls the one it
301
301
  * pushed itself.
302
302
  */
303
+ /** The dotenv report a gated apply wave hands the green commit it applies through (#4221). */
304
+ const WAVE_GREEN_DOTENV = "chant-green.env";
305
+
306
+ /** The rule of a pipeline for a pushed `ci/green/` tag (#4168 step 9), which runs the apply under apply on merge. */
307
+ const GITLAB_GREEN_TAG_RULE = '$CI_COMMIT_TAG =~ /^ci\\/green\\//';
308
+
303
309
  function prLoopGitlabPipeline(
304
310
  components: DriverComponent[],
305
311
  env: string,
@@ -316,23 +322,28 @@ function prLoopGitlabPipeline(
316
322
  const planJob = member ? `${member.name}-plan` : "plan";
317
323
  const named = (job: string) => (member ? `${member.name}-${job}` : job);
318
324
  const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
319
- const applies = loop.waveGate
325
+ // #4168 step 9: a push to the branch records, and under apply on merge a ci/green/ tag pipeline applies.
326
+ const greens = loop.waveGate
320
327
  ? waveJobs.map((job) => ({ job, name: named(prApplyWaveJobName(job.wave!)), stage: prApplyWaveJobName(job.wave!) }))
321
328
  : loop.applyOnMerge
322
329
  ? [{ job: apply, name: named("apply"), stage: "apply" }]
323
- : [{ job: record, name: named("record"), stage: "record" }];
330
+ : [];
331
+ const applies = [{ job: record, name: named("record"), stage: "record" }, ...greens];
324
332
  const onMergeRequest = GITLAB_MERGE_REQUEST_SOURCE;
325
333
  if (loop.branch !== undefined) assertGitlabBranch("The merge-request pipeline", loop.branch);
326
334
  const onMerge = gitlabPushRule(loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH");
335
+ const onGreen = GITLAB_GREEN_TAG_RULE;
327
336
  // A member's jobs start in its directory, after the image's install lines.
328
337
  const enter = rooted ? [] : [`cd ${memberShellDir(member!)}`];
329
338
  // The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY variable is set.
330
- const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
339
+ // #4221: a wave job hands the commit it applies to the next wave as GREEN_SHA, through a dotenv report.
340
+ const handOff = (job: typeof plan): string[] => (job.wave ? [`echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/${WAVE_GREEN_DOTENV}"`] : []);
341
+ const script = (job: typeof plan): string[] => [...prLoopSetup(image), ...enter, ...beforeScript, ...(job.checkout ?? []), ...handOff(job), ...(job.setup ?? []), ...(job.build ?? []), job.run.command, ...extraScript];
331
342
  const stages = ["plan", ...applies.map((a) => a.stage)];
332
343
  const doc: Record<string, unknown> = {
333
344
  // Included files merge their top-level keys, so a member's file carries
334
345
  // the rules every member shares and no name: two names would collide.
335
- workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }] },
346
+ workflow: { ...(member ? {} : { name: `chant-pr-${env}` }), rules: [{ if: onMergeRequest }, { if: onMerge }, ...(greens.length > 0 ? [{ if: onGreen }] : [])] },
336
347
  stages,
337
348
  variables: { ...options.variables, CHANT_ENV: env, ...GITLAB_FULL_HISTORY },
338
349
  [planJob]: {
@@ -349,9 +360,9 @@ function prLoopGitlabPipeline(
349
360
  {
350
361
  stage,
351
362
  image,
352
- ...(i > 0 ? { needs: [applies[i - 1]!.name] } : {}),
363
+ ...(i > 1 ? { needs: [applies[i - 1]!.name] } : {}),
353
364
  resource_group: job.jobName === "record" ? prRecordGroup(env, member?.name) : prApplyGroup(env, member?.name),
354
- rules: [{ if: onMerge }],
365
+ rules: [{ if: job.jobName === "record" ? onMerge : onGreen }],
355
366
  variables: job.run.env,
356
367
  script: script(job),
357
368
  // The attempt record (#3543), kept under the pushed commit even when
@@ -366,7 +377,7 @@ function prLoopGitlabPipeline(
366
377
  when: "always",
367
378
  },
368
379
  }),
369
- artifacts: { when: "always", paths: [reportPath] },
380
+ artifacts: { when: "always", paths: [reportPath], ...(job.wave ? { reports: { dotenv: WAVE_GREEN_DOTENV } } : {}) },
370
381
  },
371
382
  ]),
372
383
  ),
@@ -375,9 +386,9 @@ function prLoopGitlabPipeline(
375
386
  { jobName: planJob, component: "merge request plan", stage: "plan", needs: [] },
376
387
  ...applies.map(({ job, name, stage }, i) => ({
377
388
  jobName: name,
378
- component: job.wave ? `merge request apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "merge request apply",
389
+ component: job.wave ? `green apply, wave ${job.wave}` : job.jobName === "record" ? "merge record" : "green apply",
379
390
  stage,
380
- needs: i > 0 ? [applies[i - 1]!.name] : [],
391
+ needs: i > 1 ? [applies[i - 1]!.name] : [],
381
392
  })),
382
393
  ];
383
394
  const yaml = emitGitlabFile(["workflow", "stages", "variables", planJob, ...applies.map((a) => a.name)].map((key) => [key, doc[key]] as const));
@@ -12,7 +12,7 @@ import { describe, expect, test } from "vitest";
12
12
  import { parseYAML } from "@intentius/chant/yaml";
13
13
  import type { DriverComponent } from "@intentius/chant/components/driver";
14
14
  import { generateGitlabPipeline } from "./generate-pipeline";
15
- import { PR_LOOP_SETUP, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
15
+ import { greenCheckout, PR_LOOP_SETUP, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
16
16
 
17
17
  const ESTATE: DriverComponent[] = [
18
18
  { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
@@ -30,11 +30,12 @@ interface Job {
30
30
  image?: string;
31
31
  artifacts: Record<string, unknown>;
32
32
  cache?: Record<string, unknown>;
33
+ needs?: string[];
33
34
  }
34
35
 
35
36
  describe("the GitLab merge-request pipeline", () => {
36
37
  const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } });
37
- const doc = parseYAML(result.yaml) as { workflow: { rules: unknown[] }; variables: Record<string, string>; plan: Job; apply: Job };
38
+ const doc = parseYAML(result.yaml) as { workflow: { rules: unknown[] }; variables: Record<string, string>; plan: Job; record: Job; apply: Job };
38
39
 
39
40
  test("matches its golden", () => {
40
41
  if (process.env.UPDATE_GOLDEN) writeFileSync(GOLDEN, result.yaml);
@@ -50,15 +51,21 @@ describe("the GitLab merge-request pipeline", () => {
50
51
  expect(ok, JSON.stringify(validate.errors, null, 2)).toBe(true);
51
52
  });
52
53
 
53
- test("plans in a merge request pipeline from its diff base, applies on a push to the default branch, one at a time", () => {
54
+ 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", () => {
54
55
  expect(doc.plan.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]);
55
56
  expect(doc.plan.variables).toEqual({ BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID" });
56
57
  expect(doc.plan.script).toEqual([
57
58
  ...PR_LOOP_SETUP,'chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge gitlab']);
58
- expect(doc.apply.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
59
- expect(doc.apply.variables).toEqual({ BASE_SHA: "$CI_COMMIT_BEFORE_SHA" });
59
+ expect(doc.record.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
60
+ expect(doc.record.variables).toEqual({ BASE_SHA: "$CI_COMMIT_BEFORE_SHA" });
61
+ // #4168 step 9: a pushed ci/green/ tag runs the apply, which checks out the newest green commit and reads no push base.
62
+ expect(doc.apply.rules).toEqual([{ if: "$CI_COMMIT_TAG =~ /^ci\\/green\\//" }]);
63
+ expect(doc.workflow.rules).toContainEqual({ if: "$CI_COMMIT_TAG =~ /^ci\\/green\\//" });
64
+ expect(doc.apply.variables).toEqual({});
65
+ expect(doc.apply.script.at(-2)).toBe(greenCheckout("$CI_DEFAULT_BRANCH"));
66
+ expect(doc.apply.needs).toBeUndefined();
60
67
  // #4156: ci run builds pr-apply (with no --require-review since #4168), and GitLab hands CHANT_RUNNER_KEY to the job as a CI/CD variable when it has one.
61
- expect(doc.apply.script.at(-1)).toBe('chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab');
68
+ expect(doc.apply.script.at(-1)).toBe("chant ci run apply --in-place --env prod --gate pr-apply --forge gitlab");
62
69
  expect(result.yaml).not.toContain("CHANT_RUNNER_KEY");
63
70
  expect(doc.apply.resource_group).toBe("chant-apply-prod");
64
71
  expect(doc.variables.GIT_DEPTH).toBe("0");
@@ -84,10 +91,10 @@ describe("the GitLab merge-request pipeline", () => {
84
91
  });
85
92
 
86
93
  test("an all-zero CI_COMMIT_BEFORE_SHA falls back to the merge request diff base, then the target branch", () => {
87
- const line = doc.apply.script.find((l) => l.includes("CI_MERGE_REQUEST_DIFF_BASE_SHA"))!;
94
+ const line = doc.record.script.find((l) => l.includes("CI_MERGE_REQUEST_DIFF_BASE_SHA"))!;
88
95
  expect(line).toContain("0000000000000000000000000000000000000000");
89
96
  expect(line).toContain("git fetch origin $CI_DEFAULT_BRANCH");
90
- expect(doc.apply.script.indexOf(line)).toBeLessThan(doc.apply.script.length - 1);
97
+ expect(doc.record.script.indexOf(line)).toBeLessThan(doc.record.script.length - 1);
91
98
  expect(doc.plan.script.some((l) => l.includes("CI_MERGE_REQUEST_DIFF_BASE_SHA"))).toBe(false);
92
99
  });
93
100
 
@@ -114,11 +121,11 @@ describe("the GitLab merge-request pipeline", () => {
114
121
  const member = { name: "network", dir: "infra/network", file: ".gitlab/ci/chant-pr.network.prod.gitlab-ci.yml" };
115
122
  const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, member });
116
123
  const mdoc = parseYAML(scoped.yaml) as Record<string, unknown> & { workflow: { name?: string; rules: unknown[] } };
117
- expect(scoped.jobs.map((j) => j.jobName)).toEqual(["network-plan", "network-apply"]);
124
+ expect(scoped.jobs.map((j) => j.jobName)).toEqual(["network-plan", "network-record", "network-apply"]);
118
125
  expect(mdoc.plan).toBeUndefined();
119
126
  // Included files merge top-level keys: no name, so members do not collide on it.
120
127
  expect(mdoc.workflow.name).toBeUndefined();
121
- expect(mdoc.workflow.rules).toHaveLength(2);
128
+ expect(mdoc.workflow.rules).toHaveLength(3);
122
129
  const plan = mdoc["network-plan"] as Job;
123
130
  const apply = mdoc["network-apply"] as Job;
124
131
  expect(plan.script).toContain("cd infra/network");
@@ -42,6 +42,17 @@ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () =>
42
42
  expect(result.yaml).toBe(readFileSync(GOLDEN, "utf-8"));
43
43
  });
44
44
 
45
+ test("wave 1 takes the newest green commit, and each later wave applies the one wave 1 took (#4221)", () => {
46
+ for (const k of [1, 2, 3]) {
47
+ const job = doc[`apply-wave-${k}`] as { script: string[]; artifacts: { reports?: { dotenv?: string } } };
48
+ expect(job.artifacts.reports).toEqual({ dotenv: "chant-green.env" });
49
+ expect(job.script).toContain('echo "GREEN_SHA=$GREEN_SHA" > "$CI_PROJECT_DIR/chant-green.env"');
50
+ const lastGreen = job.script.some((l) => l.includes("chant ci last-green"));
51
+ expect(lastGreen).toBe(k === 1);
52
+ if (k > 1) expect(job.script.some((l) => l.startsWith('test -n "$GREEN_SHA"'))).toBe(true);
53
+ }
54
+ });
55
+
45
56
  test("validates against the vendored GitLab CI schema", () => {
46
57
  const req = createRequire(import.meta.url);
47
58
  const mod = req("ajv") as { default?: unknown };
@@ -51,9 +62,10 @@ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () =>
51
62
  });
52
63
 
53
64
  test("one apply job and stage per wave, each needing the one before, on the merge, one at a time", () => {
54
- expect(doc.stages).toEqual(["plan", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
65
+ expect(doc.stages).toEqual(["plan", "record", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
55
66
  expect(result.jobs.map((j) => [j.jobName, j.needs])).toEqual([
56
67
  ["plan", []],
68
+ ["record", []],
57
69
  ["apply-wave-1", []],
58
70
  ["apply-wave-2", ["apply-wave-1"]],
59
71
  ["apply-wave-3", ["apply-wave-2"]],
@@ -64,7 +76,7 @@ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () =>
64
76
  expect(job.needs).toEqual(k > 1 ? [`apply-wave-${k - 1}`] : undefined);
65
77
  expect(job.resource_group).toBe("chant-apply-prod");
66
78
  // #4156: through chant ci run, which runs pr-apply --wave-gate release (no --require-review since #4168) --resume ... --wave <k>.
67
- expect(job.script.at(-1)).toBe(`chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge gitlab --wave-gate release --wave ${k}`);
79
+ expect(job.script.at(-1)).toBe(`chant ci run apply --in-place --env prod --gate pr-apply --forge gitlab --wave-gate release --wave ${k}`);
68
80
  // Every wave shares the commit's record, so wave k pulls what wave k-1 pushed.
69
81
  expect(job.cache).toEqual({ key: "chant-apply-prod-$CI_COMMIT_SHA", paths: [".chant/pr-resume/pr-apply.json"], when: "always" });
70
82
  }
@@ -77,12 +89,13 @@ describe("the GitLab merge-request pipeline with a gate per wave (#4003)", () =>
77
89
  const scoped = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true }, gatedWaves: { gate: "release" }, member });
78
90
  expect(scoped.jobs.map((j) => [j.jobName, j.needs])).toEqual([
79
91
  ["network-plan", []],
92
+ ["network-record", []],
80
93
  ["network-apply-wave-1", []],
81
94
  ["network-apply-wave-2", ["network-apply-wave-1"]],
82
95
  ["network-apply-wave-3", ["network-apply-wave-2"]],
83
96
  ]);
84
97
  // ci run, started in the member's directory, passes --member network to pr-apply.
85
98
  expect(scoped.yaml).toContain("cd infra/network");
86
- expect(scoped.yaml).toContain("chant ci run apply --in-place --base \"$BASE_SHA\" --env prod --gate pr-apply --forge gitlab --wave-gate release --wave 3");
99
+ expect(scoped.yaml).toContain("chant ci run apply --in-place --env prod --gate pr-apply --forge gitlab --wave-gate release --wave 3");
87
100
  });
88
101
  });