@intentius/chant-lexicon-github 0.124.40 → 0.124.41

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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * A pull request's environment in the generated GitHub workflow (#4298):
3
+ * `env-up` on an opened, updated or reopened pull request, `env-post` after
4
+ * it, `env-down` when it closes, `env-sweep` on the schedule, and a plan job
5
+ * that skips the close.
6
+ *
7
+ * `env-up` runs the pull request's code, so it follows the handover (#4295):
8
+ * only its checkout sees the job's token, every later step starts without
9
+ * the token variables, and the `pr-env up` step is last and hands its report
10
+ * and record on. The behaviour test runs the generated steps with `sh`, as a
11
+ * forge runs them, with every token variable set.
12
+ */
13
+
14
+ import { execFileSync, spawnSync } from "node:child_process";
15
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { afterAll, describe, expect, test } from "vitest";
19
+ import { parseYAML } from "@intentius/chant/yaml";
20
+ import type { DriverComponent } from "@intentius/chant/components/driver";
21
+ import { dropForgeTokens, FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
22
+ import { PR_ENV_SWEEP_CRON } from "@intentius/chant/components/pr-pipeline";
23
+ import { generateGithubPipeline } from "./generate-pipeline";
24
+
25
+ const ESTATE: DriverComponent[] = [{ name: "app", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "kubectl-apply", manifest: "app.yaml" } as never] }] }];
26
+
27
+ interface Step {
28
+ name?: string;
29
+ id?: string;
30
+ if?: string;
31
+ uses?: string;
32
+ run?: string;
33
+ env?: Record<string, string>;
34
+ with?: Record<string, unknown>;
35
+ }
36
+ interface Job {
37
+ if: string;
38
+ needs?: string[];
39
+ permissions: Record<string, string>;
40
+ outputs?: Record<string, string>;
41
+ concurrency: { group: string; "cancel-in-progress": boolean };
42
+ steps: Step[];
43
+ }
44
+
45
+ const dirs: string[] = [];
46
+ afterAll(() => {
47
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
48
+ });
49
+
50
+ const SECRET = "ghs_generated_env_secret_4298";
51
+ const TOKENS = Object.fromEntries(FORGE_TOKEN_VARS.map((v) => [v, `${SECRET}_${v}`]));
52
+ const BASE = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" };
53
+ const UP_LINE = /^chant components pr-env up .*$/m;
54
+
55
+ function hasActionlint(): boolean {
56
+ try {
57
+ execFileSync("actionlint", ["-version"], { stdio: "ignore" });
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function workflow(): { yaml: string; jobs: string[]; doc: { on: Record<string, unknown>; jobs: Record<string, Job> } } {
65
+ const result = generateGithubPipeline(ESTATE, { env: "staging", image: "example/ci:1", beforeScript: ["npm ci"], extraScript: ["echo after"], prLoop: { prEnvironment: true, build: true } });
66
+ return { yaml: result.yaml, jobs: result.jobs.map((j) => j.jobName), doc: parseYAML(result.yaml) as { on: Record<string, unknown>; jobs: Record<string, Job> } };
67
+ }
68
+
69
+ describe("the GitHub pull-request workflow with a pull request environment", () => {
70
+ const { yaml, jobs, doc } = workflow();
71
+
72
+ test("the pull_request trigger fires on close too, and a schedule starts the sweep", () => {
73
+ expect(doc.on.pull_request).toEqual({ branches: ["main"], types: ["opened", "synchronize", "reopened", "closed"] });
74
+ expect(doc.on.schedule).toEqual([{ cron: PR_ENV_SWEEP_CRON }]);
75
+ expect(doc.jobs.plan!.if).toBe("github.event_name == 'pull_request' && github.event.action != 'closed'");
76
+ expect(jobs).toEqual(expect.arrayContaining(["env-up", "env-post", "env-down", "env-sweep"]));
77
+ });
78
+
79
+ test("env-up plans pr-<n> with no forge and no push; env-down tears it down from the base branch", () => {
80
+ const up = doc.jobs["env-up"]!;
81
+ const down = doc.jobs["env-down"]!;
82
+ expect(up.if).toBe("github.event_name == 'pull_request' && github.event.action != 'closed'");
83
+ expect(down.if).toBe("github.event_name == 'pull_request' && github.event.action == 'closed'");
84
+ const last = up.steps.at(-1)!;
85
+ expect(last.run).toMatch(UP_LINE);
86
+ expect(last.run!.match(UP_LINE)![0]).toBe('chant components pr-env up --base "$BASE_SHA" --pr "$PR_NUMBER" --output .chant/pr-env --no-push');
87
+ expect(last.env?.CHANT_ENV).toBe("pr-${{ github.event.pull_request.number }}");
88
+ const downRun = down.steps.find((s) => s.run?.startsWith("chant components pr-env"))!;
89
+ expect(downRun.run).toBe('chant components pr-env down --pr "$PR_NUMBER" --reason closed --forge github --output .chant/pr-env');
90
+ expect(down.steps[0]!.with?.ref).toBe("${{ github.event.pull_request.base.ref }}");
91
+ expect(up.concurrency).toEqual({ group: "chant-pr-env-${{ github.event.pull_request.number }}", "cancel-in-progress": false });
92
+ expect(down.concurrency).toEqual(up.concurrency);
93
+ expect(doc.jobs["env-sweep"]!.if).toBe("github.event_name == 'schedule'");
94
+ });
95
+
96
+ test("without the option nothing changes", () => {
97
+ const plain = parseYAML(generateGithubPipeline(ESTATE, { env: "staging", prLoop: {} }).yaml) as { on: Record<string, unknown>; jobs: Record<string, Job> };
98
+ expect(plain.on.pull_request).toEqual({ branches: ["main"] });
99
+ expect(plain.jobs["env-up"]).toBeUndefined();
100
+ });
101
+
102
+ test.skipIf(!hasActionlint())("passes actionlint", () => {
103
+ const dir = mkdtempSync(join(tmpdir(), "chant-actionlint-"));
104
+ dirs.push(dir);
105
+ mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
106
+ writeFileSync(join(dir, ".github", "workflows", "chant-pr.yml"), yaml);
107
+ expect(() => execFileSync("actionlint", [".github/workflows/chant-pr.yml"], { cwd: dir, encoding: "utf8" })).not.toThrow();
108
+ });
109
+ });
110
+
111
+ describe("env-up holds no forge token while the pull request's code runs (#4295)", () => {
112
+ const { doc } = workflow();
113
+ const up = doc.jobs["env-up"]!;
114
+ const post = doc.jobs["env-post"]!;
115
+
116
+ test("only the checkout step is given the job's token, and only for its fetches", () => {
117
+ const [checkout, ...rest] = up.steps;
118
+ expect(checkout!.uses).toBeUndefined();
119
+ expect(checkout!.env).toEqual({ CHANT_CHECKOUT_TOKEN: "${{ github.token }}" });
120
+ for (const step of rest) {
121
+ expect(JSON.stringify(step)).not.toContain("github.token");
122
+ for (const name of FORGE_TOKEN_VARS) expect(step.env ?? {}).not.toHaveProperty(name);
123
+ }
124
+ expect(up.permissions).toEqual({ contents: "read" });
125
+ });
126
+
127
+ test("every step after the checkout starts by dropping the token variables, and none is an action", () => {
128
+ for (const step of up.steps.slice(1)) {
129
+ expect(step.uses).toBeUndefined();
130
+ expect(step.run!.split("\n")[0]).toBe(dropForgeTokens("sh"));
131
+ }
132
+ });
133
+
134
+ test("the pr-env up step is the job's last and hands its report on as the job's outputs", () => {
135
+ const last = up.steps.at(-1)!;
136
+ expect(last.id).toBe("env-up");
137
+ expect(last.run!.trimEnd().split("\n").at(-1)).toBe("echo after");
138
+ expect(up.outputs).toEqual({ report_0: "${{ steps.env-up.outputs.report_0 }}" });
139
+ });
140
+
141
+ test("env-post checks out the target branch's commit, and only it can write the record, the note and the status", () => {
142
+ expect(post.needs).toEqual(["env-up"]);
143
+ expect(post.if).toContain("needs.env-up.result == 'failure'");
144
+ expect(post.steps[0]).toMatchObject({ uses: expect.stringMatching(/^actions\/checkout@/), with: { ref: "${{ github.event.pull_request.base.sha }}" } });
145
+ expect(post.permissions).toEqual({ contents: "write", "pull-requests": "write", statuses: "write" });
146
+ const last = post.steps.at(-1)!;
147
+ expect(last.env).toMatchObject({ GITHUB_TOKEN: "${{ github.token }}", HEAD_SHA: "${{ github.sha }}", UP_RESULT: "${{ needs.env-up.result }}" });
148
+ expect(last.run).toBe('chant components pr-env post --pr "$PR_NUMBER" --head "$HEAD_SHA" --forge github --output .chant/pr-env --result "$UP_RESULT"');
149
+ // The post job runs no build, no lint and no pr-env up: none of the change's code.
150
+ expect(JSON.stringify(post)).not.toMatch(/chant (build|lint)|pr-env up/);
151
+ });
152
+ });
153
+
154
+ /** Run one generated step as a forge does: its script as a file, `sh -e`, with the job's tokens in the environment. */
155
+ function runStep(step: Step, cwd: string, env: Record<string, string>): number | null {
156
+ const file = join(cwd, "..", `step-${Math.random().toString(36).slice(2)}.sh`);
157
+ writeFileSync(file, step.run!);
158
+ return spawnSync("sh", ["-e", file], { cwd, env: { ...BASE, ...TOKENS, ...env }, encoding: "utf-8" }).status;
159
+ }
160
+
161
+ describe("env-up's steps, run, see no forge token and hand the report and record on (#4295)", () => {
162
+ const { doc } = workflow();
163
+
164
+ test("each step after the checkout, and every process it starts, sees none of the token variables", () => {
165
+ const top = mkdtempSync(join(tmpdir(), "chant-generated-env-step-"));
166
+ dirs.push(top);
167
+ const work = join(top, "work");
168
+ mkdirSync(work);
169
+ const seen = join(top, "seen");
170
+ mkdirSync(seen);
171
+ const output = join(top, "github-output");
172
+ const steps = doc.jobs["env-up"]!.steps.slice(1);
173
+ steps.forEach((step, i) => {
174
+ const probe = `env > "${seen}/${i}.env"; sh -c 'env' >> "${seen}/${i}.env"`;
175
+ const report = "mkdir -p .chant/pr-env && echo '{\"stage\":\"up\"}' > .chant/pr-env/pr-env-up.json && echo '{\"kind\":\"up\"}' > .chant/pr-env/pr-env-record.json";
176
+ const lines = step.run!.split("\n");
177
+ const handover = lines.filter((l) => l.startsWith("chant_report=") || l.startsWith("chant_handover") || l.startsWith("trap "));
178
+ const body = [lines[0], ...handover, probe, ...(UP_LINE.test(step.run!) ? [report] : [])].join("\n");
179
+ expect(runStep({ run: body }, work, { GITHUB_OUTPUT: output, BASE_SHA: "b".repeat(40), PR_NUMBER: "7" }), step.name ?? lines[1]).toBe(0);
180
+ });
181
+ for (let i = 0; i < steps.length; i++) expect(readFileSync(join(seen, `${i}.env`), "utf-8")).not.toContain(SECRET);
182
+ expect(readFileSync(output, "utf-8")).toMatch(/^report_0=\S+$/m);
183
+ });
184
+
185
+ test("env-post's take-over step writes the report and the record back from that output", () => {
186
+ const top = mkdtempSync(join(tmpdir(), "chant-generated-env-post-"));
187
+ dirs.push(top);
188
+ const work = join(top, "work");
189
+ mkdirSync(join(work, ".chant", "pr-env"), { recursive: true });
190
+ writeFileSync(join(work, ".chant", "pr-env", "pr-env-up.json"), '{"stage":"up"}\n');
191
+ writeFileSync(join(work, ".chant", "pr-env", "pr-env-record.json"), '{"kind":"up"}\n');
192
+ const output = join(top, "github-output");
193
+ const upStep = doc.jobs["env-up"]!.steps.at(-1)!;
194
+ const handOn = upStep.run!.split("\n").filter((l, n) => n === 0 || l.startsWith("chant_report=") || l.startsWith("chant_handover") || l.startsWith("trap ")).join("\n");
195
+ expect(runStep({ run: handOn }, work, { GITHUB_OUTPUT: output })).toBe(0);
196
+ const value = readFileSync(output, "utf-8").trim().slice("report_0=".length);
197
+
198
+ const postWork = join(top, "post");
199
+ mkdirSync(postWork);
200
+ const take = doc.jobs["env-post"]!.steps.find((s) => s.name === "Take the environment's report from the env-up job")!;
201
+ const filled = take.run!.replace("${{ toJSON(needs.env-up.outputs.report_0) }}", JSON.stringify(value));
202
+ expect(runStep({ run: filled }, postWork, {})).toBe(0);
203
+ expect(existsSync(join(postWork, ".chant", "pr-env", "pr-env-record.json"))).toBe(true);
204
+ expect(readFileSync(join(postWork, ".chant", "pr-env", "pr-env-up.json"), "utf-8")).toBe('{"stage":"up"}\n');
205
+ });
206
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * #4295: in the generated pull-request workflow, no step that runs the pull
3
+ * request's code holds a forge token. The plan job uses the job's token for
4
+ * its checkout's fetches alone, every later step starts without the token
5
+ * variables, and the plan step is the job's last and hands its report on as
6
+ * the job's outputs. The plan-note job, which runs none of the change's
7
+ * code, posts.
8
+ *
9
+ * The behaviour tests run the generated steps with `sh`, as a forge runs
10
+ * them, with every token variable set, and `chant ci run plan` replaced by a
11
+ * line that prints what the step can see.
12
+ */
13
+
14
+ import { spawnSync } from "node:child_process";
15
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { afterAll, describe, expect, test } from "vitest";
19
+ import { parseYAML } from "@intentius/chant/yaml";
20
+ import type { DriverComponent } from "@intentius/chant/components/driver";
21
+ import { dropForgeTokens, FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
22
+ import { generateGithubPipeline } from "./generate-pipeline";
23
+
24
+ const ESTATE: DriverComponent[] = [
25
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
26
+ { name: "app", dependsOn: ["net"], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "app" }] }] },
27
+ ];
28
+
29
+ interface Step {
30
+ name?: string;
31
+ id?: string;
32
+ if?: string;
33
+ uses?: string;
34
+ run?: string;
35
+ env?: Record<string, string>;
36
+ with?: Record<string, unknown>;
37
+ }
38
+ interface Job {
39
+ needs?: string[];
40
+ if?: string;
41
+ permissions?: Record<string, string>;
42
+ outputs?: Record<string, string>;
43
+ steps: Step[];
44
+ }
45
+
46
+ const dirs: string[] = [];
47
+ afterAll(() => {
48
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
49
+ });
50
+
51
+ const SECRET = "ghs_generated_job_secret_4295";
52
+ const TOKENS = Object.fromEntries(FORGE_TOKEN_VARS.map((v) => [v, `${SECRET}_${v}`]));
53
+ const BASE = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" };
54
+ const PLAN_LINE = /^chant ci run plan .*$/m;
55
+
56
+ /** The pipeline with a build, a before-script line and an after-script line, so every kind of step is there. */
57
+ function workflow(extra: Record<string, unknown> = {}): { jobs: Record<string, Job> } {
58
+ const { yaml } = generateGithubPipeline(ESTATE, { env: "prod", image: "example/ci:1", beforeScript: ["npm ci"], extraScript: ["echo after"], prLoop: { build: true, ...extra } });
59
+ return parseYAML(yaml) as { jobs: Record<string, Job> };
60
+ }
61
+
62
+ describe("the generated plan job holds no forge token while the change's code runs (#4295)", () => {
63
+ const doc = workflow();
64
+ const plan = doc.jobs.plan!;
65
+ const note = doc.jobs["plan-note"]!;
66
+
67
+ test("only the checkout step is given the job's token, and only for its fetches", () => {
68
+ const [checkout, ...rest] = plan.steps;
69
+ expect(checkout!.uses).toBeUndefined();
70
+ expect(checkout!.env).toEqual({ CHANT_CHECKOUT_TOKEN: "${{ github.token }}" });
71
+ for (const step of rest) {
72
+ expect(JSON.stringify(step)).not.toContain("github.token");
73
+ for (const name of FORGE_TOKEN_VARS) expect(step.env ?? {}).not.toHaveProperty(name);
74
+ }
75
+ // The token itself can read the repository and nothing else.
76
+ expect(plan.permissions).toEqual({ contents: "read" });
77
+ });
78
+
79
+ test("every step after the checkout starts by dropping the token variables, and none is an action", () => {
80
+ for (const step of plan.steps.slice(1)) {
81
+ expect(step.uses).toBeUndefined();
82
+ expect(step.run!.split("\n")[0]).toBe(dropForgeTokens("sh"));
83
+ }
84
+ });
85
+
86
+ test("the plan step is the job's last: no upload, cache or checkout post step runs after the change's code", () => {
87
+ const last = plan.steps.at(-1)!;
88
+ expect(last.id).toBe("plan");
89
+ expect(last.run).toMatch(PLAN_LINE);
90
+ // The after-script lines run inside it, not as steps of their own.
91
+ expect(last.run!.trimEnd().split("\n").at(-1)).toBe("echo after");
92
+ expect(plan.outputs).toEqual({ report_0: "${{ steps.plan.outputs.report_0 }}" });
93
+ });
94
+
95
+ test("the plan-note job checks out the target branch's commit, and only it can write the note and the status", () => {
96
+ expect(note.needs).toEqual(["plan"]);
97
+ expect(note.if).toContain("needs.plan.result == 'failure'");
98
+ expect(note.steps[0]).toMatchObject({ uses: expect.stringMatching(/^actions\/checkout@/), with: { ref: "${{ github.event.pull_request.base.sha }}" } });
99
+ expect(note.permissions).toEqual({ actions: "read", contents: "read", "pull-requests": "write", statuses: "write" });
100
+ const post = note.steps.at(-1)!;
101
+ expect(post.env).toMatchObject({ GITHUB_TOKEN: "${{ github.token }}", HEAD_SHA: "${{ github.sha }}", PLAN_RESULT: "${{ needs.plan.result }}" });
102
+ expect(post.run).toBe('chant components pr-post --pr "$PR_NUMBER" --head "$HEAD_SHA" --env prod --output .chant/pr --forge github --result "$PLAN_RESULT"');
103
+ // The note job runs no build, no lint and no plan: none of the change's code.
104
+ expect(JSON.stringify(note)).not.toMatch(/chant (build|lint|ci run)/);
105
+ });
106
+ });
107
+
108
+ /** Run one generated step as a forge does: its script as a file, `sh -e`, with the job's tokens in the environment. */
109
+ function runStep(step: Step, cwd: string, env: Record<string, string>): number | null {
110
+ const file = join(cwd, "..", `step-${Math.random().toString(36).slice(2)}.sh`);
111
+ writeFileSync(file, step.run!);
112
+ return spawnSync("sh", ["-e", file], { cwd, env: { ...BASE, ...TOKENS, ...env }, encoding: "utf-8" }).status;
113
+ }
114
+
115
+ describe("the generated steps, run, see no forge token and hand the report on (#4295)", () => {
116
+ const doc = workflow();
117
+
118
+ test("each step after the checkout, and every process it starts, sees none of the token variables", () => {
119
+ const top = mkdtempSync(join(tmpdir(), "chant-generated-step-"));
120
+ dirs.push(top);
121
+ const work = join(top, "work");
122
+ mkdirSync(work);
123
+ const seen = join(top, "seen");
124
+ mkdirSync(seen);
125
+ const output = join(top, "github-output");
126
+ const steps = doc.jobs.plan!.steps.slice(1);
127
+ steps.forEach((step, i) => {
128
+ // The step's own lines become a probe; the plan line also writes a report, as pr-plan would.
129
+ const probe = `env > "${seen}/${i}.env"; sh -c 'env' >> "${seen}/${i}.env"`;
130
+ const report = 'mkdir -p .chant/pr && echo \'{"stage":"plan"}\' > .chant/pr/pr-plan.json && echo note > .chant/pr/pr-note.md';
131
+ // The generated drop line and hand-on lines run as they are; the step's own commands become the probe.
132
+ const lines = step.run!.split("\n");
133
+ const handover = lines.filter((l) => l.startsWith("chant_report=") || l.startsWith("chant_handover") || l.startsWith("trap "));
134
+ const body = [lines[0], ...handover, probe, ...(PLAN_LINE.test(step.run!) ? [report] : [])].join("\n");
135
+ const status = runStep({ run: body }, work, { GITHUB_OUTPUT: output, BASE_SHA: "b".repeat(40), PR_NUMBER: "7" });
136
+ expect(status, step.name ?? step.run!.split("\n")[1]).toBe(0);
137
+ });
138
+ for (let i = 0; i < steps.length; i++) {
139
+ const env = readFileSync(join(seen, `${i}.env`), "utf-8");
140
+ expect(env).not.toContain(SECRET);
141
+ }
142
+ // The plan step handed its report on as the job's output.
143
+ expect(readFileSync(output, "utf-8")).toMatch(/^report_0=\S+$/m);
144
+ });
145
+
146
+ test("the note job's take-over step writes the plan step's report back from that output", () => {
147
+ const top = mkdtempSync(join(tmpdir(), "chant-generated-note-"));
148
+ dirs.push(top);
149
+ const work = join(top, "work");
150
+ mkdirSync(join(work, ".chant", "pr"), { recursive: true });
151
+ writeFileSync(join(work, ".chant", "pr", "pr-plan.json"), '{"stage":"plan"}\n');
152
+ const output = join(top, "github-output");
153
+ const planStep = doc.jobs.plan!.steps.at(-1)!;
154
+ const handOn = planStep.run!.split("\n").filter((l, n) => n === 0 || l.startsWith("chant_report=") || l.startsWith("chant_handover") || l.startsWith("trap ")).join("\n");
155
+ expect(runStep({ run: handOn }, work, { GITHUB_OUTPUT: output })).toBe(0);
156
+ const value = readFileSync(output, "utf-8").trim().slice("report_0=".length);
157
+
158
+ const noteWork = join(top, "note");
159
+ mkdirSync(noteWork);
160
+ const take = doc.jobs["plan-note"]!.steps.find((s) => s.name === "Take the plan report from the plan job")!;
161
+ // The forge fills the expression with toJSON of the output: a JSON string on one line.
162
+ const filled = take.run!.replace("${{ toJSON(needs.plan.outputs.report_0) }}", JSON.stringify(value));
163
+ expect(runStep({ run: filled }, noteWork, {})).toBe(0);
164
+ expect(existsSync(join(noteWork, ".chant", "pr", "pr-plan.json"))).toBe(true);
165
+ expect(readFileSync(join(noteWork, ".chant", "pr", "pr-plan.json"), "utf-8")).toBe('{"stage":"plan"}\n');
166
+ });
167
+ });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * #4296: the pull-request workflow's plan job trades its OIDC token for the
3
+ * plan identity before the change is checked out, and the change's code
4
+ * never holds a token, a way to request one, or the apply identity.
5
+ *
6
+ * The behaviour tests run the generated steps with `sh`, as a forge runner
7
+ * does: the trade step against a local emulator of the token URL and the
8
+ * clouds (@intentius/chant/__fixtures__/oidc-emulator), then each later
9
+ * step with the runner's token request variables still set and what the
10
+ * trade left in GITHUB_ENV, its own commands replaced by a probe that
11
+ * prints what it sees and tries to request a token. Test values only.
12
+ */
13
+
14
+ import { execFile } from "node:child_process";
15
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { promisify } from "node:util";
19
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
20
+ import { parseYAML } from "@intentius/chant/yaml";
21
+ import type { DriverComponent } from "@intentius/chant/components/driver";
22
+ import { dropForgeTokens, FORGE_TOKEN_VARS } from "@intentius/chant/ci-handover";
23
+ import { OIDC_REQUEST_VARS, resolveCloudIdentity, TRADE_SPEC_ENV, tradeIdentities, type PipelineCloudIdentity } from "@intentius/chant/ci-identity";
24
+ import { emulatedCredentials, EMULATED_JWT_PREFIX, EMULATED_REQUEST_TOKEN, startOidcEmulator, type OidcEmulator } from "@intentius/chant/__fixtures__/oidc-emulator";
25
+ import { generateGithubPipeline } from "./generate-pipeline";
26
+
27
+ const run = promisify(execFile);
28
+
29
+ const ESTATE: DriverComponent[] = [
30
+ { name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
31
+ { name: "app", dependsOn: ["net"], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "app" }] }] },
32
+ ];
33
+
34
+ const PLAN_ROLE = "arn:aws:iam::111111111111:role/prod-plan";
35
+ const APPLY_ROLE = "arn:aws:iam::111111111111:role/prod-apply";
36
+ const IDENTITY = resolveCloudIdentity(
37
+ {
38
+ aws: { rules: [{ environments: ["prod"], plan: PLAN_ROLE, apply: APPLY_ROLE }] },
39
+ gcp: { provider: "projects/123/locations/global/workloadIdentityPools/ci/providers/forge", rules: [{ plan: "plan@test.iam.gserviceaccount.com", apply: "apply@test.iam.gserviceaccount.com" }] },
40
+ azure: { tenantId: "test-tenant", rules: [{ plan: "plan-client", apply: "apply-client" }] },
41
+ },
42
+ "prod",
43
+ )!;
44
+ const APPLY_IDENTITIES = tradeIdentities(IDENTITY.apply);
45
+
46
+ interface Step {
47
+ name?: string;
48
+ id?: string;
49
+ uses?: string;
50
+ run?: string;
51
+ env?: Record<string, string>;
52
+ "working-directory"?: string;
53
+ }
54
+ interface Job {
55
+ permissions?: Record<string, string>;
56
+ steps: Step[];
57
+ }
58
+
59
+ function workflow(identity: PipelineCloudIdentity | undefined, extra: Record<string, unknown> = {}): { jobs: Record<string, Job> } {
60
+ const { yaml } = generateGithubPipeline(ESTATE, {
61
+ env: "prod",
62
+ image: "example/ci:1",
63
+ beforeScript: ["npm ci"],
64
+ extraScript: ["echo after"],
65
+ prLoop: { build: true, applyOnMerge: true, ...(identity ? { identity } : {}), ...extra },
66
+ });
67
+ return parseYAML(yaml) as { jobs: Record<string, Job> };
68
+ }
69
+
70
+ const TRADE_NAME = /^Trade the job's OIDC token for the (plan|apply) identity$/;
71
+ const tradeOf = (job: Job): Record<string, unknown> => JSON.parse(job.steps[0]!.env![TRADE_SPEC_ENV]!) as Record<string, unknown>;
72
+
73
+ describe("the generated jobs' cloud identities (#4296)", () => {
74
+ const doc = workflow(IDENTITY);
75
+ const plan = doc.jobs.plan!;
76
+
77
+ test("the plan job's first step trades for the plan identity, before the checkout", () => {
78
+ expect(plan.steps[0]!.name).toMatch(TRADE_NAME);
79
+ expect(plan.steps[0]!.name).toContain("plan identity");
80
+ expect(plan.steps[0]!.uses).toBeUndefined();
81
+ expect(plan.steps[0]!.run).toMatch(/^\(cd \/ && node -e '/);
82
+ expect(plan.steps[1]!.name).toBe("Check out the change");
83
+ expect(tradeOf(plan)).toEqual(IDENTITY.plan);
84
+ expect(plan.permissions).toEqual({ contents: "read", "id-token": "write" });
85
+ });
86
+
87
+ test("the plan job names no apply identity anywhere", () => {
88
+ const text = JSON.stringify(plan);
89
+ for (const id of APPLY_IDENTITIES) expect(text).not.toContain(id);
90
+ });
91
+
92
+ test("every step after the checkout starts by dropping the forge tokens and the OIDC request variables", () => {
93
+ for (const step of plan.steps.slice(2)) expect(step.run!.split("\n")[0]).toBe(dropForgeTokens("sh", OIDC_REQUEST_VARS));
94
+ });
95
+
96
+ test("the record job trades for the plan identity and the apply job for the apply identity, each before its checkout", () => {
97
+ const record = doc.jobs.record!;
98
+ const apply = doc.jobs.apply!;
99
+ expect(record.steps[0]!.name).toContain("plan identity");
100
+ expect(tradeOf(record)).toEqual(IDENTITY.plan);
101
+ expect(apply.steps[0]!.name).toContain("apply identity");
102
+ expect(tradeOf(apply)).toEqual(IDENTITY.apply);
103
+ for (const job of [record, apply]) {
104
+ expect(job.steps[1]!.uses).toMatch(/^actions\/checkout@/);
105
+ expect(job.permissions!["id-token"]).toBe("write");
106
+ }
107
+ expect(JSON.stringify(record)).not.toContain(APPLY_ROLE);
108
+ });
109
+
110
+ test("with a gate per wave, every wave's apply job trades for the apply identity", () => {
111
+ const waves = workflow(IDENTITY, { waveGate: "prod" }).jobs;
112
+ const names = Object.keys(waves).filter((n) => n.startsWith("apply-wave-"));
113
+ expect(names.length).toBe(2);
114
+ for (const n of names) expect(tradeOf(waves[n]!)).toEqual(IDENTITY.apply);
115
+ });
116
+
117
+ test("the plan-note job requests no token and trades nothing", () => {
118
+ const note = doc.jobs["plan-note"]!;
119
+ expect(note.permissions!["id-token"]).toBeUndefined();
120
+ expect(JSON.stringify(note)).not.toContain(TRADE_SPEC_ENV);
121
+ });
122
+
123
+ test("without identity no job requests a token, and the plan job is as #4295 left it", () => {
124
+ const plain = workflow(undefined);
125
+ for (const job of Object.values(plain.jobs)) {
126
+ expect(job.permissions?.["id-token"]).toBeUndefined();
127
+ expect(job.steps.some((s) => TRADE_NAME.test(s.name ?? ""))).toBe(false);
128
+ }
129
+ expect(plain.jobs.plan!.steps[1]!.run!.split("\n")[0]).toBe(dropForgeTokens("sh"));
130
+ });
131
+ });
132
+
133
+ const dirs: string[] = [];
134
+ let emu: OidcEmulator;
135
+ beforeAll(async () => {
136
+ emu = await startOidcEmulator();
137
+ });
138
+ afterAll(async () => {
139
+ await emu.close();
140
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
141
+ });
142
+
143
+ const BASE = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" };
144
+ const FORGE_SECRET = "ghs_identity_secret_4296";
145
+ const TOKENS = Object.fromEntries(FORGE_TOKEN_VARS.map((v) => [v, `${FORGE_SECRET}_${v}`]));
146
+
147
+ /** Run one step's script as a forge does: a file, `sh -e`. */
148
+ async function runStep(script: string, cwd: string, env: Record<string, string>): Promise<{ code: number; stdout: string; stderr: string }> {
149
+ const file = join(cwd, "..", `step-${Math.random().toString(36).slice(2)}.sh`);
150
+ writeFileSync(file, script);
151
+ try {
152
+ const r = await run("sh", ["-e", file], { cwd, env: { ...BASE, ...env } });
153
+ return { code: 0, stdout: r.stdout, stderr: r.stderr };
154
+ } catch (e) {
155
+ const err = e as { code: number; stdout: string; stderr: string };
156
+ return { code: err.code, stdout: err.stdout, stderr: err.stderr };
157
+ }
158
+ }
159
+
160
+ /** GITHUB_ENV's `NAME=value` lines as the runner applies them to the job's later steps. */
161
+ const githubEnv = (text: string): Record<string, string> =>
162
+ Object.fromEntries(text.split("\n").filter((l) => l.includes("=")).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
163
+
164
+ /** A probe: what the step's shell and a child see, and whether a request for an apply-audience token succeeds. */
165
+ const PROBE = [
166
+ 'env > "$SEEN"',
167
+ `sh -c 'env' >> "$SEEN"`,
168
+ `node -e 'const u=process.env.ACTIONS_ID_TOKEN_REQUEST_URL,k=process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;if(!u||!k){console.log("no-request-vars");process.exit(0)}fetch(u+"&audience=sts.amazonaws.com",{headers:{authorization:"bearer "+k}}).then(r=>r.text()).then(t=>console.log("minted:"+t))' >> "$SEEN"`,
169
+ ].join("\n");
170
+
171
+ describe("the generated plan job, run: the change's code never holds a token or the apply identity (#4296)", () => {
172
+ test("the trade mints and trades for the plan identity alone; every later step sees plan credentials and nothing to request a token with", async () => {
173
+ const identity: PipelineCloudIdentity = { plan: emu.pointAt(IDENTITY.plan), apply: emu.pointAt(IDENTITY.apply) };
174
+ const plan = workflow(identity).jobs.plan!;
175
+ const top = mkdtempSync(join(tmpdir(), "chant-identity-job-"));
176
+ dirs.push(top);
177
+ const work = join(top, "work");
178
+ mkdirSync(work);
179
+ const envFile = join(top, "github-env");
180
+ writeFileSync(envFile, "");
181
+ // The runner gives every step of a job with id-token: write the request variables, and the forge tokens.
182
+ const runner = { ...emu.requestEnv, ...TOKENS, GITHUB_ENV: envFile };
183
+ const before = { minted: emu.log.minted.length, assumed: emu.log.assumed.length, impersonated: emu.log.impersonated.length, clients: emu.log.clients.length };
184
+
185
+ const trade = plan.steps[0]!;
186
+ const traded = await runStep(trade.run!, work, { ...runner, ...trade.env });
187
+ expect(traded.stderr).not.toContain("failed");
188
+ expect(traded.code).toBe(0);
189
+ expect(emu.log.minted.length - before.minted).toBe(3);
190
+ expect(emu.log.assumed.slice(before.assumed).map((a) => a.role)).toEqual([PLAN_ROLE]);
191
+ expect(emu.log.impersonated.slice(before.impersonated)).toEqual(["plan@test.iam.gserviceaccount.com"]);
192
+ expect(emu.log.clients.slice(before.clients).map((c) => c.clientId)).toEqual(["plan-client"]);
193
+ const left = githubEnv(readFileSync(envFile, "utf-8"));
194
+ expect(left.AWS_ACCESS_KEY_ID).toBe(emulatedCredentials(PLAN_ROLE).accessKeyId);
195
+
196
+ const later = plan.steps.slice(2);
197
+ expect(later.length).toBeGreaterThan(2);
198
+ const mintedAfterTrade = emu.log.minted.length;
199
+ for (const [i, step] of later.entries()) {
200
+ const seen = join(top, `seen-${i}`);
201
+ const lines = step.run!.split("\n");
202
+ const r = await runStep([lines[0], PROBE].join("\n"), work, { ...runner, ...left, ...(step.env ?? {}), SEEN: seen });
203
+ expect(r.code, step.name ?? lines[1]).toBe(0);
204
+ const text = readFileSync(seen, "utf-8");
205
+ expect(text).toContain(`AWS_ACCESS_KEY_ID=${emulatedCredentials(PLAN_ROLE).accessKeyId}`);
206
+ expect(text).toContain("no-request-vars");
207
+ expect(text).not.toContain("minted:");
208
+ expect(text).not.toContain(EMULATED_JWT_PREFIX);
209
+ expect(text).not.toContain(EMULATED_REQUEST_TOKEN);
210
+ expect(text).not.toContain(FORGE_SECRET);
211
+ for (const id of APPLY_IDENTITIES) expect(text).not.toContain(id);
212
+ expect(text).not.toContain(emulatedCredentials(APPLY_ROLE).accessKeyId);
213
+ for (const v of OIDC_REQUEST_VARS) expect(text).not.toMatch(new RegExp(`^${v}=`, "m"));
214
+ }
215
+ // No step after the trade minted a token.
216
+ expect(emu.log.minted.length).toBe(mintedAfterTrade);
217
+ expect(emu.log.assumed.some((a) => a.role === APPLY_ROLE)).toBe(false);
218
+ });
219
+ });