@intentius/chant-lexicon-github 0.124.34 → 0.124.35

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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAiBjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAuMD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAmBjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAGlC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AA+ND;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
@@ -59,9 +59,12 @@ import { resolveComponentGraph } from "@intentius/chant/components/driver";
59
59
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
60
60
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
61
61
  import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
62
- import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopSetup, prLoopWaveJobs, prLoopWithWaves, } from "@intentius/chant/components/pr-pipeline";
62
+ import { GREEN_TAG_PATTERN, GREEN_TICK_WORKFLOW, PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prApplyGroup, prRecordGroup, prApplyRecordKey, prApplyWaveJobName, prLoopJobs, prLoopSetup, prLoopWaveJobs, prLoopWithWaves, } from "@intentius/chant/components/pr-pipeline";
63
63
  import { memberRepoPath } from "@intentius/chant/lexicon";
64
64
  import { checkoutStep, concurrency, downloadArtifactStep, emitWorkflowYAML, keepRecordStep, runSteps, UPLOAD_ARTIFACT } from "./ci-jobs.js";
65
+ /** The output a gated apply wave hands the green commit it applies through, and the step that sets it (#4221). */
66
+ const WAVE_GREEN_OUTPUT = "green-sha";
67
+ const WAVE_STEP_ID = "apply";
65
68
  /** The workflow artifact the gated-wave jobs hand their attempt record through, one per wave. */
66
69
  function waveRecordArtifact(wave) {
67
70
  return `fan-out-record-wave-${wave}`;
@@ -164,7 +167,15 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
164
167
  ...(job.jobName === "apply" ? [restoreRecord(job.wave)] : []),
165
168
  // The job's setup lines (#4004) run in the command's shell, so a value they settle reaches it.
166
169
  // The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY secret is set.
167
- { name, env: job.run.env, run: [...(job.setup ?? []), job.run.command].join("\n") },
170
+ // #4221: a wave job hands the commit it applies to the next wave as the job output `green-sha`.
171
+ job.wave
172
+ ? {
173
+ name,
174
+ id: WAVE_STEP_ID,
175
+ env: { ...job.run.env, ...(job.wave > 1 ? { GREEN_SHA: `\${{ needs.${prApplyWaveJobName(job.wave - 1)}.outputs.${WAVE_GREEN_OUTPUT} }}` } : {}) },
176
+ run: [...(job.checkout ?? []), `echo "${WAVE_GREEN_OUTPUT}=$GREEN_SHA" >> "$GITHUB_OUTPUT"`, ...(job.setup ?? []), job.run.command].join("\n"),
177
+ }
178
+ : { name, env: job.run.env, run: [...(job.checkout ?? []), ...(job.setup ?? []), job.run.command].join("\n") },
168
179
  ...runSteps(extraScript),
169
180
  ...(job.jobName === "apply" ? [saveRecord(job.wave)] : []),
170
181
  {
@@ -174,24 +185,31 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
174
185
  with: { name: reportName(job), path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true },
175
186
  },
176
187
  ];
188
+ // #4168 step 9: under apply on merge the apply runs on a green signal, and a push to the branch records.
189
+ const forgejo = loop.forge === "forgejo";
190
+ const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : "github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'";
191
+ const onPush = loop.applyOnMerge && forgejo ? "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')" : "github.event_name == 'push'";
177
192
  const applyJob = (job, needs, name) => ({
178
- if: "github.event_name == 'push'",
193
+ if: job.jobName === "record" ? onPush : onGreen,
179
194
  "runs-on": GITHUB_RUNNER,
180
195
  ...(needs.length > 0 ? { needs } : {}),
181
196
  container: image,
182
197
  concurrency: concurrency(prApplyGroup(env, member?.name)),
183
198
  permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
199
+ ...(job.wave ? { outputs: { [WAVE_GREEN_OUTPUT]: `\${{ steps.${WAVE_STEP_ID}.outputs.${WAVE_GREEN_OUTPUT} }}` } } : {}),
184
200
  steps: steps(job, name),
185
201
  });
186
202
  const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
187
- const applyJobs = loop.waveGate
203
+ const recordJob = ["record", { ...applyJob(record, [], "Record the plan on the merged commit; nothing applies"), concurrency: concurrency(prRecordGroup(env, member?.name)) }];
204
+ const greenJobs = loop.waveGate
188
205
  ? waveJobs.map((job) => [
189
206
  prApplyWaveJobName(job.wave),
190
- applyJob(job, job.wave > 1 ? [prApplyWaveJobName(job.wave - 1)] : [], `Apply wave ${job.wave}, under the approval its plan has`),
207
+ applyJob(job, job.wave > 1 ? [prApplyWaveJobName(job.wave - 1)] : [], job.wave === 1 ? "Apply wave 1 of the newest green commit, under the approval its plan has" : `Apply wave ${job.wave} of the green commit wave 1 took, under the approval its plan has`),
191
208
  ])
192
209
  : loop.applyOnMerge
