@intentius/chant-lexicon-github 0.102.0 → 0.104.1

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;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAGjG,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;AA6BD;;;;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,CAmKnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAU/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;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAKjG,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;AAmKD;;;;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,CAgLnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAU/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "acb14fd2e5c1d90d4de3e5400297d5f0e94abde98b5325f30e56eff59cc15440",
4
+ "manifest.json": "fdb92786145ee3d5605d434aa59832d4feb14b3663b5963b012710546606e047",
5
5
  "meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
6
6
  "types/index.d.ts": "bdd54a4145a39a9dd4f7bbc8c500191acb6bc9057a87da0040475afac84db397",
7
7
  "rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
@@ -78,5 +78,5 @@
78
78
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
79
79
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
80
80
  },
81
- "composite": "4ca047e656894e9ca5b89957a7622a8851c3297e5540ef5bba9914e478320553"
81
+ "composite": "4120e5c5daf8c9e06b7a2702a881c96d81964046b4723c1b036ac52fe54b5826"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.102.0",
3
+ "version": "0.104.1",
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.102.0",
3
+ "version": "0.104.1",
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,7 +61,7 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.102.0",
64
+ "@intentius/chant": "^0.104.1",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -0,0 +1,68 @@
1
+ name: chant-pr-prod
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+ push:
8
+ branches:
9
+ - main
10
+
11
+ env:
12
+ CHANT_ENV: prod
13
+
14
+ jobs:
15
+ plan:
16
+ if: github.event_name == 'pull_request'
17
+ runs-on: ubuntu-latest
18
+ container: node:22-slim
19
+ permissions:
20
+ contents: read
21
+ pull-requests: write
22
+ statuses: write
23
+ steps:
24
+ - uses: actions/checkout@v7
25
+ with:
26
+ fetch-depth: 0
27
+ - name: Plan the members this pull request reaches
28
+ env:
29
+ BASE_SHA: '${{ github.event.pull_request.base.sha }}'
30
+ PR_NUMBER: '${{ github.event.pull_request.number }}'
31
+ GITHUB_TOKEN: '${{ github.token }}'
32
+ run: chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge github
33
+ - name: Keep the plan report
34
+ if: always()
35
+ uses: actions/upload-artifact@v4
36
+ with:
37
+ name: chant-pr-plan
38
+ path: .chant/pr
39
+ if-no-files-found: ignore
40
+ include-hidden-files: true
41
+ apply:
42
+ if: github.event_name == 'push'
43
+ runs-on: ubuntu-latest
44
+ container: node:22-slim
45
+ concurrency:
46
+ group: chant-apply-prod
47
+ cancel-in-progress: false
48
+ permissions:
49
+ contents: write
50
+ pull-requests: write
51
+ statuses: write
52
+ steps:
53
+ - uses: actions/checkout@v7
54
+ with:
55
+ fetch-depth: 0
56
+ - name: Apply the plan a reviewer approved
57
+ env:
58
+ BASE_SHA: '${{ github.event.before }}'
59
+ GITHUB_TOKEN: '${{ github.token }}'
60
+ run: chant components pr-apply --base "$BASE_SHA" --env prod --gate pr-apply --output .chant/pr --forge github --require-review
61
+ - name: Keep the apply report
62
+ if: always()
63
+ uses: actions/upload-artifact@v4
64
+ with:
65
+ name: chant-pr-apply
66
+ path: .chant/pr
67
+ if-no-files-found: ignore
68
+ include-hidden-files: true
@@ -421,3 +421,32 @@ describe("generateGithubPipeline: a promote job (#2575)", () => {
421
421
  expect(artifactRoot(["/tmp/out/a.tar", "/tmp/out/b.tar"])).toBe("/tmp/out");
422
422
  });
423
423
  });
