@intentius/chant-lexicon-github 0.124.40 → 0.124.42
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/ci-jobs.d.ts +9 -0
- package/dist/components/ci-jobs.d.ts.map +1 -1
- package/dist/components/ci-jobs.js +12 -0
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.js +229 -16
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/__fixtures__/pr-loop-build.github.golden.yml +76 -10
- package/src/components/__fixtures__/pr-loop-record.github.golden.yml +70 -8
- package/src/components/__fixtures__/pr-loop-waves.github.golden.yml +72 -10
- package/src/components/__fixtures__/pr-loop.github.golden.yml +70 -8
- package/src/components/ci-jobs.ts +13 -0
- package/src/components/generate-pipeline.test.ts +3 -0
- package/src/components/generate-pipeline.ts +245 -15
- package/src/components/generate-pr-env-pipeline.test.ts +206 -0
- package/src/components/generate-pr-pipeline.before-merge.test.ts +67 -0
- package/src/components/generate-pr-pipeline.handover.test.ts +167 -0
- package/src/components/generate-pr-pipeline.identity.test.ts +219 -0
- package/src/components/generate-pr-pipeline.test.ts +29 -9
- package/src/components/generate-pr-waves-pipeline.test.ts +6 -3
|
@@ -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
|
+
});
|
|
@@ -12,6 +12,7 @@ import { describe, expect, test } from "vitest";
|
|
|
12
12
|
import { parseYAML } from "@intentius/chant/yaml";
|
|
13
13
|
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
14
14
|
import { githubBaseFallback, greenCheckout, prApplyGroup, prApplyRecordKey, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
|
|
15
|
+
import { dropForgeTokens } from "@intentius/chant/ci-handover";
|
|
15
16
|
import { generateGithubPipeline } from "./generate-pipeline";
|
|
16
17
|
|
|
17
18
|
const ESTATE: DriverComponent[] = [
|
|
@@ -38,6 +39,14 @@ interface Step {
|
|
|
38
39
|
env?: Record<string, string>;
|
|
39
40
|
with?: Record<string, unknown>;
|
|
40
41
|
}
|
|
42
|
+
/** A plan job's run step without the line that drops the forge tokens (#4295). */
|
|
43
|
+
const headLines = (run: string | undefined): string[] => {
|
|
44
|
+
const lines = (run ?? "").trimEnd().split("\n");
|
|
45
|
+
return lines[0] === dropForgeTokens("sh") ? lines.slice(1) : lines;
|
|
46
|
+
};
|
|
47
|
+
/** The plan step's command: its last line. */
|
|
48
|
+
const lastLine = (run: string | undefined): string | undefined => (run ?? "").trimEnd().split("\n").at(-1);
|
|
49
|
+
|
|
41
50
|
interface Job {
|
|
42
51
|
if: string;
|
|
43
52
|
permissions: Record<string, string>;
|
|
@@ -61,18 +70,20 @@ describe("the GitHub pull-request workflow", () => {
|
|
|
61
70
|
expect(doc.jobs.plan.if).toBe("github.event_name == 'pull_request'");
|
|
62
71
|
expect(doc.jobs.record.if).toBe("github.event_name == 'push'");
|
|
63
72
|
expect(doc.jobs.apply.if).toBe("github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'");
|
|
64
|
-
expect(result.jobs.map((j) => j.jobName)).toEqual(["plan", "record", "apply"]);
|
|
73
|
+
expect(result.jobs.map((j) => j.jobName)).toEqual(["plan", "plan-note", "record", "apply"]);
|
|
65
74
|
expect(result.jobs.find((j) => j.jobName === "apply")!.needs).toEqual([]);
|
|
66
75
|
});
|
|
67
76
|
|
|
68
77
|
test("the plan job measures from the pull request's base and cannot push; the apply job can, one at a time", () => {
|
|
69
78
|
const plan = doc.jobs.plan.steps.find((s) => s.run?.includes("ci run plan"))!;
|
|
70
79
|
// #4156: the job runs pr-plan through chant ci run, in its own checkout, and pushes no record from the pull request's code.
|
|
71
|
-
expect(plan.run).toBe('chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github');
|
|
80
|
+
expect(lastLine(plan.run)).toBe('chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github');
|
|
72
81
|
expect(plan.env).toMatchObject({ BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}" });
|
|
73
82
|
// The runner key is an optional secret: unset, the expression is empty and the record is unsigned.
|
|
74
83
|
expect(plan.env?.CHANT_RUNNER_KEY).toBe("${{ secrets.CHANT_RUNNER_KEY }}");
|
|
75
|
-
|
|
84
|
+
// #4295: the plan job only reads; the plan-note job writes the note and the status.
|
|
85
|
+
expect(doc.jobs.plan.permissions).toEqual({ contents: "read" });
|
|
86
|
+
expect(doc.jobs["plan-note"].permissions).toEqual({ actions: "read", contents: "read", "pull-requests": "write", statuses: "write" });
|
|
76
87
|
|
|
77
88
|
const apply = doc.jobs.apply.steps.find((s) => s.run?.includes("ci run apply"))!;
|
|
78
89
|
// #4168: ci run builds pr-apply without --require-review unless the pipeline asks for it.
|
|
@@ -82,7 +93,10 @@ describe("the GitHub pull-request workflow", () => {
|
|
|
82
93
|
expect(apply.env).toEqual({ GITHUB_TOKEN: "${{ github.token }}", CHANT_RUNNER_KEY: "${{ secrets.CHANT_RUNNER_KEY }}" });
|
|
83
94
|
expect(doc.jobs.apply.permissions.contents).toBe("write");
|
|
84
95
|
expect(doc.jobs.apply.concurrency).toEqual({ group: "chant-apply-prod", "cancel-in-progress": false });
|
|
85
|
-
for (const job of Object.
|
|
96
|
+
for (const [name, job] of Object.entries(doc.jobs)) if (!name.startsWith("plan")) expect(job.steps[0].with).toEqual({ "fetch-depth": 0 });
|
|
97
|
+
// #4295: the plan job checks out with a script, and the note job checks out the target branch's commit.
|
|
98
|
+
expect(doc.jobs.plan.steps[0].uses).toBeUndefined();
|
|
99
|
+
expect(doc.jobs["plan-note"].steps[0].with).toEqual({ ref: "${{ github.event.pull_request.base.sha }}" });
|
|
86
100
|
});
|
|
87
101
|
|
|
88
102
|
test("a first push's all-zero github.event.before is settled in the record's own shell, before pr-record reads it (#4004)", () => {
|
|
@@ -179,7 +193,7 @@ describe("the GitHub pull-request workflow", () => {
|
|
|
179
193
|
expect(mdoc.defaults).toEqual({ run: { "working-directory": "infra/network" } });
|
|
180
194
|
const plan = mdoc.jobs.plan.steps.find((s) => s.run?.includes("ci run plan"))!;
|
|
181
195
|
// ci run finds the member from the directory it runs in and passes --member to pr-plan and pr-apply.
|
|
182
|
-
expect(plan.run).toBe('chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github');
|
|
196
|
+
expect(lastLine(plan.run)).toBe('chant ci run plan --in-place --no-push --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --forge github');
|
|
183
197
|
expect(prLoopJobs("github", "prod", {}, "network")[0]!.command).toContain("--member network");
|
|
184
198
|
const apply = mdoc.jobs.apply.steps.find((s) => s.run?.includes("ci run apply"))!;
|
|
185
199
|
expect(mdoc.jobs.apply.concurrency).toEqual({ group: "chant-apply.network.prod", "cancel-in-progress": false });
|
|
@@ -187,10 +201,16 @@ describe("the GitHub pull-request workflow", () => {
|
|
|
187
201
|
expect(apply.run).toContain("--in-place");
|
|
188
202
|
const restore = mdoc.jobs.apply.steps.find((s) => s.uses === "actions/cache/restore@v4")!;
|
|
189
203
|
expect(restore.with).toMatchObject({ path: "infra/network/.chant/pr-resume/pr-apply.json", "restore-keys": "chant-apply.network.prod-${{ github.sha }}-" });
|
|
190
|
-
|
|
204
|
+
// #4295: the plan job uploads nothing; the plan-note job keeps its report.
|
|
205
|
+
expect(mdoc.jobs.plan.steps.some((s) => s.uses?.startsWith("actions/upload-artifact"))).toBe(false);
|
|
206
|
+
for (const [name, job] of Object.entries(mdoc.jobs)) {
|
|
207
|
+
if (name === "plan") continue;
|
|
191
208
|
const keep = job.steps.find((s) => s.uses?.startsWith("actions/upload-artifact"))!;
|
|
192
209
|
expect(keep.with?.path).toBe("infra/network/.chant/pr");
|
|
193
210
|
}
|
|
211
|
+
// The checkout runs at the repository's root, before the member's directory exists.
|
|
212
|
+
expect((mdoc.jobs.plan.steps[0] as Record<string, unknown>)["working-directory"]).toBe(".");
|
|
213
|
+
expect(mdoc.jobs["plan-note"].steps.at(-1)!.run).toContain("--member network");
|
|
194
214
|
});
|
|
195
215
|
|
|
196
216
|
test("no two member and environment pairs share an apply group or record key", () => {
|
|
@@ -236,8 +256,8 @@ describe("the github pull-request pipeline with build (#4088)", () => {
|
|
|
236
256
|
|
|
237
257
|
test("builds, then lints, before pr-plan and pr-apply", () => {
|
|
238
258
|
for (const job of ["plan", "apply"]) {
|
|
239
|
-
const runs = doc.jobs[job].steps.map((s: { run?: string }) => s.run
|
|
240
|
-
const i = runs.findIndex((r: string) => r.
|
|
259
|
+
const runs = doc.jobs[job].steps.map((s: { run?: string }) => headLines(s.run).join("\n")).filter(Boolean);
|
|
260
|
+
const i = runs.findIndex((r: string) => r.includes(`chant ci run ${job}`));
|
|
241
261
|
expect(runs.slice(i - 2, i)).toEqual(["chant build --env prod", "chant lint"]);
|
|
242
262
|
}
|
|
243
263
|
});
|
|
@@ -266,7 +286,7 @@ describe("the GitHub pull-request workflow records on merge by default (#4168)",
|
|
|
266
286
|
});
|
|
267
287
|
|
|
268
288
|
test("a push runs the record job, which applies nothing and keeps no resume record", () => {
|
|
269
|
-
expect(result.jobs.map((j) => j.jobName)).toEqual(["plan", "record"]);
|
|
289
|
+
expect(result.jobs.map((j) => j.jobName)).toEqual(["plan", "plan-note", "record"]);
|
|
270
290
|
expect(doc.jobs.apply).toBeUndefined();
|
|
271
291
|
expect(doc.jobs.record.if).toBe("github.event_name == 'push'");
|
|
272
292
|
const run = doc.jobs.record.steps.find((s) => s.run?.includes("ci run record"))!;
|
|
@@ -80,9 +80,10 @@ describe("the GitHub pull-request workflow with a gate per wave (#4003)", () =>
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
test("keeps the plan and record jobs, and writes one apply job per wave, each needing the one before", () => {
|
|
83
|
-
expect(Object.keys(doc.jobs)).toEqual(["plan", "record", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
|
|
83
|
+
expect(Object.keys(doc.jobs)).toEqual(["plan", "plan-note", "record", "apply-wave-1", "apply-wave-2", "apply-wave-3"]);
|
|
84
84
|
expect(result.jobs.map((j) => [j.jobName, j.needs])).toEqual([
|
|
85
85
|
["plan", []],
|
|
86
|
+
["plan-note", ["plan"]],
|
|
86
87
|
["record", []],
|
|
87
88
|
["apply-wave-1", []],
|
|
88
89
|
["apply-wave-2", ["apply-wave-1"]],
|
|
@@ -96,7 +97,9 @@ describe("the GitHub pull-request workflow with a gate per wave (#4003)", () =>
|
|
|
96
97
|
for (const k of [1, 2, 3]) {
|
|
97
98
|
const job = doc.jobs[`apply-wave-${k}`]!;
|
|
98
99
|
// #4168 step 9: the waves apply the newest green commit once the tick ran.
|
|
99
|
-
|
|
100
|
+
// #4291: a wave after the first runs after one that failed, so a failure blocks only its dependents.
|
|
101
|
+
const onGreen = "github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'";
|
|
102
|
+
expect(job.if).toBe(k === 1 ? onGreen : `!cancelled() && needs.apply-wave-${k - 1}.result != 'skipped' && (${onGreen})`);
|
|
100
103
|
expect(job.concurrency).toEqual({ group: "chant-apply-prod", "cancel-in-progress": false });
|
|
101
104
|
expect(job.permissions.contents).toBe("write");
|
|
102
105
|
const run = job.steps.find((s) => s.run?.includes("ci run apply"))!.run!;
|
|
@@ -121,7 +124,7 @@ describe("the GitHub pull-request workflow with a gate per wave (#4003)", () =>
|
|
|
121
124
|
|
|
122
125
|
test("without --wave-gate the workflow is the one apply job it was", () => {
|
|
123
126
|
const plain = generateGithubPipeline(ESTATE, { env: "prod", prLoop: { applyOnMerge: true } });
|
|
124
|
-
expect(plain.jobs.map((j) => j.jobName)).toEqual(["plan", "record", "apply"]);
|
|
127
|
+
expect(plain.jobs.map((j) => j.jobName)).toEqual(["plan", "plan-note", "record", "apply"]);
|
|
125
128
|
expect(plain.yaml).not.toContain("--wave");
|
|
126
129
|
});
|
|
127
130
|
|