193
- ? [["apply", applyJob(apply, [], "Apply the plan a reviewer approved")]]
194
- : [["record", { ...applyJob(record, [], "Record the plan on the merged commit; nothing applies"), concurrency: concurrency(prRecordGroup(env, member?.name)) }]];
210
+ ? [["apply", applyJob(apply, [], "Apply the newest green commit under the merged changes' approvals")]]
211
+ : [];
212
+ const applyJobs = [recordJob, ...greenJobs];
195
213
  const jobsDoc = {
196
214
  plan: {
197
215
  if: "github.event_name == 'pull_request'",
@@ -206,7 +224,11 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
206
224
  return {
207
225
  name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
208
226
  environment: env,
209
- on: { pull_request: { branches: [branch] }, push: { branches: [branch] } },
227
+ on: !loop.applyOnMerge
228
+ ? { pull_request: { branches: [branch] }, push: { branches: [branch] } }
229
+ : forgejo
230
+ ? { pull_request: { branches: [branch] }, push: { branches: [branch], tags: [GREEN_TAG_PATTERN] } }
231
+ : { pull_request: { branches: [branch] }, push: { branches: [branch] }, workflow_run: { workflows: [GREEN_TICK_WORKFLOW], types: ["completed"] }, workflow_dispatch: {} },
210
232
  ...(rooted ? {} : { defaults: { run: { "working-directory": member.dir } } }),
211
233
  env: { ...options.variables, CHANT_ENV: env },
212
234
  jobsDoc,
@@ -215,9 +237,10 @@ function prLoopGithubDoc(components, env, image, beforeScript, extraScript, opti
215
237
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
216
238
  ...applyNames.map((name, i) => ({
217
239
  jobName: name,
218
- component: loop.waveGate ? `pull request apply, wave ${i + 1}` : loop.applyOnMerge ? "pull request apply" : "merge record",
240
+ component: name === "record" ? "merge record" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
219
241
  stage: name,
220
- needs: i > 0 ? [applyNames[i - 1]] : [],
242
+ // The record runs on a push and the apply on a green commit, so only the waves need each other.
243
+ needs: i > 1 ? [applyNames[i - 1]] : [],
221
244
  })),
222
245
  ],
223
246
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "5f90d39ad2f505ce370fa896da370421567132336d3404820974cd74a1dfe68f",
4
+ "manifest.json": "3090b1e50a456840a260fd6554142bfa22caa3357a0bbdcbbb52d8cf8852a3bd",
5
5
  "meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
6
6
  "types/index.d.ts": "0ead95536e947ddfb98a5acf0ddb88547913b5a53a342df67711c59266f84b3e",
7
7
  "rules/deprecated-action-version.ts": "a9ba7cc924ff1c7055110416d8989273690415bfb5e8dcc32dc9431b9351f5ca",
@@ -78,5 +78,5 @@
78
78
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
79
79
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
80
80
  },
81
- "composite": "58ec911a741a048aafa2e77b71308b47d28436478805c484a48867361d3220d0"
81
+ "composite": "76bee507e1094451f0435711785b080f9d494a9aacb86319f3f2f40c84d24433"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.124.34",
3
+ "version": "0.124.35",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.124.34",
3
+ "version": "0.124.35",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -61,6 +61,6 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.124.34"
64
+ "@intentius/chant": "^0.124.35"
65
65
  }
66
66
  }
@@ -7,6 +7,12 @@ on:
7
7
  push:
8
8
  branches:
9
9
  - main
10
+ workflow_run:
11
+ workflows:
12
+ - chant-ci-green
13
+ types:
14
+ - completed
15
+ workflow_dispatch: {}
10
16
 
11
17
  env:
12
18
  CHANT_ENV: prod
@@ -44,10 +50,46 @@ jobs:
44
50
  path: .chant/pr
45
51
  if-no-files-found: ignore
46
52
  include-hidden-files: true
47
- apply:
53
+ record:
48
54
  if: github.event_name == 'push'
49
55
  runs-on: ubuntu-latest
50
56
  container: node:22
