@intentius/chant-lexicon-github 0.124.23 → 0.124.24

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.
@@ -27,6 +27,13 @@
27
27
  * With `notify` on the spec (#3761), the workflow maps each variable it names
28
28
  * from the repository secret of the same name, so a wave job can post to
29
29
  * Slack, Teams or a signed webhook. `options.variables` wins over the mapping.
30
+ *
31
+ * `runner` (on the spec, a wave or a job group, #3914) is the job's `runs-on`.
32
+ * A wave's `jobs` renders its deciding job with the wave's own settings and a
33
+ * job per group (per share of a group) with the group's: its runner,
34
+ * environment, variables over the wave's, setup after the wave's, and id
35
+ * tokens. A job with id tokens gets `permissions:` of its own, the
36
+ * workflow's plus `id-token: write`, and each audience as `<NAME>_AUDIENCE`.
30
37
  */
31
38
  import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
32
39
  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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAAqD,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AACnH,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAe9F,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,CAgDpI;AAyCD,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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "98cf7207a36c0e608406fedf2df293334e174fe58ff1b9072fd33241880474d7",
4
+ "manifest.json": "ab329d49ebddfa547afccf09a39cb746378211e9292b9d6f88db95d5f5f357e0",
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": "cda2229fa0109be98bfe640297de14464b3f3e5902987fb061f91450f6cc5fd2"
81
+ "composite": "680c4d4083a0fe1a921a6bd565971dec440c393e60b6dbdeec3564d46567810f"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.124.23",
3
+ "version": "0.124.24",
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.23",
3
+ "version": "0.124.24",
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.23"
64
+ "@intentius/chant": "^0.124.24"
65
65
  }
66
66
  }
@@ -0,0 +1,135 @@
1
+ name: migrations
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ workflow_dispatch: {}
8
+
9
+ concurrency:
10
+ group: migrations
11
+ cancel-in-progress: false
12
+
13
+ permissions:
14
+ contents: write
15
+
16
+ jobs:
17
+ wave-1-dev:
18
+ runs-on:
19
+ - self-hosted
20
+ - dev
21
+ container: node:22
22
+ steps:
23
+ - uses: actions/checkout@v7
24
+ with:
25
+ fetch-depth: 0
26
+ - name: Plan, gate and apply wave 1 (dev)
27
+ run: chant run wave --spec waves.json --wave 1
28
+ wave-2-prod-decide:
29
+ runs-on: plan
30
+ needs:
31
+ - wave-1-dev
32
+ container: node:22
33
+ permissions:
34
+ contents: write
35
+ id-token: write
36
+ env:
37
+ STAGE: apply
38
+ PLAN_TOKEN_AUDIENCE: plan.example
39
+ steps:
40
+ - uses: actions/checkout@v7
41
+ with:
42
+ fetch-depth: 0
43
+ - name: Plan wave 2 (prod) and decide its gate
44
+ run: chant run wave --spec waves.json --wave 2 --decide
45
+ - name: Keep wave 2's decision
46
+ if: always()
47
+ uses: actions/upload-artifact@v4
48
+ with:
49
+ name: migrations-wave-2-decision
50
+ path: .chant/op-waves/migrations/wave-2.json
51
+ if-no-files-found: ignore
52
+ include-hidden-files: true
53
+ overwrite: true
54
+ wave-2-prod-us-share-1:
55
+ runs-on:
56
+ group: prod-apply
57
+ labels:
58
+ - linux
59
+ needs:
60
+ - wave-2-prod-decide
61
+ container: node:22
62
+ environment:
63
+ name: prod-us
64
+ permissions:
65
+ contents: write
66
+ id-token: write
67
+ env:
68
+ STAGE: apply
69
+ REGION: us
70
+ CLOUD_TOKEN_AUDIENCE: us.example
71
+ steps:
72
+ - uses: actions/checkout@v7
73
+ with:
74
+ fetch-depth: 0
75
+ - name: Download wave 2's decision
76
+ uses: actions/download-artifact@v4
77
+ with:
78
+ name: migrations-wave-2-decision
79
+ path: .chant/op-waves/migrations
80
+ - run: echo us
81
+ - name: Apply share 1 of job group us of wave 2 (prod)
82
+ run: chant run wave --spec waves.json --wave 2 --group us --share 1
83
+ wave-2-prod-us-share-2:
84
+ runs-on:
85
+ group: prod-apply
86
+ labels:
87
+ - linux
88
+ needs:
89
+ - wave-2-prod-decide
90
+ container: node:22
91
+ environment:
92
+ name: prod-us
93
+ permissions:
94
+ contents: write
95
+ id-token: write
96
+ env:
97
+ STAGE: apply
98
+ REGION: us
99
+ CLOUD_TOKEN_AUDIENCE: us.example
100
+ steps:
101
+ - uses: actions/checkout@v7
102
+ with:
103
+ fetch-depth: 0
104
+ - name: Download wave 2's decision
105
+ uses: actions/download-artifact@v4
106
+ with:
107
+ name: migrations-wave-2-decision
108
+ path: .chant/op-waves/migrations
109
+ - run: echo us
110
+ - name: Apply share 2 of job group us of wave 2 (prod)
111
+ run: chant run wave --spec waves.json --wave 2 --group us --share 2
112
+ wave-2-prod-eu:
113
+ runs-on: eu-apply
114
+ needs:
115
+ - wave-2-prod-decide
116
+ container: node:22
117
+ environment:
118
+ name: prod-eu
119
+ permissions:
120
+ contents: write
121
+ id-token: write
122
+ env:
123
+ STAGE: apply
124
+ PLAN_TOKEN_AUDIENCE: plan.example
125
+ steps:
126
+ - uses: actions/checkout@v7
127
+ with:
128
+ fetch-depth: 0
129
+ - name: Download wave 2's decision
130
+ uses: actions/download-artifact@v4
131
+ with:
132
+ name: migrations-wave-2-decision
133
+ path: .chant/op-waves/migrations
134
+ - name: Apply job group eu of wave 2 (prod)
135
+ run: chant run wave --spec waves.json --wave 2 --group eu
@@ -74,3 +74,50 @@ describe("generateGithubOpWavesPipeline", () => {
74
74
  expect(() => generateGithubOpWavesPipeline({ ...waveLevel, hooks: { after: { apply: [{ command: ["./bin/smoke"], onFailure: "approve" }] } } }, { specFile: "waves.json" })).toThrow(/nothing left to hold/);
75
75
  });
