@intentius/chant-lexicon-github 0.101.0 → 0.104.0

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.
Files changed (95) hide show
  1. package/dist/action-pins.d.ts +53 -0
  2. package/dist/action-pins.d.ts.map +1 -0
  3. package/dist/codegen/naming.d.ts.map +1 -1
  4. package/dist/components/generate-op-pipeline.d.ts.map +1 -1
  5. package/dist/components/generate-pipeline.d.ts.map +1 -1
  6. package/dist/composites/checkout.d.ts +7 -0
  7. package/dist/composites/checkout.d.ts.map +1 -1
  8. package/dist/composites/dependabot.d.ts.map +1 -1
  9. package/dist/composites/deploy-environment.d.ts.map +1 -1
  10. package/dist/composites/docker-build.d.ts.map +1 -1
  11. package/dist/composites/go-ci.d.ts.map +1 -1
  12. package/dist/composites/node-ci.d.ts.map +1 -1
  13. package/dist/composites/node-pipeline.d.ts +30 -30
  14. package/dist/composites/node-pipeline.d.ts.map +1 -1
  15. package/dist/composites/python-ci.d.ts.map +1 -1
  16. package/dist/composites/setup-node.d.ts +7 -0
  17. package/dist/composites/setup-node.d.ts.map +1 -1
  18. package/dist/expression.d.ts.map +1 -1
  19. package/dist/generated/index.d.ts +22 -22
  20. package/dist/generated/index.d.ts.map +1 -1
  21. package/dist/import/generator.d.ts.map +1 -1
  22. package/dist/import/parser.d.ts.map +1 -1
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/integrity.json +7 -6
  26. package/dist/lint/audit-catalog.d.ts.map +1 -1
  27. package/dist/lint/audit-lineage.d.ts +13 -0
  28. package/dist/lint/audit-lineage.d.ts.map +1 -1
  29. package/dist/lint/post-synth/gha021.d.ts.map +1 -1
  30. package/dist/lint/post-synth/gha029.d.ts.map +1 -1
  31. package/dist/lint/post-synth/gha059.d.ts.map +1 -1
  32. package/dist/lint/post-synth/gha069.d.ts +33 -0
  33. package/dist/lint/post-synth/gha069.d.ts.map +1 -0
  34. package/dist/lint/post-synth/index.d.ts.map +1 -1
  35. package/dist/lint/post-synth/yaml-helpers.d.ts +20 -0
  36. package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
  37. package/dist/lint/rules/data/deprecated-versions.d.ts.map +1 -1
  38. package/dist/manifest.json +1 -1
  39. package/dist/okf/index.md +1 -0
  40. package/dist/okf/rules/GHA069.md +16 -0
  41. package/dist/okf/types/Job.md +1 -0
  42. package/dist/okf/types/Workflow.md +1 -0
  43. package/dist/rules/gha021.ts +24 -24
  44. package/dist/rules/gha029.ts +3 -2
  45. package/dist/rules/gha059.ts +10 -2
  46. package/dist/rules/gha069.ts +111 -0
  47. package/dist/rules/yaml-helpers.ts +41 -47
  48. package/dist/variables.d.ts.map +1 -1
  49. package/package.json +3 -3
  50. package/src/action-pins.test.ts +87 -0
  51. package/src/action-pins.ts +75 -0
  52. package/src/codegen/docs.ts +1 -1
  53. package/src/components/__fixtures__/pr-loop.github.golden.yml +68 -0
  54. package/src/components/generate-op-pipeline.test.ts +4 -4
  55. package/src/components/generate-op-pipeline.ts +8 -7
  56. package/src/components/generate-pipeline.test.ts +30 -1
  57. package/src/components/generate-pipeline.ts +157 -14
  58. package/src/components/generate-pr-pipeline.test.ts +98 -0
  59. package/src/composites/checkout.ts +8 -1
  60. package/src/composites/composites.test.ts +4 -4
  61. package/src/composites/deploy-environment.ts +3 -2
  62. package/src/composites/docker-build.ts +2 -1
  63. package/src/composites/go-ci.ts +4 -3
  64. package/src/composites/node-ci.ts +3 -2
  65. package/src/composites/node-pipeline.ts +8 -7
  66. package/src/composites/python-ci.ts +3 -2
  67. package/src/composites/setup-node.ts +8 -1
  68. package/src/index.ts +4 -0
  69. package/src/lint/audit-catalog.ts +1 -0
  70. package/src/lint/audit-lineage.ts +13 -0
  71. package/src/lint/post-synth/fixtures/GHA069/negative-no-job-block.yml +16 -0
  72. package/src/lint/post-synth/fixtures/GHA069/negative-no-oidc-step.yml +22 -0
  73. package/src/lint/post-synth/fixtures/GHA069/negative.yml +21 -0
  74. package/src/lint/post-synth/fixtures/GHA069/positive.yml +20 -0
  75. package/src/lint/post-synth/gha011.test.ts +16 -0
  76. package/src/lint/post-synth/gha018.test.ts +18 -0
  77. package/src/lint/post-synth/gha019.test.ts +42 -0
  78. package/src/lint/post-synth/gha021.test.ts +18 -0
  79. package/src/lint/post-synth/gha021.ts +24 -24
  80. package/src/lint/post-synth/gha022.test.ts +15 -0
  81. package/src/lint/post-synth/gha023.test.ts +19 -0
  82. package/src/lint/post-synth/gha029.ts +3 -2
  83. package/src/lint/post-synth/gha038.test.ts +20 -0
  84. package/src/lint/post-synth/gha059.ts +10 -2
  85. package/src/lint/post-synth/gha069.test.ts +173 -0
  86. package/src/lint/post-synth/gha069.ts +111 -0
  87. package/src/lint/post-synth/index.ts +2 -0
  88. package/src/lint/post-synth/yaml-helpers.test.ts +83 -0
  89. package/src/lint/post-synth/yaml-helpers.ts +41 -47
  90. package/src/lint/rules/data/deprecated-versions.ts +5 -2
  91. package/src/lint/rules/deprecated-action-version.test.ts +1 -1
  92. package/src/lint/rules/rules.test.ts +1 -1
  93. package/src/mcp/context-tools.test.ts +17 -0
  94. package/src/plugin.test.ts +1 -1
  95. package/src/serializer.ts +15 -4