57
+ concurrency:
58
+ group: chant-record-prod
59
+ cancel-in-progress: false
60
+ permissions:
61
+ contents: write
62
+ pull-requests: write
63
+ statuses: write
64
+ steps:
65
+ - uses: actions/checkout@v7
66
+ with:
67
+ fetch-depth: 0
68
+ - run: 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)
69
+ - name: Build the templates
70
+ run: chant build --env prod
71
+ - name: Lint the project
72
+ run: chant lint
73
+ - name: Record the plan on the merged commit; nothing applies
74
+ env:
75
+ BASE_SHA: '${{ github.event.before }}'
76
+ GITHUB_TOKEN: '${{ github.token }}'
77
+ CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
78
+ run: |
79
+ if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then git fetch origin "main" && BASE_SHA="$(git merge-base FETCH_HEAD "$GITHUB_SHA")"; if [ "$BASE_SHA" = "$GITHUB_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$GITHUB_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; export BASE_SHA
80
+ chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github
81
+ - name: Keep the record report
82
+ if: always()
83
+ uses: actions/upload-artifact@v4
84
+ with:
85
+ name: chant-pr-record
86
+ path: .chant/pr
87
+ if-no-files-found: ignore
88
+ include-hidden-files: true
89
+ apply:
90
+ if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
91
+ runs-on: ubuntu-latest
92
+ container: node:22
51
93
  concurrency:
52
94
  group: chant-apply-prod
53
95
  cancel-in-progress: false
@@ -70,14 +112,13 @@ jobs:
70
112
  path: .chant/pr-resume/pr-apply.json
71
113
  key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
72
114
  restore-keys: chant-apply-prod-${{ github.sha }}-
73
- - name: Apply the plan a reviewer approved
115
+ - name: Apply the newest green commit under the merged changes' approvals
74
116
  env:
75
- BASE_SHA: '${{ github.event.before }}'
76
117
  GITHUB_TOKEN: '${{ github.token }}'
77
118
  CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
78
119
  run: |
79
- if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then git fetch origin "main" && BASE_SHA="$(git merge-base FETCH_HEAD "$GITHUB_SHA")"; if [ "$BASE_SHA" = "$GITHUB_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$GITHUB_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; export BASE_SHA
80
- chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github
120
+ git fetch --quiet --tags origin "+refs/heads/main:refs/remotes/origin/main" && GREEN_SHA="$(chant ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"
121
+ chant ci run apply --in-place --env prod --gate pr-apply --forge github
81
122
  - name: Keep the record for a re-run
82
123
  if: always()
83
124
  uses: actions/cache/save@v4
@@ -7,6 +7,12 @@ on:
7
7
  push:
8
8
  branches:
9
9
  - main
10
+ workflow_run:
11
+ workflows:
12
+ - chant-ci-green
13
+ types:
14
+ - completed
15
+ workflow_dispatch: {}
10
16
 
11
17
  env:
12
18
  CHANT_ENV: prod
@@ -40,10 +46,42 @@ jobs:
40
46
  path: .chant/pr
41
47
  if-no-files-found: ignore
42
48
  include-hidden-files: true
43
- apply-wave-1:
49
+ record:
44
50
  if: github.event_name == 'push'
45
51
  runs-on: ubuntu-latest
46
52
  container: node:22
53
+ concurrency:
54
+ group: chant-record-prod
55
+ cancel-in-progress: false
56
+ permissions:
57
+ contents: write
58
+ pull-requests: write
59
+ statuses: write
60
+ steps:
61
+ - uses: actions/checkout@v7
62
+ with:
63
+ fetch-depth: 0
64
+ - run: 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)
65
+ - name: Record the plan on the merged commit; nothing applies
66
+ env:
67
+ BASE_SHA: '${{ github.event.before }}'
68
+ GITHUB_TOKEN: '${{ github.token }}'
69
+ CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
70
+ run: |
71
+ if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then git fetch origin "main" && BASE_SHA="$(git merge-base FETCH_HEAD "$GITHUB_SHA")"; if [ "$BASE_SHA" = "$GITHUB_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$GITHUB_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; export BASE_SHA
72
+ chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github
73
+ - name: Keep the record report
74
+ if: always()
75
+ uses: actions/upload-artifact@v4
76
+ with:
77
+ name: chant-pr-record
78
+ path: .chant/pr
79
+ if-no-files-found: ignore
80
+ include-hidden-files: true
81
+ apply-wave-1:
82
+ if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
83
+ runs-on: ubuntu-latest
84
+ container: node:22
47
85
  concurrency:
48
86
  group: chant-apply-prod
49
87
  cancel-in-progress: false
@@ -51,6 +89,8 @@ jobs:
51
89
  contents: write
52
90
  pull-requests: write
53
91
  statuses: write
92
+ outputs:
93
+ green-sha: '${{ steps.apply.outputs.green-sha }}'
54
94
  steps:
55
95
  - uses: actions/checkout@v7
56
96
  with:
@@ -62,14 +102,15 @@ jobs:
62
102
  path: .chant/pr-resume/pr-apply.json
63
103
  key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}-wave-1
64
104
  restore-keys: chant-apply-prod-${{ github.sha }}-
65
- - name: Apply wave 1, under the approval its plan has
105
+ - name: Apply wave 1 of the newest green commit, under the approval its plan has
106
+ id: apply
66
107
  env:
67
- BASE_SHA: '${{ github.event.before }}'
68
108
  GITHUB_TOKEN: '${{ github.token }}'
69
109
  CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
70
110
  run: |
71
- if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then git fetch origin "main" && BASE_SHA="$(git merge-base FETCH_HEAD "$GITHUB_SHA")"; if [ "$BASE_SHA" = "$GITHUB_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$GITHUB_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; export BASE_SHA
72
- chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github --wave-gate release --wave 1
111
+ git fetch --quiet --tags origin "+refs/heads/main:refs/remotes/origin/main" && GREEN_SHA="$(chant ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"
112
+ echo "green-sha=$GREEN_SHA" >> "$GITHUB_OUTPUT"
113
+ chant ci run apply --in-place --env prod --gate pr-apply --forge github --wave-gate release --wave 1
73
114
  - name: Keep the record for the next wave and a re-run
74
115
  if: always()
75
116
  uses: actions/cache/save@v4
@@ -85,7 +126,7 @@ jobs:
85
126
  if-no-files-found: ignore
86
127
  include-hidden-files: true
87
128
  apply-wave-2:
88
- if: github.event_name == 'push'
129
+ if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
89
130
  runs-on: ubuntu-latest
90
131
  needs:
91
132
  - apply-wave-1
@@ -97,6 +138,8 @@ jobs:
97
138
  contents: write
98
139
  pull-requests: write
99
140
  statuses: write
141
+ outputs:
142
+ green-sha: '${{ steps.apply.outputs.green-sha }}'
100
143
  steps:
101
144
  - uses: actions/checkout@v7
102
145
  with:
@@ -108,14 +151,16 @@ jobs:
108
151
  path: .chant/pr-resume/pr-apply.json
109
152
  key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}-wave-2
110
153
  restore-keys: chant-apply-prod-${{ github.sha }}-
111
- - name: Apply wave 2, under the approval its plan has
154
+ - name: Apply wave 2 of the green commit wave 1 took, under the approval its plan has
155
+ id: apply
112
156
  env:
113
- BASE_SHA: '${{ github.event.before }}'
114
157
  GITHUB_TOKEN: '${{ github.token }}'
115
158
  CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
159
+ GREEN_SHA: '${{ needs.apply-wave-1.outputs.green-sha }}'
116
160
  run: |
117
- if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then git fetch origin "main" && BASE_SHA="$(git merge-base FETCH_HEAD "$GITHUB_SHA")"; if [ "$BASE_SHA" = "$GITHUB_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$GITHUB_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; export BASE_SHA
118
- chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github --wave-gate release --wave 2
161
+ test -n "$GREEN_SHA" && git fetch --quiet --tags origin "+refs/heads/main:refs/remotes/origin/main" && git checkout --quiet --detach "$GREEN_SHA"
162
+ echo "green-sha=$GREEN_SHA" >> "$GITHUB_OUTPUT"
163
+ chant ci run apply --in-place --env prod --gate pr-apply --forge github --wave-gate release --wave 2
119
164
  - name: Keep the record for the next wave and a re-run
120
165
  if: always()
121
166
  uses: actions/cache/save@v4
@@ -131,7 +176,7 @@ jobs:
131
176
  if-no-files-found: ignore
132
177
  include-hidden-files: true
133
178
  apply-wave-3:
134
- if: github.event_name == 'push'
179
+ if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
135
180
  runs-on: ubuntu-latest
136
181
  needs:
137
182
  - apply-wave-2
@@ -143,6 +188,8 @@ jobs:
143
188
  contents: write
144
189
  pull-requests: write
145
190
  statuses: write
191
+ outputs:
192
+ green-sha: '${{ steps.apply.outputs.green-sha }}'
146
193
  steps:
147
194
  - uses: actions/checkout@v7
148
195
  with:
@@ -154,14 +201,16 @@ jobs:
154
201
  path: .chant/pr-resume/pr-apply.json
155
202
  key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}-wave-3
156
203
  restore-keys: chant-apply-prod-${{ github.sha }}-
157
- - name: Apply wave 3, under the approval its plan has
204
+ - name: Apply wave 3 of the green commit wave 1 took, under the approval its plan has
205
+ id: apply
158
206
  env:
159
- BASE_SHA: '${{ github.event.before }}'
160
207
  GITHUB_TOKEN: '${{ github.token }}'
161
208
  CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
209
+ GREEN_SHA: '${{ needs.apply-wave-2.outputs.green-sha }}'
162
210
  run: |
163
- if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then git fetch origin "main" && BASE_SHA="$(git merge-base FETCH_HEAD "$GITHUB_SHA")"; if [ "$BASE_SHA" = "$GITHUB_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$GITHUB_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; export BASE_SHA
164
- chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github --wave-gate release --wave 3
211
+ test -n "$GREEN_SHA" && git fetch --quiet --tags origin "+refs/heads/main:refs/remotes/origin/main" && git checkout --quiet --detach "$GREEN_SHA"
212
+ echo "green-sha=$GREEN_SHA" >> "$GITHUB_OUTPUT"
213
+ chant ci run apply --in-place --env prod --gate pr-apply --forge github --wave-gate release --wave 3
165
214
  - name: Keep the record for the next wave and a re-run
166
215
  if: always()
167
216
  uses: actions/cache/save@v4
@@ -7,6 +7,12 @@ on:
7
7
  push:
8
8
  branches:
9
9
  - main
10
+ workflow_run:
11
+ workflows:
12
+ - chant-ci-green
13
+ types:
14
+ - completed
15
+ workflow_dispatch: {}
10
16
 
11
17
  env:
12
18
  CHANT_ENV: prod
@@ -40,10 +46,42 @@ jobs:
40
46
  path: .chant/pr
41
47
  if-no-files-found: ignore
42
48
  include-hidden-files: true
43
- apply:
49
+ record:
44
50
  if: github.event_name == 'push'
45
51
  runs-on: ubuntu-latest
46
52
  container: node:22
53
+ concurrency:
54
+ group: chant-record-prod
55
+ cancel-in-progress: false
56
+ permissions:
57
+ contents: write
58
+ pull-requests: write
59
+ statuses: write
60
+ steps:
61
+ - uses: actions/checkout@v7
62
+ with:
63
+ fetch-depth: 0
64
+ - run: 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)
65
+ - name: Record the plan on the merged commit; nothing applies
66
+ env:
67
+ BASE_SHA: '${{ github.event.before }}'
68
+ GITHUB_TOKEN: '${{ github.token }}'
69
+ CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
70
+ run: |
71
+ if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then git fetch origin "main" && BASE_SHA="$(git merge-base FETCH_HEAD "$GITHUB_SHA")"; if [ "$BASE_SHA" = "$GITHUB_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$GITHUB_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; export BASE_SHA
72
+ chant ci run record --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github
73
+ - name: Keep the record report
74
+ if: always()
75
+ uses: actions/upload-artifact@v4
76
+ with:
77
+ name: chant-pr-record
78
+ path: .chant/pr
79
+ if-no-files-found: ignore
80
+ include-hidden-files: true
81
+ apply:
82
+ if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
83
+ runs-on: ubuntu-latest
84
+ container: node:22
47
85
  concurrency:
48
86
  group: chant-apply-prod
49
87
  cancel-in-progress: false
@@ -62,14 +100,13 @@ jobs:
62
100
  path: .chant/pr-resume/pr-apply.json
63
101
  key: chant-apply-prod-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
64
102
  restore-keys: chant-apply-prod-${{ github.sha }}-
65
- - name: Apply the plan a reviewer approved
103
+ - name: Apply the newest green commit under the merged changes' approvals
66
104
  env:
67
- BASE_SHA: '${{ github.event.before }}'
68
105
  GITHUB_TOKEN: '${{ github.token }}'
69
106
  CHANT_RUNNER_KEY: '${{ secrets.CHANT_RUNNER_KEY }}'
70
107
  run: |
71
- if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then git fetch origin "main" && BASE_SHA="$(git merge-base FETCH_HEAD "$GITHUB_SHA")"; if [ "$BASE_SHA" = "$GITHUB_SHA" ]; then BASE_SHA="$(git rev-parse --verify --quiet "$GITHUB_SHA^" || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)"; fi; fi; export BASE_SHA
72
- chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github
108
+ git fetch --quiet --tags origin "+refs/heads/main:refs/remotes/origin/main" && GREEN_SHA="$(chant ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"
109
+ chant ci run apply --in-place --env prod --gate pr-apply --forge github
73
110
  - name: Keep the record for a re-run
74
111
  if: always()
75
112
  uses: actions/cache/save@v4
@@ -61,6 +61,8 @@ import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components
61
61
  import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
62
62
  import { CI_IMAGE, ciJobName, dirnameOf, fillCommand, GITHUB_RUNNER } from "@intentius/chant/ci-jobs";
63
63
  import {
64
+ GREEN_TAG_PATTERN,
65
+ GREEN_TICK_WORKFLOW,
64
66
  PR_APPLY_RECORD,
65
67
  PR_LOOP_REPORT_DIR,
66
68
  prApplyGroup,
@@ -120,6 +122,10 @@ export interface GithubPipelineDoc {
120
122
  jobs: GeneratedJob[];
121
123
  }
122
124
 
125
+ /** The output a gated apply wave hands the green commit it applies through, and the step that sets it (#4221). */
126
+ const WAVE_GREEN_OUTPUT = "green-sha";
127
+ const WAVE_STEP_ID = "apply";
128
+
123
129
  /** The workflow artifact the gated-wave jobs hand their attempt record through, one per wave. */
124
130
  function waveRecordArtifact(wave: number): string {
125
131
  return `fan-out-record-wave-${wave}`;
@@ -238,7 +244,15 @@ function prLoopGithubDoc(
238
244
  ...(job.jobName === "apply" ? [restoreRecord(job.wave)] : []),
239
245
  // The job's setup lines (#4004) run in the command's shell, so a value they settle reaches it.
240
246
  // The command runs through chant ci run (#4156), which writes the job's run record, signed when the CHANT_RUNNER_KEY secret is set.
241
- { name, env: job.run.env, run: [...(job.setup ?? []), job.run.command].join("\n") },
247
+ // #4221: a wave job hands the commit it applies to the next wave as the job output `green-sha`.
248
+ job.wave
249
+ ? {
250
+ name,
251
+ id: WAVE_STEP_ID,
252
+ env: { ...job.run.env, ...(job.wave > 1 ? { GREEN_SHA: `\${{ needs.${prApplyWaveJobName(job.wave - 1)}.outputs.${WAVE_GREEN_OUTPUT} }}` } : {}) },
253
+ run: [...(job.checkout ?? []), `echo "${WAVE_GREEN_OUTPUT}=$GREEN_SHA" >> "$GITHUB_OUTPUT"`, ...(job.setup ?? []), job.run.command].join("\n"),
254
+ }
255
+ : { name, env: job.run.env, run: [...(job.checkout ?? []), ...(job.setup ?? []), job.run.command].join("\n") },
242
256
  ...runSteps(extraScript),
243
257
  ...(job.jobName === "apply" ? [saveRecord(job.wave)] : []),
244
258
  {
@@ -248,24 +262,31 @@ function prLoopGithubDoc(
248
262
  with: { name: reportName(job), path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true },
249
263
  },
250
264
  ];
265
+ // #4168 step 9: under apply on merge the apply runs on a green signal, and a push to the branch records.
266
+ const forgejo = loop.forge === "forgejo";
267
+ const onGreen = forgejo ? "startsWith(github.ref, 'refs/tags/ci/green/')" : "github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'";
268
+ const onPush = loop.applyOnMerge && forgejo ? "github.event_name == 'push' && startsWith(github.ref, 'refs/heads/')" : "github.event_name == 'push'";
251
269
  const applyJob = (job: typeof plan, needs: string[], name: string) => ({
252
- if: "github.event_name == 'push'",
270
+ if: job.jobName === "record" ? onPush : onGreen,
253
271
  "runs-on": GITHUB_RUNNER,
254
272
  ...(needs.length > 0 ? { needs } : {}),
255
273
  container: image,
256
274
  concurrency: concurrency(prApplyGroup(env, member?.name)),
257
275
  permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
276
+ ...(job.wave ? { outputs: { [WAVE_GREEN_OUTPUT]: `\${{ steps.${WAVE_STEP_ID}.outputs.${WAVE_GREEN_OUTPUT} }}` } } : {}),
258
277
  steps: steps(job, name),
259
278
  });
260
279
  const waveJobs = loop.waveGate ? prLoopWaveJobs(components, apply) : [];
261
- const applyJobs: Array<[string, Record<string, unknown>]> = loop.waveGate
280
+ const recordJob: [string, Record<string, unknown>] = ["record", { ...applyJob(record, [], "Record the plan on the merged commit; nothing applies"), concurrency: concurrency(prRecordGroup(env, member?.name)) }];
281
+ const greenJobs: Array<[string, Record<string, unknown>]> = loop.waveGate
262
282
  ? waveJobs.map((job) => [
263
283
  prApplyWaveJobName(job.wave!),
264
- applyJob(job, job.wave! > 1 ? [prApplyWaveJobName(job.wave! - 1)] : [], `Apply wave ${job.wave}, under the approval its plan has`),
284
+ applyJob(job, job.wave! > 1 ? [prApplyWaveJobName(job.wave! - 1)] : [], job.wave === 1 ? "Apply wave 1 of the newest green commit, under the approval its plan has" : `Apply wave ${job.wave} of the green commit wave 1 took, under the approval its plan has`),
265
285
  ])
266
286
  : loop.applyOnMerge
267
- ? [["apply", applyJob(apply, [], "Apply the plan a reviewer approved")]]
268
- : [["record", { ...applyJob(record, [], "Record the plan on the merged commit; nothing applies"), concurrency: concurrency(prRecordGroup(env, member?.name)) }]];
287
+ ? [["apply", applyJob(apply, [], "Apply the newest green commit under the merged changes' approvals")]]
288
+ : [];
289
+ const applyJobs: Array<[string, Record<string, unknown>]> = [recordJob, ...greenJobs];
269
290
  const jobsDoc: Record<string, unknown> = {
270
291
  plan: {
271
292
  if: "github.event_name == 'pull_request'",
@@ -280,7 +301,11 @@ function prLoopGithubDoc(
280
301
  return {
281
302
  name: member ? `chant-pr.${member.name}.${env}` : `chant-pr-${env}`,
282
303
  environment: env,
283
- on: { pull_request: { branches: [branch] }, push: { branches: [branch] } },
304
+ on: !loop.applyOnMerge
305
+ ? { pull_request: { branches: [branch] }, push: { branches: [branch] } }
306
+ : forgejo
307
+ ? { pull_request: { branches: [branch] }, push: { branches: [branch], tags: [GREEN_TAG_PATTERN] } }
308
+ : { pull_request: { branches: [branch] }, push: { branches: [branch] }, workflow_run: { workflows: [GREEN_TICK_WORKFLOW], types: ["completed"] }, workflow_dispatch: {} },
284
309
  ...(rooted ? {} : { defaults: { run: { "working-directory": member!.dir } } }),
285
310
  env: { ...options.variables, CHANT_ENV: env },
286
311
  jobsDoc,
@@ -289,9 +314,10 @@ function prLoopGithubDoc(
289
314
  { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
290
315
  ...applyNames.map((name, i) => ({
291
316
  jobName: name,
292
- component: loop.waveGate ? `pull request apply, wave ${i + 1}` : loop.applyOnMerge ? "pull request apply" : "merge record",
317
+ component: name === "record" ? "merge record" : loop.waveGate ? `green apply, wave ${i}` : "green apply",
293
318
  stage: name,
294
- needs: i > 0 ? [applyNames[i - 1]] : [],
319
+ // The record runs on a push and the apply on a green commit, so only the waves need each other.
320
+ needs: i > 1 ? [applyNames[i - 1]] : [],
295
321
  })),
296
322
  ],
297
323
  };
@@ -11,7 +11,7 @@ import { join } from "node:path";
11
11
  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
- import { githubBaseFallback, prApplyGroup, prApplyRecordKey, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
14
+ import { githubBaseFallback, greenCheckout, prApplyGroup, prApplyRecordKey, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
15
15
  import { generateGithubPipeline } from "./generate-pipeline";
16
16
 
17
17
  const ESTATE: DriverComponent[] = [
@@ -54,12 +54,15 @@ describe("the GitHub pull-request workflow", () => {
54
54
  expect(result.yaml).toBe(readFileSync(GOLDEN, "utf-8"));
55
55
  });
56
56
 
57
- test("plans on a pull request into main and applies on a push to it", () => {
57
+ test("plans on a pull request into main, records on a push to it, and applies once the tick ran (#4168 step 9)", () => {
58
58
  expect(doc.name).toBe("chant-pr-prod");
59
- expect(doc.on).toEqual({ pull_request: { branches: ["main"] }, push: { branches: ["main"] } });
59
+ // A tag the tick pushes with the job token starts no workflow, so the apply follows the tick's workflow instead.
60
+ expect(doc.on).toEqual({ pull_request: { branches: ["main"] }, push: { branches: ["main"] }, workflow_run: { workflows: ["chant-ci-green"], types: ["completed"] }, workflow_dispatch: {} });
60
61
  expect(doc.jobs.plan.if).toBe("github.event_name == 'pull_request'");
61
- expect(doc.jobs.apply.if).toBe("github.event_name == 'push'");
62
- expect(result.jobs.map((j) => j.jobName)).toEqual(["plan", "apply"]);
62
+ expect(doc.jobs.record.if).toBe("github.event_name == 'push'");
63
+ expect(doc.jobs.apply.if).toBe("github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'");
64
+ expect(result.jobs.map((j) => j.jobName)).toEqual(["plan", "record", "apply"]);
65
+ expect(result.jobs.find((j) => j.jobName === "apply")!.needs).toEqual([]);
63
66
  });
64
67
 
65
68
  test("the plan job measures from the pull request's base and cannot push; the apply job can, one at a time", () => {
@@ -75,26 +78,35 @@ describe("the GitHub pull-request workflow", () => {
75
78
  // #4168: ci run builds pr-apply without --require-review unless the pipeline asks for it.
76
79
  expect(apply.run).not.toContain("--require-review");
77
80
  expect(apply.run).not.toContain("--no-push");
78
- expect(apply.env).toMatchObject({ BASE_SHA: "${{ github.event.before }}", CHANT_RUNNER_KEY: "${{ secrets.CHANT_RUNNER_KEY }}" });
81
+ // #4168 step 9: the apply measures from the last applied green commit, so it reads no push base.
82
+ expect(apply.env).toEqual({ GITHUB_TOKEN: "${{ github.token }}", CHANT_RUNNER_KEY: "${{ secrets.CHANT_RUNNER_KEY }}" });
79
83
  expect(doc.jobs.apply.permissions.contents).toBe("write");
80
84
  expect(doc.jobs.apply.concurrency).toEqual({ group: "chant-apply-prod", "cancel-in-progress": false });
81
85
  for (const job of Object.values(doc.jobs)) expect(job.steps[0].with).toEqual({ "fetch-depth": 0 });
82
86
  });
83
87
 
84
- test("a first push's all-zero github.event.before is settled in the apply's own shell, before pr-apply reads it (#4004)", () => {
85
- const apply = doc.jobs.apply.steps.find((s) => s.run?.includes("ci run apply"))!;
86
- const lines = apply.run!.trimEnd().split("\n");
88
+ test("a first push's all-zero github.event.before is settled in the record's own shell, before pr-record reads it (#4004)", () => {
89
+ const record = doc.jobs.record.steps.find((s) => s.run?.includes("ci run record"))!;
90
+ const lines = record.run!.trimEnd().split("\n");
87
91
  expect(lines).toHaveLength(2);
88
92
  expect(lines[0]).toBe(githubBaseFallback());
89
93
  expect(lines[0]).toContain('[ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]');
90
94
  expect(lines[0]).toContain('git merge-base FETCH_HEAD "$GITHUB_SHA"');
91
- expect(lines[1]).toMatch(/^chant ci run apply --in-place --base "\$BASE_SHA"/);
92
- const trunk = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, branch: "trunk" } }).yaml) as { jobs: Record<string, Job> };
93
- expect(trunk.jobs.apply.steps.find((s) => s.run?.includes("ci run apply"))!.run).toContain('git fetch origin "trunk"');
95
+ expect(lines[1]).toMatch(/^chant ci run record --in-place --base "\$BASE_SHA"/);
94
96
  const plan = doc.jobs.plan.steps.find((s) => s.run?.includes("ci run plan"))!;
95
97
  expect(plan.run).not.toContain("0000000000000000000000000000000000000000");
96
98
  });
97
99
 
100
+ test("the apply checks out the newest green commit of the branch before it runs (#4168 step 9)", () => {
101
+ const apply = doc.jobs.apply.steps.find((s) => s.run?.includes("ci run apply"))!;
102
+ const lines = apply.run!.trimEnd().split("\n");
103
+ expect(lines).toEqual([greenCheckout('"main"'), "chant ci run apply --in-place --env prod --gate pr-apply --forge github"]);
104
+ expect(lines[0]).toContain('GREEN_SHA="$(chant ci last-green)"');
105
+ const trunk = parseYAML(generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, branch: "trunk" } }).yaml) as { jobs: Record<string, Job> };
106
+ expect(trunk.jobs.apply.steps.find((s) => s.run?.includes("ci run apply"))!.run).toContain('"+refs/heads/trunk:refs/remotes/origin/trunk"');
107
+ expect(prLoopJobs("github", "prod")[1]!.command).toMatch(/^chant components pr-apply --green /);
108
+ });
109
+
98
110
  test("the apply resumes from a record the cache keeps for the pushed commit across a re-run (#3543)", () => {
99
111
  const steps = doc.jobs.apply.steps;
100
112
  const restore = steps.findIndex((s) => s.uses === "actions/cache/restore@v4");
@@ -130,7 +142,7 @@ describe("the GitHub pull-request workflow", () => {
130
142
  const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true, gate: "infra-apply", branch: "trunk", requireReview: true } }).yaml;
131
143
  expect(yaml).toContain("--gate infra-apply");
132
144
  expect(yaml).toContain("- trunk");
133
- expect(yaml).toContain("ci run apply --in-place --base \"$BASE_SHA\" --env prod --gate infra-apply --forge github --branch \"trunk\" --require-review");
145
+ expect(yaml).toContain("ci run apply --in-place --env prod --gate infra-apply --forge github --branch \"trunk\" --require-review");
134
146
  expect(yaml).toContain('--branch "trunk"');
135
147
  });
136
148
 
@@ -163,7 +175,7 @@ describe("the GitHub pull-request workflow", () => {
163
175
  };
164
176
  expect(mdoc.name).toBe("chant-pr.network.prod");
165
177
  // No path filter: a change outside the member can reach it.
166
- expect(mdoc.on).toEqual({ pull_request: { branches: ["main"] }, push: { branches: ["main"] } });
178
+ expect(mdoc.on).toEqual({ pull_request: { branches: ["main"] }, push: { branches: ["main"] }, workflow_run: { workflows: ["chant-ci-green"], types: ["completed"] }, workflow_dispatch: {} });
167
179
  expect(mdoc.defaults).toEqual({ run: { "working-directory": "infra/network" } });
168
180
  const plan = mdoc.jobs.plan.steps.find((s) => s.run?.includes("ci run plan"))!;
169
181
  // ci run finds the member from the directory it runs in and passes --member to pr-plan and pr-apply.
@@ -59,10 +59,31 @@ describe("the GitHub pull-request workflow with a gate per wave (#4003)", () =>
59
59
  expect(result.yaml).toBe(readFileSync(GOLDEN, "utf-8"));
60
60
  });
61
61
 
62
- test("keeps the plan job, and writes one apply job per wave, each needing the one before", () => {
63
- expect(Object.keys(doc.jobs)).toEqual(["plan", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
62
+ test("wave 1 takes the newest green commit, and each later wave applies the one wave 1 took (#4221)", () => {
63
+ const jobs = doc.jobs as unknown as Record<string, { outputs?: Record<string, string>; steps: Array<{ id?: string; run?: string; env?: Record<string, string> }> }>;
64
+ const waves = Object.keys(jobs).filter((name) => name.startsWith("apply-wave-")).length;
65
+ expect(waves).toBeGreaterThan(1);
66
+ for (let k = 1; k <= waves; k++) {
67
+ const job = jobs[`apply-wave-${k}`]!;
68
+ expect(job.outputs).toEqual({ "green-sha": "${{ steps.apply.outputs.green-sha }}" });
69
+ const step = job.steps.find((s) => s.id === "apply")!;
70
+ expect(step.run).toContain('echo "green-sha=$GREEN_SHA" >> "$GITHUB_OUTPUT"');
71
+ if (k === 1) {
72
+ expect(step.run).toContain('GREEN_SHA="$(chant ci last-green)"');
73
+ expect(step.env?.GREEN_SHA).toBeUndefined();
74
+ } else {
75
+ expect(step.run).not.toContain("chant ci last-green");
76
+ expect(step.run).toContain('test -n "$GREEN_SHA"');
77
+ expect(step.env?.GREEN_SHA).toBe(`\${{ needs.apply-wave-${k - 1}.outputs.green-sha }}`);
78
+ }
79
+ }
80
+ });
81
+
82
+ test("keeps the plan and record jobs, and writes one apply job per wave, each needing the one before", () => {
83
+ expect(Object.keys(doc.jobs)).toEqual(["plan", "record", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
64
84
  expect(result.jobs.map((j) => [j.jobName, j.needs])).toEqual([
65
85
  ["plan", []],
86
+ ["record", []],
66
87
  ["apply-wave-1", []],
67
88
  ["apply-wave-2", ["apply-wave-1"]],
68
89
  ["apply-wave-3", ["apply-wave-2"]],
@@ -74,12 +95,13 @@ describe("the GitHub pull-request workflow with a gate per wave (#4003)", () =>
74
95
  expect(plan.run).not.toContain("--wave ");
75
96
  for (const k of [1, 2, 3]) {
76
97
  const job = doc.jobs[`apply-wave-${k}`]!;
77
- expect(job.if).toBe("github.event_name == 'push'");
98
+ // #4168 step 9: the waves apply the newest green commit once the tick ran.
99
+ expect(job.if).toBe("github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'");
78
100
  expect(job.concurrency).toEqual({ group: "chant-apply-prod", "cancel-in-progress": false });
79
101
  expect(job.permissions.contents).toBe("write");
80
102
  const run = job.steps.find((s) => s.run?.includes("ci run apply"))!.run!;
81
103
  // #4156: through chant ci run, which runs pr-apply --wave-gate release (no --require-review since #4168) --resume ... --wave <k>.
82
- expect(run).toContain(`chant ci run apply --in-place --base "$BASE_SHA" --env prod --gate pr-apply --forge github --wave-gate release --wave ${k}`);
104
+ expect(run).toContain(`chant ci run apply --in-place --env prod --gate pr-apply --forge github --wave-gate release --wave ${k}`);
83
105
  expect(job.steps.find((s) => s.run?.includes("ci run apply"))!.env?.CHANT_RUNNER_KEY).toBe("${{ secrets.CHANT_RUNNER_KEY }}");
84
106
  }
85
107
  });
@@ -99,7 +121,7 @@ describe("the GitHub pull-request workflow with a gate per wave (#4003)", () =>
99
121
 
100
122
  test("without --wave-gate the workflow is the one apply job it was", () => {
101
123
  const plain = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } });
102
- expect(plain.jobs.map((j) => j.jobName)).toEqual(["plan", "apply"]);
124
+ expect(plain.jobs.map((j) => j.jobName)).toEqual(["plan", "record", "apply"]);
103
125
  expect(plain.yaml).not.toContain("--wave");
104
126
  });
105
127