@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.
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/__fixtures__/pr-loop.github.golden.yml +68 -0
- package/src/components/generate-pipeline.test.ts +29 -0
- package/src/components/generate-pipeline.ts +149 -0
- package/src/components/generate-pr-pipeline.test.ts +98 -0
|
@@ -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;
|
|
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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
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": "
|
|
81
|
+
"composite": "4120e5c5daf8c9e06b7a2702a881c96d81964046b4723c1b036ac52fe54b5826"
|
|
82
82
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-github",
|
|
3
|
-
"version": "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.
|
|
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
|
+
});
|