76
76
  });
77
+
78
+ describe("a runner per wave and job groups per wave (#3914)", () => {
79
+ const grouped: OpWavesSpec = {
80
+ ...spec,
81
+ runner: "plan",
82
+ waves: [
83
+ { name: "dev", runs: [{ target: "dev" }], gate: "never", runner: ["self-hosted", "dev"] },
84
+ {
85
+ name: "prod",
86
+ runs: [{ target: "us/a" }, { target: "us/b" }, { target: "eu/a" }],
87
+ variables: { STAGE: "apply" },
88
+ idTokens: { PLAN_TOKEN: "plan.example" },
89
+ jobs: [
90
+ { name: "us", runs: ["us/*"], shares: 2, runner: { group: "prod-apply", labels: ["linux"] }, environment: { name: "prod-us" }, variables: { REGION: "us" }, setup: [{ run: "echo us" }], idTokens: { CLOUD_TOKEN: "us.example" } },
91
+ { name: "eu", runs: ["eu/*"], runner: "eu-apply", environment: { name: "prod-eu" } },
92
+ ],
93
+ },
94
+ ],
95
+ };
96
+
97
+ test("the rendered pipeline matches its golden", async () => {
98
+ await expect(generateGithubOpWavesPipeline(grouped, { specFile: "waves.json" }).files[0]!.yaml).toMatchFileSnapshot("./__golden__/op-waves-jobs.yml");
99
+ });
100
+
101
+ test("runs-on, environment, variables, id-token and the group's command on each job", () => {
102
+ const { files, jobs } = generateGithubOpWavesPipeline(grouped, { specFile: "waves.json" });
103
+ expect(jobs.map((j) => j.jobName)).toEqual(["wave-1-dev", "wave-2-prod-decide", "wave-2-prod-us-share-1", "wave-2-prod-us-share-2", "wave-2-prod-eu"]);
104
+ const yaml = files[0]!.yaml;
105
+ expect(yaml).toMatch(/wave-1-dev:\n\s+runs-on:\n\s+- self-hosted\n\s+- dev\n/);
106
+ expect(yaml).toMatch(
107
+ /wave-2-prod-decide:\n\s+runs-on: plan\n[\s\S]*?permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/,
108
+ );
109
+ expect(yaml).toMatch(
110
+ /wave-2-prod-us-share-1:\n\s+runs-on:\n\s+group: prod-apply\n\s+labels:\n\s+- linux\n\s+needs:\n\s+- wave-2-prod-decide\n\s+container: node:22\n\s+environment:\n\s+name: prod-us\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+REGION: us\n\s+CLOUD_TOKEN_AUDIENCE: us.example\n/,
111
+ );
112
+ expect(yaml).toContain("- run: echo us\n - name: Apply share 1 of job group us of wave 2 (prod)\n run: chant run wave --spec waves.json --wave 2 --group us --share 1\n");
113
+ // A group with no id tokens of its own gets the wave's.
114
+ expect(yaml).toMatch(/wave-2-prod-eu:\n\s+runs-on: eu-apply\n[\s\S]*?name: prod-eu\n\s+permissions:\n\s+contents: write\n\s+id-token: write\n\s+env:\n\s+STAGE: apply\n\s+PLAN_TOKEN_AUDIENCE: plan.example\n/);
115
+ expect(yaml).toContain("run: chant run wave --spec waves.json --wave 2 --group eu\n");
116
+ });
117
+
118
+ test("the spec's runner is the resume and record-plans jobs' too", () => {
119
+ const { files } = generateGithubOpWavesPipeline({ ...spec, runner: "plan", resume: { schedule: "*/10 * * * *" }, waves: spec.waves.map((w) => ({ ...w, approval: "pr-review" as const })) }, { specFile: "waves.json" });
120
+ expect(files.map((f) => f.name)).toEqual(["migrations.yml", "migrations-resume.yml", "migrations-plans.yml"]);
121
+ for (const f of files) expect(f.yaml).not.toContain("ubuntu-latest");
122
+ });
123
+ });
@@ -27,9 +27,16 @@
27
27
  * With `notify` on the spec (#3761), the workflow maps each variable it names
28
28
  * from the repository secret of the same name, so a wave job can post to
29
29
  * Slack, Teams or a signed webhook. `options.variables` wins over the mapping.
30
+ *
31
+ * `runner` (on the spec, a wave or a job group, #3914) is the job's `runs-on`.
32
+ * A wave's `jobs` renders its deciding job with the wave's own settings and a
33
+ * job per group (per share of a group) with the group's: its runner,
34
+ * environment, variables over the wave's, setup after the wave's, and id
35
+ * tokens. A job with id tokens gets `permissions:` of its own, the
36
+ * workflow's plus `id-token: write`, and each audience as `<NAME>_AUDIENCE`.
30
37
  */
31
38
 
32
- import { notifyVariables, opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
39
+ import { notifyVariables, opWaveGroupOf, opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
33
40
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
34
41
  import {
35
42
  assertEnvironment,
@@ -38,8 +45,9 @@ import {
38
45
  dirnameOf,
39
46
  environmentDoc,
40
47
  GITHUB_JOB_TOKEN,
41
- GITHUB_RUNNER,
48
+ githubRunsOn,
42
49
  githubSecretEnv,
50
+ idTokenAudienceEnv,
43
51
  nonEmpty,
44
52
  pushBranches,
45
53
  setupStepDoc,
@@ -66,27 +74,38 @@ export function buildGithubOpWavesDoc(
66
74
  for (const wave of spec.waves) {
67
75
  assertSetupSteps(`${spec.name}/${wave.name}`, wave.setup ?? []);
68
76
  if (wave.environment) assertEnvironment(`${spec.name}/${wave.name}`, wave.environment);
77
+ for (const group of wave.jobs ?? []) {
78
+ assertSetupSteps(`${spec.name}/${wave.name}/${group.name}`, group.setup ?? []);
79
+ if (group.environment) assertEnvironment(`${spec.name}/${wave.name}/${group.name}`, group.environment);
80
+ }
69
81
  }
82
+ const permissions = { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) };
70
83
 
71
84
  const jobsDoc: Record<string, unknown> = {};
72
85
  for (const job of jobs) {
73
86
  const wave = spec.waves[job.wave - 1]!;
87
+ // #3914: a job group's job runs with the group's settings over the wave's.
88
+ const group = opWaveGroupOf(spec, job);
74
89
  const steps: Array<Record<string, unknown>> = [checkoutStep(true)];
75
90
  if (job.kind === "share") {
76
91
  steps.push(downloadArtifactStep(`Download wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), dirnameOf(job.record!)));
77
92
  }
78
- steps.push(...(wave.setup ?? []).map(setupStepDoc), ...runSteps(options.beforeScript ?? []));
79
- steps.push({ name: describeJob(job.kind, job.wave, wave.name, job.share), run: job.command.join(" ") });
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(" ") });
80
95
  steps.push(...runSteps(options.extraScript ?? []));
81
96
  if (job.kind === "decide") {
82
97
  steps.push(keepRecordStep(`Keep wave ${job.wave}'s decision`, opWaveRecordArtifact(spec, job.wave), job.record!));
83
98
  }
99
+ const environment = group?.environment ?? wave.environment;
100
+ const idTokens = group?.idTokens ?? wave.idTokens;
84
101
  jobsDoc[job.jobName] = {
85
- "runs-on": GITHUB_RUNNER,
102
+ "runs-on": githubRunsOn(group?.runner ?? wave.runner ?? spec.runner),
86
103
  ...(job.needs.length > 0 ? { needs: job.needs } : {}),
87
104
  container: image,
88
- ...(wave.environment ? { environment: environmentDoc(wave.environment) } : {}),
89
- ...nonEmpty("env", wave.variables),
105
+ ...(environment ? { environment: environmentDoc(environment) } : {}),
106
+ // #3914: a job's own permissions replace the workflow's, so they repeat them.
107
+ ...(idTokens ? { permissions: { ...permissions, "id-token": "write" } } : {}),
108
+ ...nonEmpty("env", group || idTokens ? { ...wave.variables, ...group?.variables, ...idTokenAudienceEnv(idTokens) } : wave.variables),
90
109
  steps,
91
110
  };
92
111
  }
@@ -98,7 +117,7 @@ export function buildGithubOpWavesDoc(
98
117
  on: { push: { branches: pushBranches(spec.branches) }, workflow_dispatch: {} },
99
118
  ...nonEmpty("env", env),
100
119
  concurrency: concurrency(spec.name),
101
- permissions: { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) },
120
+ permissions,
102
121
  jobsDoc,
103
122
  };
104
123
  return {
@@ -123,7 +142,7 @@ function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): Git
123
142
  concurrency: concurrency(`${spec.name}-resume`, true),
124
143
  // contents: read fetches chant/lifecycle; actions: write re-runs a waiting run.
125
144
  permissions: { contents: "read", actions: "write" },
126
- jobsDoc: { resume: { "runs-on": GITHUB_RUNNER, container: options.image ?? OP_WAVES_IMAGE, steps } },
145
+ jobsDoc: { resume: { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
127
146
  };
128
147
  }
129
148
 
@@ -138,12 +157,13 @@ function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): Gith
138
157
  concurrency: concurrency(spec.name + "-plans-${{ github.event.number }}", true),
139
158
  // contents: write pushes the record to chant/lifecycle.
140
159
  permissions: { contents: "write" },
141
- jobsDoc: { "record-plans": { "runs-on": GITHUB_RUNNER, container: options.image ?? OP_WAVES_IMAGE, steps } },
160
+ jobsDoc: { "record-plans": { "runs-on": githubRunsOn(spec.runner), container: options.image ?? OP_WAVES_IMAGE, steps } },
142
161
  };
143
162
  }
144
163
 
145
- function describeJob(kind: "wave" | "decide" | "share", wave: number, name: string, share?: number): string {
164
+ function describeJob(kind: "wave" | "decide" | "share", wave: number, name: string, share?: number, group?: string): string {
146
165
  if (kind === "decide") return `Plan wave ${wave} (${name}) and decide its gate`;
166
+ if (group !== undefined) return `Apply ${share !== undefined ? `share ${share} of ` : ""}job group ${group} of wave ${wave} (${name})`;
147
167
  if (kind === "share") return `Apply share ${share} of wave ${wave} (${name})`;
148
168
  return `Plan, gate and apply wave ${wave} (${name})`;
149
169
  }