424
+
425
+ describe("generateGithubPipeline: gated waves (#3049)", () => {
426
+ const roots: DriverComponent[] = [
427
+ { name: "net", deploy: [] },
428
+ { name: "a", dependsOn: ["net"], deploy: [] },
429
+ { name: "app", dependsOn: ["a"], deploy: [] },
430
+ ];
431
+
432
+ test("one job per wave, each handing the attempt record to the next", () => {
433
+ const result = generateGithubPipeline(roots, { env: "prod", gatedWaves: { gate: "release" } });
434
+ const parsed = parseYAML(result.yaml) as { jobs: Record<string, { needs?: string[]; steps: Array<Record<string, unknown>> }> };
435
+ expect(Object.keys(parsed.jobs)).toEqual(["wave-1", "wave-2", "wave-3"]);
436
+ expect(parsed.jobs["wave-2"].needs).toEqual(["wave-1"]);
437
+
438
+ const steps = parsed.jobs["wave-2"].steps;
439
+ expect(steps[0].with).toEqual({ "fetch-depth": 0 });
440
+ expect(steps[1]).toMatchObject({ uses: "actions/download-artifact@v4", with: { name: "fan-out-record-wave-1", path: ".chant" } });
441
+ expect(steps[2].run).toBe(
442
+ "chant components fan-out --base HEAD~1 --env prod --wave-gate release --wave 2 --resume .chant/fan-out.json",
443
+ );
444
+ expect(steps[3]).toMatchObject({ if: "always()", with: { name: "fan-out-record-wave-2", path: ".chant/fan-out.json" } });
445
+ // Wave 1 has nothing to download.
446
+ expect(parsed.jobs["wave-1"].steps.some((s) => s.uses === "actions/download-artifact@v4")).toBe(false);
447
+ });
448
+
449
+ test("a gated-wave pipeline has no promote job", () => {
450
+ expect(() => generateGithubPipeline(roots, { gatedWaves: { gate: "release" }, promoteTo: "prod" })).toThrow(/no promote job/);
451
+ });
452
+ });
@@ -59,6 +59,8 @@
59
59
  import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
60
60
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
61
61
  import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
62
+ import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
63
+ import { PR_LOOP_REPORT_DIR, prApplyGroup, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
62
64
  import { memberRepoPath } from "@intentius/chant/lexicon";
63
65
  import type {
64
66
  ComponentPipelineJob as GeneratedJob,
@@ -107,6 +109,140 @@ export interface GithubPipelineDoc {
107
109
  jobs: GeneratedJob[];
108
110
  }
109
111
 
112
+ /** The workflow artifact the gated-wave jobs hand their attempt record through, one per wave. */
113
+ function waveRecordArtifact(wave: number): string {
114
+ return `fan-out-record-wave-${wave}`;
115
+ }
116
+
117
+ /**
118
+ * The gated-wave workflow (#3049): one job per wave, each running one wave of
119
+ * `chant components fan-out --wave-gate`, chained by `needs:`. Each job
120
+ * downloads the record the job before it uploaded and uploads its own, even
121
+ * when it stops at a gate (exit 3), so re-running it after `chant approve`
122
+ * reads the record. The checkout fetches full history for `--base`.
123
+ */
124
+ function gatedWaveGithubDoc(
125
+ components: DriverComponent[],
126
+ env: string,
127
+ image: string,
128
+ beforeScript: string[],
129
+ extraScript: string[],
130
+ options: GenerateGithubOptions,
131
+ ): GithubPipelineDoc {
132
+ const waveJobs = gatedWaveJobs(components, env, options.gatedWaves!);
133
+ const jobsDoc: Record<string, unknown> = {};
134
+ const jobs: GeneratedJob[] = [];
135
+ for (const job of waveJobs) {
136
+ jobs.push({ jobName: job.jobName, component: `wave ${job.wave}`, stage: job.jobName, needs: job.needs });
137
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } }];
138
+ if (job.wave > 1) {
139
+ steps.push({
140
+ name: `Download the record wave ${job.wave - 1} left`,
141
+ uses: "actions/download-artifact@v4",
142
+ with: { name: waveRecordArtifact(job.wave - 1), path: dirnameOf(GATED_WAVE_RECORD) },
143
+ });
144
+ }
145
+ for (const line of beforeScript) steps.push({ run: line });
146
+ steps.push({ run: job.command.join(" ") });
147
+ for (const line of extraScript) steps.push({ run: line });
148
+ steps.push({
149
+ name: `Upload the record for wave ${job.wave}`,
150
+ if: "always()",
151
+ uses: "actions/upload-artifact@v4",
152
+ with: {
153
+ name: waveRecordArtifact(job.wave),
154
+ path: GATED_WAVE_RECORD,
155
+ "if-no-files-found": "ignore",
156
+ "include-hidden-files": true,
157
+ overwrite: true,
158
+ },
159
+ });
160
+ jobsDoc[job.jobName] = {
161
+ "runs-on": "ubuntu-latest",
162
+ ...(job.needs.length > 0 ? { needs: job.needs } : {}),
163
+ container: image,
164
+ steps,
165
+ };
166
+ }
167
+ const doc: GithubPipelineDoc = {
168
+ name: `chant-components-${env}`,
169
+ environment: env,
170
+ on: { workflow_dispatch: {} },
171
+ env: { ...options.variables, CHANT_ENV: env },
172
+ jobsDoc,
173
+ stages: waveJobs.map((j) => j.jobName),
174
+ jobs,
175
+ };
176
+ return options.member ? scopeToMember(doc, options.member) : doc;
177
+ }
178
+
179
+ /**
180
+ * The pull-request workflow (#3183): `plan` on each pull request into the
181
+ * target branch, `apply` on each push to it. The plan job can comment and set
182
+ * statuses but cannot push, since it runs the pull request's code; the apply
183
+ * job can push, to record a pending fact on `chant/lifecycle`, and runs one
184
+ * at a time per environment. Both keep their report as an artifact, and both
185
+ * check out the full history, since each measures the change with git.
186
+ */
187
+ function prLoopGithubDoc(
188
+ env: string,
189
+ image: string,
190
+ beforeScript: string[],
191
+ extraScript: string[],
192
+ options: GenerateGithubOptions,
193
+ ): GithubPipelineDoc {
194
+ const loop = options.prLoop!;
195
+ const branch = loop.branch ?? "main";
196
+ const [plan, apply] = prLoopJobs(loop.forge ?? "github", env, loop);
197
+ const steps = (job: typeof plan, name: string): Array<Record<string, unknown>> => [
198
+ { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } },
199
+ ...beforeScript.map((line) => ({ run: line })),
200
+ { name, env: job.env, run: job.command },
201
+ ...extraScript.map((line) => ({ run: line })),
202
+ {
203
+ name: `Keep the ${job.jobName} report`,
204
+ if: "always()",
205
+ uses: "actions/upload-artifact@v4",
206
+ with: { name: `chant-pr-${job.jobName}`, path: PR_LOOP_REPORT_DIR, "if-no-files-found": "ignore", "include-hidden-files": true },
207
+ },
208
+ ];
209
+ const jobsDoc: Record<string, unknown> = {
210
+ plan: {
211
+ if: "github.event_name == 'pull_request'",
212
+ "runs-on": "ubuntu-latest",
213
+ container: image,
214
+ permissions: { contents: "read", "pull-requests": "write", statuses: "write" },
215
+ steps: steps(plan, "Plan the members this pull request reaches"),
216
+ },
217
+ apply: {
218
+ if: "github.event_name == 'push'",
219
+ "runs-on": "ubuntu-latest",
220
+ container: image,
221
+ concurrency: { group: prApplyGroup(env), "cancel-in-progress": false },
222
+ permissions: { contents: "write", "pull-requests": "write", statuses: "write" },
223
+ steps: steps(apply, "Apply the plan a reviewer approved"),
224
+ },
225
+ };
226
+ return {
227
+ name: `chant-pr-${env}`,
228
+ environment: env,
229
+ on: { pull_request: { branches: [branch] }, push: { branches: [branch] } },
230
+ env: { ...options.variables, CHANT_ENV: env },
231
+ jobsDoc,
232
+ stages: ["plan", "apply"],
233
+ jobs: [
234
+ { jobName: "plan", component: "pull request plan", stage: "plan", needs: [] },
235
+ { jobName: "apply", component: "pull request apply", stage: "apply", needs: [] },
236
+ ],
237
+ };
238
+ }
239
+
240
+ /** The directory part of a relative path, `.` when there is none. */
241
+ function dirnameOf(path: string): string {
242
+ const i = path.lastIndexOf("/");
243
+ return i <= 0 ? "." : path.slice(0, i);
244
+ }
245
+
110
246
  /** GitHub Actions job ids must match `[a-zA-Z_][a-zA-Z0-9_-]*`; component names are already kebab-case in every fixture, but normalize defensively (mirrors gitlab's `toJobName`). */