@@ -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
@@ -72,7 +72,7 @@ describe("generateGithubOpPipeline: one file per scheduled Op", () => {
72
72
  const job = doc.jobs!["actions-audit"];
73
73
 
74
74
  expect(job["runs-on"]).toBe("ubuntu-latest");
75
- expect(job.steps[0].uses).toBe("actions/checkout@v4");
75
+ expect(job.steps[0].uses).toBe("actions/checkout@v7");
76
76
  const runStep = job.steps.find((s) => typeof s.run === "string")!;
77
77
  expect(runStep.run).toBe("chant run actions-audit");
78
78
  });
@@ -327,7 +327,7 @@ describe("generateGithubOpPipeline: setup steps and additive permissions (#2242)
327
327
  const steps = doc.jobs!["app-apply"].steps;
328
328
 
329
329
  expect(steps.slice(0, 3).map((s) => s.uses ?? s.run)).toEqual([
330
- "actions/checkout@v4",
330
+ "actions/checkout@v7",
331
331
  "aws-actions/configure-aws-credentials@v6",
332
332
  "install terraform",
333
333
  ]);
@@ -385,7 +385,7 @@ describe("generateGithubOpPipeline: setup steps and additive permissions (#2242)
385
385
  ];
386
386
  const steps = parseFile(generateGithubOpPipeline(specs).files[0].yaml).jobs!["app-apply"].steps;
387
387
  expect(steps.map((s) => s.uses).filter(Boolean)).toEqual([
388
- "actions/checkout@v4",
388
+ "actions/checkout@v7",
389
389
  "github/codeql-action/upload-sarif@v4",
390
390
  "aws-actions/configure-aws-credentials@0e613a0980cbf65ed5b322eb7a1e075d28913a83",
391
391
  ]);
@@ -622,7 +622,7 @@ describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2
622
622
  " runs-on: ubuntu-latest",
623
623
  " container: node:22-slim",
624
624
  " steps:",
625
- " - uses: actions/checkout@v4",
625
+ " - uses: actions/checkout@v7",
626
626
  " - run: chant run actions-audit",
627
627
  " env:",
628
628
  " GITHUB_TOKEN: '${{ github.token }}'",
@@ -70,7 +70,7 @@
70
70
  * that job either, same as `environment:` above does not).
71
71
  */
72
72
 
73
- import { emitYAML } from "@intentius/chant/yaml";
73
+ import { emitYAML, emitYAMLEntry } from "@intentius/chant/yaml";
74
74
  import { memberPathFilter, resolveOpTrigger } from "@intentius/chant/lexicon";
