@intentius/chant-lexicon-github 0.124.43 → 0.124.45

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.
@@ -34,6 +34,13 @@
34
34
  * environment, variables over the wave's, setup after the wave's, and id
35
35
  * tokens. A job with id tokens gets `permissions:` of its own, the
36
36
  * workflow's plus `id-token: write`, and each audience as `<NAME>_AUDIENCE`.
37
+ *
38
+ * A job whose wave or group gives `cloud` (#4332, `cloudIdentityWave` in
39
+ * `@intentius/chant/ci-identity`) gets `id-token: write` and starts with a
40
+ * step that trades the job's OIDC token for those identities, before the
41
+ * checkout. Every later run step starts with an unset of the variables a
42
+ * token is requested with, so the code the job runs holds no OIDC token
43
+ * and cannot request one for another group's identity.
37
44
  */
38
45
  import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
39
46
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,EAAoE,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAClI,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAgB9F,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAEtF,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAW,CAAC;AAEvC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,GAC9B;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CA2DpI;AA0CD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,EAAoE,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAClI,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAiB9F,OAAO,EAAsB,KAAK,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAEtF,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAW,CAAC;AAEvC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED,qEAAqE;AACrE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,sBAAsB,GAC9B;IAAE,GAAG,EAAE,mBAAmB,CAAC;IAAC,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAAC,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAA;CAAE,CAgEpI;AAoDD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAMvH"}
@@ -34,9 +34,17 @@
34
34
  * environment, variables over the wave's, setup after the wave's, and id
35
35
  * tokens. A job with id tokens gets `permissions:` of its own, the
36
36
  * workflow's plus `id-token: write`, and each audience as `<NAME>_AUDIENCE`.
37
+ *
38
+ * A job whose wave or group gives `cloud` (#4332, `cloudIdentityWave` in
39
+ * `@intentius/chant/ci-identity`) gets `id-token: write` and starts with a
40
+ * step that trades the job's OIDC token for those identities, before the
41
+ * checkout. Every later run step starts with an unset of the variables a
42
+ * token is requested with, so the code the job runs holds no OIDC token
43
+ * and cannot request one for another group's identity.
37
44
  */
38
45
  import { notifyVariables, opWaveGroupOf, opWaveJobs, opWavesResumeCommand } from "@intentius/chant/op/op-waves";
39
46
  import { assertEnvironment, assertSetupSteps, CI_IMAGE, dirnameOf, environmentDoc, GITHUB_JOB_TOKEN, githubRunsOn, githubSecretEnv, idTokenAudienceEnv, nonEmpty, pushBranches, setupStepDoc, } from "@intentius/chant/ci-jobs";
47
+ import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine } from "@intentius/chant/ci-identity";
40
48
  import { checkoutStep, concurrency, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs.js";
41
49
  import { emitOpPipelineYAML } from "./generate-op-pipeline.js";
42
50
  /** The default image: it carries git, which the runner needs to read the base commit. */
@@ -66,25 +74,30 @@ export function buildGithubOpWavesDoc(spec, options) {
66
74
  const wave = spec.waves[job.wave - 1];
67
75
  // #3914: a job group's job runs with the group's settings over the wave's.
68
76
  const group = opWaveGroupOf(spec, job);
69
- const steps = [checkoutStep(true)];
77
+ // #4332: the job's cloud identities, traded before the checkout.
78
+ const cloud = group?.cloud ?? wave.cloud;
79
+ const steps = [...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
70
80
  if (job.kind === "share") {
71
81
  steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record)));
72
82
  }
73
- steps.push(...[...(wave.setup ?? []), ...(group?.setup ?? [])].map(setupStepDoc), ...runSteps(options.beforeScript ?? []));
74
- steps.push({ name: describeJob(job.kind, job.wave, wave.name, job.share, job.group), run: job.command.join(" ") });
75
- steps.push(...runSteps(options.extraScript ?? []));
83
+ const later = [];
84
+ later.push(...[...(wave.setup ?? []), ...(group?.setup ?? [])].map(setupStepDoc), ...runSteps(options.beforeScript ?? []));
85
+ later.push({ name: describeJob(job.kind, job.wave, wave.name, job.share, job.group), run: job.command.join(" ") });
86
+ later.push(...runSteps(options.extraScript ?? []));
87
+ steps.push(...(cloud ? later.map(withoutTokenRequest) : later));
76
88
  if (job.kind === "decide") {
77
89
  steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record));