111
247
  function toJobName(componentName: string): string {
112
248
  return componentName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
@@ -174,6 +310,19 @@ export function buildGithubPipelineDoc(
174
310
  const { waves } = resolveComponentGraph(components);
175
311
  const byName = new Map(components.map((c) => [c.name, c]));
176
312
 
313
+ if (options.prLoop) {
314
+ if (options.gatedWaves || options.promoteTo !== undefined) {
315
+ throw new Error("a pull-request pipeline has no wave or promote jobs; drop --wave-gate and --promote-to, or --pr-loop");
316
+ }
317
+ if (options.member) throw new Error("a pull-request pipeline is not generated for a workspace member yet; generate it from the project outside the workspace");
318
+ return prLoopGithubDoc(env, image, beforeScript, extraScript, options);
319
+ }
320
+
321
+ if (options.gatedWaves) {
322
+ if (options.promoteTo !== undefined) throw new Error("a gated-wave pipeline has no promote job; drop --promote-to or --wave-gate");
323
+ return gatedWaveGithubDoc(components, env, image, beforeScript, extraScript, options);
324
+ }
325
+
177
326
  // Components that something else depends on must hand their resolved outputs
178
327
  // (stack outputs, published artifact refs) to their dependents, which run as
179
328
  // separate jobs on separate runners. Each such producer dumps its outputs to
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The generated pull-request workflow for GitHub Actions (#3183): a plan job
3
+ * on each pull request, an apply job on each push to the target branch. The
4
+ * whole file has a golden, and actionlint checks it when it is installed.
5
+ */
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { describe, expect, test } from "vitest";
12
+ import { parseYAML } from "@intentius/chant/yaml";
13
+ import type { DriverComponent } from "@intentius/chant/components/driver";
14
+ import { generateGithubPipeline } from "./generate-pipeline";
15
+
16
+ const ESTATE: DriverComponent[] = [
17
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
18
+ { name: "app", dependsOn: ["net"], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "app" }] }] },
19
+ ];
20
+
21
+ const GOLDEN = join(import.meta.dirname, "__fixtures__", "pr-loop.github.golden.yml");
22
+
23
+ function hasActionlint(): boolean {
24
+ try {
25
+ execFileSync("actionlint", ["-version"], { stdio: "ignore" });
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ interface Step {
33
+ uses?: string;
34
+ run?: string;
35
+ env?: Record<string, string>;
36
+ with?: Record<string, unknown>;
37
+ }
38
+ interface Job {
39
+ if: string;
40
+ permissions: Record<string, string>;
41
+ concurrency?: Record<string, unknown>;
42
+ steps: Step[];
43
+ }
44
+
45
+ describe("the GitHub pull-request workflow", () => {
46
+ const result = generateGithubPipeline(ESTATE, { env: "prod", prLoop: {} });
47
+ const doc = parseYAML(result.yaml) as { name: string; on: Record<string, unknown>; jobs: Record<string, Job> };
48
+
49
+ test("matches its golden", () => {
50
+ if (process.env.UPDATE_GOLDEN) writeFileSync(GOLDEN, result.yaml);
51
+ expect(result.yaml).toBe(readFileSync(GOLDEN, "utf-8"));
52
+ });
53
+
54
+ test("plans on a pull request into main and applies on a push to it", () => {
55
+ expect(doc.name).toBe("chant-pr-prod");
56
+ expect(doc.on).toEqual({ pull_request: { branches: ["main"] }, push: { branches: ["main"] } });
57
+ expect(doc.jobs.plan.if).toBe("github.event_name == 'pull_request'");
58
+ expect(doc.jobs.apply.if).toBe("github.event_name == 'push'");
59
+ expect(result.jobs.map((j) => j.jobName)).toEqual(["plan", "apply"]);
60
+ });
61
+
62
+ test("the plan job measures from the pull request's base and cannot push; the apply job can, one at a time", () => {
63
+ const plan = doc.jobs.plan.steps.find((s) => s.run?.includes("pr-plan"))!;
64
+ expect(plan.run).toBe('chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge github');
65
+ expect(plan.env).toMatchObject({ BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}" });
66
+ expect(doc.jobs.plan.permissions).toEqual({ contents: "read", "pull-requests": "write", statuses: "write" });
67
+
68
+ const apply = doc.jobs.apply.steps.find((s) => s.run?.includes("pr-apply"))!;
69
+ expect(apply.run).toContain("--require-review");
70
+ expect(apply.env).toMatchObject({ BASE_SHA: "${{ github.event.before }}" });
71
+ expect(doc.jobs.apply.permissions.contents).toBe("write");
72
+ expect(doc.jobs.apply.concurrency).toEqual({ group: "chant-apply-prod", "cancel-in-progress": false });
73
+ for (const job of Object.values(doc.jobs)) expect(job.steps[0].with).toEqual({ "fetch-depth": 0 });
74
+ });
75
+
76
+ test("the gate, branch and review requirement are options", () => {
77
+ const yaml = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { gate: "infra-apply", branch: "trunk", requireReview: false } }).yaml;
78
+ expect(yaml).toContain("--gate infra-apply");
79
+ expect(yaml).toContain("- trunk");
80
+ expect(yaml).not.toContain("--require-review");
81
+ });
82
+
83
+ test("refuses a wave or promote job beside it", () => {
84
+ expect(() => generateGithubPipeline(ESTATE, { prLoop: {}, gatedWaves: { gate: "g" } })).toThrow(/pull-request pipeline has no wave or promote jobs/);
85
+ expect(() => generateGithubPipeline(ESTATE, { prLoop: {}, promoteTo: "prod" })).toThrow(/pull-request pipeline has no wave or promote jobs/);
86
+ });
87
+
88
+ test.skipIf(!hasActionlint())("passes actionlint", () => {
89
+ const dir = mkdtempSync(join(tmpdir(), "chant-actionlint-"));
90
+ try {
91
+ mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
92
+ writeFileSync(join(dir, ".github", "workflows", "chant-pr.yml"), result.yaml);
93
+ expect(() => execFileSync("actionlint", [".github/workflows/chant-pr.yml"], { cwd: dir, encoding: "utf8" })).not.toThrow();
94
+ } finally {
95
+ rmSync(dir, { recursive: true, force: true });
96
+ }
97
+ });
98
+ });