75
75
  import type {
76
76
  ComponentPipelineOptions as GenerateGithubOpOptions,
@@ -83,6 +83,7 @@ import type {
83
83
  PipelineMember,
84
84
  ScheduledOpSpec,
85
85
  } from "@intentius/chant/lexicon";
86
+ import { actionRef } from "../action-pins";
86
87
 
87
88
  export type { GenerateGithubOpOptions, GenerateGithubOpResult };
88
89
 
@@ -709,7 +710,7 @@ export function buildGithubOpPipelineDocs(
709
710
  ? [...runParts, ...GATED_EXIT_FLAG, "--json"].join(" ")
710
711
  : runParts.join(" ");
711
712
 
712
- const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
713
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
713
714
  for (const step of setup) steps.push(setupStepDoc(step));
714
715
  for (const line of beforeScript) steps.push({ run: line });
715
716
  steps.push(
@@ -822,14 +823,14 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
822
823
  // it was before the field existed.
823
824
  if (doc.header && doc.header.length > 0) sections.push(doc.header.join("\n"));
824
825
  if (doc.name) sections.push("name: " + emitYAML(doc.name, 0));
825
- sections.push("on:" + emitYAML(doc.on, 1));
826
- if (doc.env && Object.keys(doc.env).length > 0) sections.push("env:" + emitYAML(doc.env, 1));
827
- sections.push("concurrency:" + emitYAML(doc.concurrency, 1));
828
- if (Object.keys(doc.permissions).length > 0) sections.push("permissions:" + emitYAML(doc.permissions, 1));
826
+ sections.push(emitYAMLEntry("on", doc.on));
827
+ if (doc.env && Object.keys(doc.env).length > 0) sections.push(emitYAMLEntry("env", doc.env));
828
+ sections.push(emitYAMLEntry("concurrency", doc.concurrency));
829
+ if (Object.keys(doc.permissions).length > 0) sections.push(emitYAMLEntry("permissions", doc.permissions));
829
830
  // The gated-apply notice job rides in `jobs:` beside the Op's own job, but
830
831
  // is carried separately on the doc so a dialect that cannot run it (forgejo,
831
832
  // whose runner has no `gh` pointed at its own instance) drops it by omission.
832
- sections.push("jobs:" + emitYAML({ ...doc.jobsDoc, ...(doc.gatedNoticeDoc ?? {}) }, 1));
833
+ sections.push(emitYAMLEntry("jobs", { ...doc.jobsDoc, ...(doc.gatedNoticeDoc ?? {}) }));
833
834
  return sections.join("\n\n") + "\n";
834
835
  }
835
836
 
@@ -104,7 +104,7 @@ describe("generateGithubPipeline: structurally valid YAML", () => {
104
104
  expect(job.steps.length).toBeGreaterThan(0);
105
105
  // Every job starts with a checkout step — a job runs on a fresh runner
106
106
  // with no repo present, unlike GitLab's auto-cloned workspace.
107
- expect(job.steps[0].uses).toBe("actions/checkout@v4");
107
+ expect(job.steps[0].uses).toBe("actions/checkout@v7");
108
108
  }
109
109
  });
110
110
 
@@ -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
+ });
@@ -56,9 +56,11 @@
56
56
  * touching the component declarations.
57
57
  */
58
58
 
59
- import { emitYAML } from "@intentius/chant/yaml";
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,
@@ -66,6 +68,7 @@ import type {
66
68
  ComponentPipelineResult as GenerateGithubResult,
67
69
  PipelineMember,
68
70
  } from "@intentius/chant/lexicon";
71
+ import { actionRef } from "../action-pins";
69
72
 
70
73
  export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
71
74
 
@@ -106,6 +109,140 @@ export interface GithubPipelineDoc {
106
109
  jobs: GeneratedJob[];
107
110
  }