78
90
  }
79
91
  const environment = group?.environment ?? wave.environment;
80
92
  const idTokens = group?.idTokens ?? wave.idTokens;
93
+ const idToken = idTokens !== undefined || cloud !== undefined;
81
94
  jobsDoc[job.jobName] = {
82
95
  "runs-on": githubRunsOn(group?.runner ?? wave.runner ?? spec.runner),
83
96
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
84
97
  container: image,
85
98
  ...(environment ? { environment: environmentDoc(environment) } : {}),
86
99
  // #3914: a job's own permissions replace the workflow's, so they repeat them.
87
- ...(idTokens ? { permissions: { ...permissions, "id-token": "write" } } : {}),
100
+ ...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
88
101
  ...nonEmpty("env", group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables),
89
102
  steps,
90
103
  };
@@ -137,6 +150,14 @@ function buildPlansDoc(spec, options) {
137
150
  jobsDoc: { "record-plans": { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
138
151
  };
139
152
  }
153
+ /** The step that trades the job's OIDC token for `cloud` (#4332), writing the credentials to GITHUB_ENV. */
154
+ function tradeStep(cloud) {
155
+ return { name: "Trade the job's OIDC token for its cloud identities", env: { [TRADE_SPEC_ENV]: JSON.stringify(cloud) }, run: tradeLine("$GITHUB_ENV", "github-env") };
156
+ }
157
+ /** A run step after the trade (#4332), with the token request variables unset first. A `uses:` step is left as it is. */
158
+ function withoutTokenRequest(step) {
159
+ return typeof step.run === "string" ? { ...step, run: `unset ${OIDC_REQUEST_VARS.join(" ")}\n${step.run}` } : step;
160
+ }
140
161
  function describeJob(kind, wave, name, share, group) {
141
162
  if (kind === "decide")
142
163
  return `Plan wave ${wave} (${name}) and decide its gate`;
@@ -1 +1 @@
1
- {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA4BjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,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;AA2bD;;;;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,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAEH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AA4BjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,MAAM,0BAA0B,CAAC;AAIlC,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;AAmcD;;;;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,CA+JnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
@@ -382,17 +382,24 @@ function prEnvGithubJobs(forge, image, beforeScript, extraScript, loop, member)
382
382
  const writes = { contents: "write", "pull-requests": "write", statuses: "write" };
383
383
  const keep = (name) => ({ name: `Keep the ${name} report`, if: "always()", uses: UPLOAD_ARTIFACT, with: { name: `chant-pr-${name}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } });
384
384
  const opened = "github.event_name == 'pull_request' && github.event.action != 'closed'";
385
- // #4295: env-up runs the change's code and holds no forge token while it does.
386
- const drop = dropForgeTokens("sh");
385
+ // #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity, before the checkout.
386
+ const identity = loop.prEnvIdentity;
387
+ const tradeStep = identity
388
+ ? [{ name: "Trade the job's OIDC token for the pull request environment's identity", ...(rooted ? {} : { "working-directory": "." }), env: { [TRADE_SPEC_ENV]: JSON.stringify(identity) }, run: tradeLine("$GITHUB_ENV", "github-env") }]
389
+ : [];
390
+ const withIdToken = (permissions) => (identity ? { ...permissions, "id-token": "write" } : permissions);
391
+ // #4295: env-up runs the change's code and holds no forge token while it does; #4330: nor the token request variables.
392
+ const drop = dropForgeTokens("sh", identity ? OIDC_REQUEST_VARS : []);
387
393
  const headStep = (line, name, env) => ({ ...(name ? { name } : {}), ...(env ? { env } : {}), run: `${drop}\n${line}` });
388
394
  const upJob = {
389
395
  if: opened,
390
396
  "runs-on": GITHUB_RUNNER,
391
397
  container: image,
392
398
  concurrency: concurrency(group),
393
- permissions: { contents: "read" },
399
+ permissions: withIdToken({ contents: "read" }),
394
400
  outputs: handoverOutputs(PR_ENV_UP_STEP_ID, forge),
395
401
  steps: [
402
+ ...tradeStep,
396
403
  {
397
404
  name: "Check out the change",
398
405
  ...(rooted ? {} : { "working-directory": "." }),
@@ -431,8 +438,9 @@ function prEnvGithubJobs(forge, image, beforeScript, extraScript, loop, member)
431
438
  "runs-on": GITHUB_RUNNER,
432
439
  container: image,
433
440
  concurrency: concurrency(groupName),
434
- permissions: writes,
441
+ permissions: withIdToken(writes),
435
442
  steps: [
443
+ ...tradeStep,
436
444
  { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
437
445
  ...runSteps(prLoopSetup(image)),
438
446
  ...runSteps(beforeScript),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "8da3ed450430477d84f2ff5930a00bc4ec0135806fa52f08aed7323a7f016be3",
4
+ "manifest.json": "7b16495d7bcf5730a4297a1f5d7161d95dc75afe6ee026e72a7572889f953166",
5
5
  "meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
6
6
  "types/index.d.ts": "0ead95536e947ddfb98a5acf0ddb88547913b5a53a342df67711c59266f84b3e",
7
7
  "rules/deprecated-action-version.ts": "a9ba7cc924ff1c7055110416d8989273690415bfb5e8dcc32dc9431b9351f5ca",
@@ -78,5 +78,5 @@
78
78
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
79
79
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
80
80
  },
81
- "composite": "898dc84e00b121dbe97351f6dc17767bde8532a89c51d89a13820d7364d54d74"
81
+ "composite": "1b42a70a674560e0669fbf3639c0e35712fbf1fe34700e1d6449aac5cd04190e"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.124.43",
3
+ "version": "0.124.45",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.124.43",
3
+ "version": "0.124.45",
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,6 +61,6 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.124.43"
64
+ "@intentius/chant": "^0.124.45"
65
65
  }
66
66
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * #4332: an Op wave's apply split into one job per cloud identity. The
3
+ * deciding job trades its OIDC token for every plan identity of the wave's
4
+ * targets before the checkout, each group's job for its group's apply
5
+ * identities alone, and every later run step starts without the variables
6
+ * a token is requested with. The behaviour test runs the deciding job's
7
+ * trade step and its command step with `sh`, as a runner does, against the
8
+ * local emulator (@intentius/chant/__fixtures__/oidc-emulator), with the
9
+ * command replaced by a probe. Test values only.
10
+ */
11
+
12
+ import { execFile } from "node:child_process";
13
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { promisify } from "node:util";
17
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
18
+ import { parseYAML } from "@intentius/chant/yaml";
19
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
20
+ import { CLOUD_TARGETS_ENV, cloudIdentityWave, OIDC_REQUEST_VARS, resolveCloudIdentity, TRADE_SPEC_ENV, tradeIdentities, type CloudIdentityDeclaration } from "@intentius/chant/ci-identity";
21
+ import { emulatedCredentials, EMULATED_JWT_PREFIX, startOidcEmulator, type OidcEmulator } from "@intentius/chant/__fixtures__/oidc-emulator";
22
+ import { generateGithubOpWavesPipeline } from "./generate-op-waves-pipeline";
23
+
24
+ const run = promisify(execFile);
25
+ const role = (n: string) => `arn:aws:iam::111111111111:role/${n}`;
26
+
27
+ const DECL: CloudIdentityDeclaration = {
28
+ aws: {
29
+ rules: [
30
+ { targets: ["envs/prod/**"], plan: role("prod-plan"), apply: role("prod-apply") },
31
+ { targets: ["envs/staging/**"], plan: role("staging-plan"), apply: role("staging-apply") },
32
+ { plan: role("own-plan"), apply: role("own-apply") },
33
+ ],
34
+ },
35
+ };
36
+ const ROOTS = ["envs/prod/net", "envs/staging/net", "shared/dns"];
37
+
38
+ function specFor(decl: CloudIdentityDeclaration, roots: string[]): OpWavesSpec {
39
+ return {
40
+ name: "tf-apply",
41
+ op: "tf-apply",
42
+ planWave: ["./bin/plan", "{runs}", "{plans}"],
43
+ applyWave: ["./bin/apply", "{runs}"],
44
+ waves: [{ name: "wave-1", runs: roots.map((target) => ({ target })), ...cloudIdentityWave(decl, "prod", "infra", roots) }],
45
+ };
46
+ }
47
+
48
+ interface Step {
49
+ name?: string;
50
+ uses?: string;
51
+ run?: string;
52
+ env?: Record<string, string>;
53
+ }
54
+ interface Job {
55
+ permissions?: Record<string, string>;
56
+ steps: Step[];
57
+ }
58
+
59
+ const jobsOf = (spec: OpWavesSpec): Record<string, Job> => (parseYAML(generateGithubOpWavesPipeline(spec, { specFile: ".chant/op-waves/tf-apply.spec.json", beforeScript: ["npm ci"] }).files[0]!.yaml) as { jobs: Record<string, Job> }).jobs;
60
+ const tradeOf = (job: Job): unknown => JSON.parse(job.steps[0]!.env![TRADE_SPEC_ENV]!);
61
+ const UNSET = `unset ${OIDC_REQUEST_VARS.join(" ")}`;
62
+
63
+ describe("an Op wave's jobs by cloud identity (#4332)", () => {
64
+ const jobs = jobsOf(specFor(DECL, ROOTS));
65
+ const plan = resolveCloudIdentity(DECL, "prod", "infra")!.plan;
66
+
67
+ test("one deciding job and a job per identity", () => {
68
+ expect(Object.keys(jobs)).toEqual(["wave-1-wave-1-decide", "wave-1-wave-1-envs-prod", "wave-1-wave-1-envs-staging", "wave-1-wave-1-default"]);
69
+ });
70
+
71
+ test("the deciding job trades for every plan identity, before the checkout, and names no apply identity", () => {
72
+ const decide = jobs["wave-1-wave-1-decide"]!;
73
+ expect(decide.steps[0]!.name).toBe("Trade the job's OIDC token for its cloud identities");
74
+ expect(decide.steps[0]!.run).toMatch(/^\(cd \/ && node -e '/);
75
+ expect(decide.steps[1]!.uses).toMatch(/^actions\/checkout@/);
76
+ expect(tradeOf(decide)).toEqual(plan);
77
+ expect(decide.permissions).toEqual({ contents: "write", "id-token": "write" });
78
+ for (const id of [role("prod-apply"), role("staging-apply"), role("own-apply")]) expect(JSON.stringify(decide)).not.toContain(id);
79
+ });
80
+
81
+ test("each group's job trades for its own apply identity and holds no other group's", () => {
82
+ const want: Record<string, string> = { "wave-1-wave-1-envs-prod": role("prod-apply"), "wave-1-wave-1-envs-staging": role("staging-apply"), "wave-1-wave-1-default": role("own-apply") };
83
+ for (const [name, id] of Object.entries(want)) {
84
+ const job = jobs[name]!;
85
+ expect(tradeIdentities(tradeOf(job) as never)).toEqual([id]);
86
+ expect(job.steps[1]!.uses).toMatch(/^actions\/checkout@/);
87
+ for (const other of Object.values(want).filter((o) => o !== id)) expect(JSON.stringify(job)).not.toContain(other);
88
+ }
89
+ });
90
+
91
+ test("every run step after the trade starts by unsetting the token request variables", () => {
92
+ for (const job of Object.values(jobs)) {
93
+ const runs = job.steps.slice(1).filter((s) => s.run !== undefined);
94
+ expect(runs.length).toBeGreaterThan(0);
95
+ for (const s of runs) expect(s.run!.split("\n")[0]).toBe(UNSET);
96
+ }
97
+ });
98
+
99
+ test("a wave with no cloud trades nothing and requests no token", () => {
100
+ const plain = jobsOf(specFor({}, ROOTS));
101
+ expect(Object.keys(plain)).toEqual(["wave-1-wave-1"]);
102
+ for (const job of Object.values(plain)) {
103
+ expect(job.permissions?.["id-token"]).toBeUndefined();
104
+ expect(JSON.stringify(job)).not.toContain(TRADE_SPEC_ENV);
105
+ }
106
+ });
107
+ });
108
+
109
+ const dirs: string[] = [];
110
+ let emu: OidcEmulator;
111
+ beforeAll(async () => {
112
+ emu = await startOidcEmulator();
113
+ });
114
+ afterAll(async () => {
115
+ await emu.close();
116
+ for (const d of dirs) rmSync(d, { recursive: true, force: true });
117
+ });
118
+
119
+ describe("the deciding job, run step by step against the emulator (#4332)", () => {
120
+ test("its command step sees the plan identities through the targets file, and cannot request a token", async () => {
121
+ const decide = jobsOf(specFor(DECL, ROOTS))["wave-1-wave-1-decide"]!;
122
+ const temp = mkdtempSync(join(tmpdir(), "chant-op-waves-identity-"));
123
+ dirs.push(temp);
124
+ const githubEnv = join(temp, "github-env");
125
+ writeFileSync(githubEnv, "");
126
+ const base = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp", RUNNER_TEMP: temp, ...emu.requestEnv };
127
+ const spec = emu.pointAt(JSON.parse(decide.steps[0]!.env![TRADE_SPEC_ENV]!));
128
+ await run("sh", ["-c", decide.steps[0]!.run!.replace("$GITHUB_ENV", githubEnv)], { env: { ...base, [TRADE_SPEC_ENV]: JSON.stringify(spec) } });
129
+ const traded = Object.fromEntries(readFileSync(githubEnv, "utf-8").split("\n").filter(Boolean).map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
130
+ expect(traded.AWS_ACCESS_KEY_ID).toBe(emulatedCredentials(role("own-plan")).accessKeyId);
131
+ const before = emu.log.minted.length;
132
+ // The command step with the command replaced by a probe: what it sees, and a token request.
133
+ const step = decide.steps.find((s) => s.run?.includes("chant run wave"))!;
134
+ const probe = `node -e 'const e=process.env;const f=JSON.parse(require("fs").readFileSync(e.${CLOUD_TARGETS_ENV},"utf8"));console.log("roles:"+Object.keys(f.clouds.aws.vars).join(","));console.log("request:"+(e.ACTIONS_ID_TOKEN_REQUEST_URL&&e.ACTIONS_ID_TOKEN_REQUEST_TOKEN?"yes":"no"))'`;
135
+ const r = await run("sh", ["-e", "-c", `${step.run!.split("\n")[0]}\n${probe}`], { env: { ...base, ...traded } });
136
+ expect(r.stdout).toContain(`roles:${[role("own-plan"), role("prod-plan"), role("staging-plan")].join(",")}`);
137
+ expect(r.stdout).toContain("request:no");
138
+ expect(r.stdout).not.toContain(EMULATED_JWT_PREFIX);
139
+ expect(emu.log.minted.length).toBe(before);
140
+ });
141
+ });
@@ -34,6 +34,13 @@
34
34
  * environment, variables over the wave's, setup after the wave's, and id
35
35
  * tokens. A job with id tokens gets `permissions:` of its own, the
36
36
  * workflow's plus `id-token: write`, and each audience as `<NAME>_AUDIENCE`.
37
+ *
38
+ * A job whose wave or group gives `cloud` (#4332, `cloudIdentityWave` in
39
+ * `@intentius/chant/ci-identity`) gets `id-token: write` and starts with a
40
+ * step that trades the job's OIDC token for those identities, before the
41
+ * checkout. Every later run step starts with an unset of the variables a
42
+ * token is requested with, so the code the job runs holds no OIDC token
43
+ * and cannot request one for another group's identity.
37
44
  */
38
45
 
39
46
  import { notifyVariables, opWaveGroupOf, opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
@@ -52,6 +59,7 @@ import {
52
59
  pushBranches,
53
60
  setupStepDoc,
54
61
  } from "@intentius/chant/ci-jobs";
62
+ import { OIDC_REQUEST_VARS, TRADE_SPEC_ENV, tradeLine, type CloudTradeSpec } from "@intentius/chant/ci-identity";
55
63
  import { checkoutStep, concurrency, downloadArtifactStep, keepRecordStep, runSteps } from "./ci-jobs";
56
64
  import { emitOpPipelineYAML, type GithubOpPipelineDoc } from "./generate-op-pipeline";
57
65
 
@@ -86,25 +94,30 @@ export function buildGithubOpWavesDoc(
86
94
  const wave = spec.waves[job.wave - 1]!;
87
95
  // #3914: a job group's job runs with the group's settings over the wave's.
88
96
  const group = opWaveGroupOf(spec, job);
89
- const steps: Array<Record<string, unknown>> = [checkoutStep(true)];
97
+ // #4332: the job's cloud identities, traded before the checkout.
98
+ const cloud = group?.cloud ?? wave.cloud;
99
+ const steps: Array<Record<string, unknown>> = [...(cloud ? [tradeStep(cloud)] : []), checkoutStep(true)];
90
100
  if (job.kind === "share") {
91
101
  steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record!)));
92
102
  }
93
- steps.push(...[...(wave.setup ?? []), ...(group?.setup ?? [])].map(setupStepDoc), ...runSteps(options.beforeScript ?? []));
94
- steps.push({ name: describeJob(job.kind, job.wave, wave.name, job.share, job.group), run: job.command.join(" ") });
95
- steps.push(...runSteps(options.extraScript ?? []));
103
+ const later: Array<Record<string, unknown>> = [];
104
+ later.push(...[...(wave.setup ?? []), ...(group?.setup ?? [])].map(setupStepDoc), ...runSteps(options.beforeScript ?? []));
105
+ later.push({ name: describeJob(job.kind, job.wave, wave.name, job.share, job.group), run: job.command.join(" ") });
106
+ later.push(...runSteps(options.extraScript ?? []));
107
+ steps.push(...(cloud ? later.map(withoutTokenRequest) : later));
96
108
  if (job.kind === "decide") {
97
109
  steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record!));
98
110
  }
99
111
  const environment = group?.environment ?? wave.environment;
100
112
  const idTokens = group?.idTokens ?? wave.idTokens;
113
+ const idToken = idTokens !== undefined || cloud !== undefined;
101
114
  jobsDoc[job.jobName] = {
102
115
  "runs-on": githubRunsOn(group?.runner ?? wave.runner ?? spec.runner),
103
116
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
104
117
  container: image,
105
118
  ...(environment ? { environment: environmentDoc(environment) } : {}),
106
119
  // #3914: a job's own permissions replace the workflow's, so they repeat them.
107
- ...(idTokens ? { permissions: { ...permissions, "id-token": "write" } } : {}),
120
+ ...(idToken ? { permissions: { ...permissions, "id-token": "write" } } : {}),
108
121
  ...nonEmpty("env", group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables),
109
122
  steps,
110
123
  };
@@ -161,6 +174,16 @@ function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): Gith
161
174
  };
162
175
  }
163
176
 
177
+ /** The step that trades the job's OIDC token for `cloud` (#4332), writing the credentials to GITHUB_ENV. */
178
+ function tradeStep(cloud: CloudTradeSpec): Record<string, unknown> {
179
+ return { name: "Trade the job's OIDC token for its cloud identities", env: { [TRADE_SPEC_ENV]: JSON.stringify(cloud) }, run: tradeLine("$GITHUB_ENV", "github-env") };
180
+ }
181
+
182
+ /** A run step after the trade (#4332), with the token request variables unset first. A `uses:` step is left as it is. */
183
+ function withoutTokenRequest(step: Record<string, unknown>): Record<string, unknown> {
184
+ return typeof step.run === "string" ? { ...step, run: `unset ${OIDC_REQUEST_VARS.join(" ")}\n${step.run}` } : step;
185
+ }
186
+
164
187
  function describeJob(kind: "wave" | "decide" | "share", wave: number, name: string, share?: number, group?: string): string {
165
188
  if (kind === "decide") return `Plan wave ${wave} (${name}) and decide its gate`;
166
189
  if (group !== undefined) return `Apply ${share !== undefined ? `share ${share} of ` : ""}job group ${group} of wave ${wave} (${name})`;
@@ -475,17 +475,24 @@ function prEnvGithubJobs(
475
475
  const keep = (name: string) => ({ name: `Keep the ${name} report`, if: "always()", uses: UPLOAD_ARTIFACT, with: { name: `chant-pr-${name}`, path: reportPath, "if-no-files-found": "ignore", "include-hidden-files": true } });
476
476
  const opened = "github.event_name == 'pull_request' && github.event.action != 'closed'";
477
477
 
478
- // #4295: env-up runs the change's code and holds no forge token while it does.
479
- const drop = dropForgeTokens("sh");
478
+ // #4330: env-up, env-down and env-sweep trade the job's OIDC token for the pull request environments' identity, before the checkout.
479
+ const identity = loop.prEnvIdentity;
480
+ const tradeStep: Array<Record<string, unknown>> = identity
481
+ ? [{ name: "Trade the job's OIDC token for the pull request environment's identity", ...(rooted ? {} : { "working-directory": "." }), env: { [TRADE_SPEC_ENV]: JSON.stringify(identity) }, run: tradeLine("$GITHUB_ENV", "github-env") }]
482
+ : [];
483
+ const withIdToken = (permissions: Record<string, string>): Record<string, string> => (identity ? { ...permissions, "id-token": "write" } : permissions);
484
+ // #4295: env-up runs the change's code and holds no forge token while it does; #4330: nor the token request variables.
485
+ const drop = dropForgeTokens("sh", identity ? OIDC_REQUEST_VARS : []);
480
486
  const headStep = (line: string, name?: string, env?: Record<string, string>): Record<string, unknown> => ({ ...(name ? { name } : {}), ...(env ? { env } : {}), run: `${drop}\n${line}` });
481
487
  const upJob = {
482
488
  if: opened,
483
489
  "runs-on": GITHUB_RUNNER,
484
490
  container: image,
485
491
  concurrency: concurrency(group),
486
- permissions: { contents: "read" },
492
+ permissions: withIdToken({ contents: "read" }),
487
493
  outputs: handoverOutputs(PR_ENV_UP_STEP_ID, forge),
488
494
  steps: [
495
+ ...tradeStep,
489
496
  {
490
497
  name: "Check out the change",
491
498
  ...(rooted ? {} : { "working-directory": "." }),
@@ -524,8 +531,9 @@ function prEnvGithubJobs(
524
531
  "runs-on": GITHUB_RUNNER,
525
532
  container: image,
526
533
  concurrency: concurrency(groupName),
527
- permissions: writes,
534
+ permissions: withIdToken(writes),
528
535
  steps: [
536
+ ...tradeStep,
529
537
  { uses: actionRef("actions/checkout"), with: { "fetch-depth": 0, ...(ref ? { ref } : {}) } },
530
538
  ...runSteps(prLoopSetup(image)),
531
539
  ...runSteps(beforeScript),
@@ -204,3 +204,31 @@ describe("env-up's steps, run, see no forge token and hand the report and record
204
204
  expect(readFileSync(join(postWork, ".chant", "pr-env", "pr-env-up.json"), "utf-8")).toBe('{"stage":"up"}\n');
205
205
  });
206
206
  });
207
+
208
+ describe("the environment's cloud identity (#4330)", () => {
209
+ const prEnvIdentity = { aws: { role: "arn:aws:iam::000000000000:role/pr-env-apply", region: "us-east-1", audience: "sts.amazonaws.com", endpoint: "http://localhost:4566" } };
210
+ const jobs = (parseYAML(generateGithubPipeline(ESTATE, { env: "staging", prLoop: { prEnvironment: true, prEnvIdentity } }).yaml) as { jobs: Record<string, Job> }).jobs;
211
+
212
+ test("env-up trades its OIDC token before the checkout, and every later step starts without the token request variables", () => {
213
+ const up = jobs["env-up"]!;
214
+ expect(up.permissions).toEqual({ contents: "read", "id-token": "write" });
215
+ expect(up.steps[0]!.name).toMatch(/Trade the job's OIDC token/);
216
+ expect(JSON.parse(up.steps[0]!.env!.CHANT_CLOUD_TRADE!)).toEqual(prEnvIdentity);
217
+ expect(up.steps[1]!.name).toBe("Check out the change");
218
+ for (const step of up.steps.slice(2)) expect(step.run).toContain("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
219
+ });
220
+
221
+ test("env-down and env-sweep trade for the same identity, and env-post trades for none", () => {
222
+ for (const name of ["env-down", "env-sweep"]) {
223
+ expect(jobs[name]!.permissions["id-token"]).toBe("write");
224
+ expect(jobs[name]!.steps[0]!.env?.CHANT_CLOUD_TRADE).toBe(JSON.stringify(prEnvIdentity));
225
+ }
226
+ expect(jobs["env-post"]!.permissions["id-token"]).toBeUndefined();
227
+ expect(JSON.stringify(jobs["env-post"]!.steps)).not.toContain("CHANT_CLOUD_TRADE");
228
+ });
229
+
230
+ test("without one, no environment job asks for an OIDC token", () => {
231
+ const plain = (parseYAML(generateGithubPipeline(ESTATE, { env: "staging", prLoop: { prEnvironment: true } }).yaml) as { jobs: Record<string, Job> }).jobs;
232
+ for (const name of ["env-up", "env-down", "env-sweep"]) expect(plain[name]!.permissions["id-token"]).toBeUndefined();
233
+ });
234
+ });