108
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
+
109
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`). */
110
247
  function toJobName(componentName: string): string {
111
248
  return componentName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
@@ -173,6 +310,19 @@ export function buildGithubPipelineDoc(
173
310
  const { waves } = resolveComponentGraph(components);
174
311
  const byName = new Map(components.map((c) => [c.name, c]));
175
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
+
176
326
  // Components that something else depends on must hand their resolved outputs
177
327
  // (stack outputs, published artifact refs) to their dependents, which run as
178
328
  // separate jobs on separate runners. Each such producer dumps its outputs to
@@ -232,7 +382,7 @@ export function buildGithubPipelineDoc(
232
382
  // One step per script line — mirrors gitlab's `script:` array of
233
383
  // discrete shell lines, rather than a single multi-line `run:` block, so
234
384
  // each line is independently inspectable (and machine-parseable).
235
- const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
385
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
236
386
 
237
387
  for (const dep of component.dependsOn ?? []) {
238
388
  steps.push({
@@ -297,7 +447,7 @@ export function buildGithubPipelineDoc(
297
447
  for (const name of [...pinned].sort()) {
298
448
  command.push("--digest", `"${name}=\${{ needs.${jobNameByComponent.get(name)!}.outputs.digest }}"`);
299
449
  }
300
- const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
450
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
301
451
  for (const [name, paths] of archives) {
302
452
  steps.push({
303
453
  name: `Download ${name} build archive`,
@@ -375,21 +525,14 @@ function memberArtifactStep(step: Record<string, unknown>, member: PipelineMembe
375
525
  * Shared with the forgejo dialect (#969), which transforms the doc first.
376
526
  */
377
527
  export function emitPipelineYAML(doc: GithubPipelineDoc): string {
378
- // A block value starts with a newline; an inline one (`{}` for an empty
379
- // component set) needs the space after the colon, or `jobs:{}` is a plain
380
- // scalar rather than a key (#2991).
381
- const section = (key: string, value: unknown): string => {
382
- const emitted = emitYAML(value, 1);
383
- return `${key}:${emitted.startsWith("\n") ? "" : " "}${emitted}`;
384
- };
385
528
  const sections: string[] = [];
386
529
  sections.push("name: " + emitYAML(doc.name, 0));
387
- sections.push(section("on", doc.on));
530
+ sections.push(emitYAMLEntry("on", doc.on));
388
531
  if (doc.env && Object.keys(doc.env).length > 0) {
389
- sections.push(section("env", doc.env));
532
+ sections.push(emitYAMLEntry("env", doc.env));
390
533
  }
391
- if (doc.defaults) sections.push(section("defaults", doc.defaults));
392
- sections.push(section("jobs", doc.jobsDoc));
534
+ if (doc.defaults) sections.push(emitYAMLEntry("defaults", doc.defaults));
535
+ sections.push(emitYAMLEntry("jobs", doc.jobsDoc));
393
536
  return sections.join("\n\n") + "\n";
394
537
  }
395
538
 
@@ -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
+ });
@@ -1,5 +1,6 @@
1
1
  import { Composite, mergeDefaults } from "@intentius/chant";
2
2
  import { Step } from "../generated/index";
3
+ import { actionRef, type ActionPinMode } from "../action-pins";
3
4
 
4
5
  export interface CheckoutProps {
5
6
  ref?: string;
@@ -8,6 +9,12 @@ export interface CheckoutProps {
8
9
  token?: string;
9
10
  submodules?: boolean | string;
10
11
  sshKey?: string;
12
+ /**
13
+ * `"tag"` (default) emits the action's major tag. `"sha"` emits the commit
14
+ * SHA from the lexicon's pin table with the version as a YAML comment, which
15
+ * passes GHA021, GHA029 and GHA059.
16
+ */
17
+ pin?: ActionPinMode;
11
18
  defaults?: {
12
19
  step?: Partial<ConstructorParameters<typeof Step>[0]>;
13
20
  };
@@ -25,7 +32,7 @@ export const Checkout = Composite((props: CheckoutProps) => {
25
32
 
26
33
  const step = new Step(mergeDefaults({
27
34
  name: "Checkout",
28
- uses: "actions/checkout@v4",
35
+ uses: actionRef("actions/checkout", props.pin),
29
36
  ...(Object.keys(withObj).length > 0 ? { with: withObj } : {}),
30
37
  }, defaults?.step));
31
38
 
@@ -19,7 +19,7 @@ describe("Checkout", () => {
19
19
  test("returns step with checkout action", () => {
20
20
  const result = Checkout({});
21
21
  expect(result.step).toBeDefined();
22
- expect(result.step.props.uses).toBe("actions/checkout@v4");
22
+ expect(result.step.props.uses).toBe("actions/checkout@v7");
23
23
  expect(result.step.props.name).toBe("Checkout");
24
24
  });
25
25
 
@@ -45,7 +45,7 @@ describe("Checkout", () => {
45
45
  describe("SetupNode", () => {
46
46
  test("returns step with setup-node action", () => {
47
47
  const result = SetupNode({});
48
- expect(result.step.props.uses).toBe("actions/setup-node@v4");
48
+ expect(result.step.props.uses).toBe("actions/setup-node@v7");
49
49
  expect(result.step.props.name).toBe("Setup Node.js");
50
50
  });
51
51
 
@@ -540,7 +540,7 @@ describe("DeployEnvironment", () => {
540
540
  const result = DeployEnvironment({ name: "staging", deployScript: "npm run deploy" });
541
541
  const steps = result.deployJob.props.steps as Array<{ props: Record<string, unknown> }>;
542
542
  expect(steps.length).toBe(2);
543
- expect(steps[0].props.uses).toBe("actions/checkout@v4");
543
+ expect(steps[0].props.uses).toBe("actions/checkout@v7");
544
544
  expect(steps[1].props.run).toBe("npm run deploy");
545
545
  });
546
546
 
@@ -683,7 +683,7 @@ describe("defaults overrides", () => {
683
683
  defaults: { step: { id: "my-checkout" } },
684
684
  });
685
685
  expect(result.step.props.id).toBe("my-checkout");
686
- expect(result.step.props.uses).toBe("actions/checkout@v4");
686
+ expect(result.step.props.uses).toBe("actions/checkout@v7");
687
687
  });
688
688
 
689
689
  test("SetupNode defaults override step props", () => {
@@ -1,5 +1,6 @@
1
1
  import { Composite, mergeDefaults } from "@intentius/chant";
2
2
  import { Job, Step } from "../generated/index";
3
+ import { actionRef } from "../action-pins";
3
4
 
4
5
  export interface DeployEnvironmentProps {
5
6
  /** Environment name. Required. */
@@ -40,7 +41,7 @@ export const DeployEnvironment = Composite((props: DeployEnvironmentProps) => {
40
41
 
41
42
  // ── Deploy job ─────────────────────────────────────────────────────
42
43
  const deploySteps = [
43
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
44
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
44
45
  ...deployScriptArr.map(
45
46
  (cmd: string) => new Step({ name: "Deploy", run: cmd }),
46
47
  ),
@@ -63,7 +64,7 @@ export const DeployEnvironment = Composite((props: DeployEnvironmentProps) => {
63
64
 
64
65
  // ── Cleanup job ────────────────────────────────────────────────────
65
66
  const cleanupSteps = [
66
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
67
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
67
68
  ...cleanupScriptArr.map(
68
69
  (cmd: string) => new Step({ name: "Cleanup", run: cmd }),
69
70
  ),
@@ -1,5 +1,6 @@
1
1
  import { Composite, mergeDefaults } from "@intentius/chant";
2
2
  import { Job, Step, Workflow } from "../generated/index";
3
+ import { actionRef } from "../action-pins";
3
4
 
4
5
  export interface DockerBuildProps {
5
6
  /** Image tag. Default: "${{ github.sha }}" */
@@ -44,7 +45,7 @@ export const DockerBuild = Composite((props: DockerBuildProps) => {
44
45
  } = props;
45
46
 
46
47
  // ── Steps ──────────────────────────────────────────────────────────
47
- const checkout = new Step({ name: "Checkout", uses: "actions/checkout@v4" });
48
+ const checkout = new Step({ name: "Checkout", uses: actionRef("actions/checkout") });
48
49
 
49
50
  const login = new Step({
50
51
  name: "Log in to container registry",
@@ -1,5 +1,6 @@
1
1
  import { Composite, mergeDefaults } from "@intentius/chant";
2
2
  import { Job, Step, Workflow } from "../generated/index";
3
+ import { actionRef } from "../action-pins";
3
4
 
4
5
  export interface GoCIProps {
5
6
  /** Go version. Default: "1.22" */
@@ -34,7 +35,7 @@ export const GoCI = Composite((props: GoCIProps) => {
34
35
  const buildJob = new Job(mergeDefaults({
35
36
  "runs-on": runsOn,
36
37
  steps: [
37
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
38
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
38
39
  new Step({
39
40
  name: "Setup Go",
40
41
  uses: "actions/setup-go@v5",
@@ -48,7 +49,7 @@ export const GoCI = Composite((props: GoCIProps) => {
48
49
  const testJob = new Job(mergeDefaults({
49
50
  "runs-on": runsOn,
50
51
  steps: [
51
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
52
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
52
53
  new Step({
53
54
  name: "Setup Go",
54
55
  uses: "actions/setup-go@v5",
@@ -64,7 +65,7 @@ export const GoCI = Composite((props: GoCIProps) => {
64
65
  ? new Job(mergeDefaults({
65
66
  "runs-on": runsOn,
66
67
  steps: [
67
- new Step({ name: "Checkout", uses: "actions/checkout@v4" }),
68
+ new Step({ name: "Checkout", uses: actionRef("actions/checkout") }),
68
69
  new Step({
69
70
  name: "Setup Go",
70
71
  uses: "actions/setup-go@v5",