@intentius/chant-lexicon-github 0.116.0 → 0.118.0

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.
@@ -13,6 +13,16 @@
13
13
  * deciding job uploads its decision, even when it waits, and each share job
14
14
  * downloads it. The forgejo generator reuses {@link buildGithubOpWavesDoc}
15
15
  * and applies its dialect.
16
+ *
17
+ * With `resume` on the spec (#3683), a second workflow, `<name>-resume.yml`,
18
+ * runs `chant run resume --op <name>` on that schedule: it re-runs the failed
19
+ * jobs of a run whose waiting wave has been approved since, with the job's
20
+ * token and `actions: write`.
21
+ *
22
+ * When a wave's approval is `pr-review` (#3684), the workflow also asks for
23
+ * `pull-requests: read`, so a wave can read the merged pull request's
24
+ * reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
25
+ * --record-plans` on each pull request to record the digests its head plans.
16
26
  */
17
27
  import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
18
28
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
@@ -25,6 +35,8 @@ export declare function opWaveRecordArtifact(spec: Pick<OpWavesSpec, "name">, wa
25
35
  export declare function buildGithubOpWavesDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): {
26
36
  doc: GithubOpPipelineDoc;
27
37
  jobs: OpWavesPipelineResult["jobs"];
38
+ resumeDoc?: GithubOpPipelineDoc;
39
+ plansDoc?: GithubOpPipelineDoc;
28
40
  };
29
41
  /** Render an Op waves spec as one GitHub Actions workflow, `<name>.yml`. */
30
42
  export declare function generateGithubOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult;
@@ -1 +1 @@
1
- {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAc,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAC5E,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAE9F,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,wBAAwB,CAAC;AAEhC,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAY,CAAC;AAExC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAOD,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,CAAA;CAAE,CA0DnE;AAQD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAGvH"}
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAoC,KAAK,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAClG,OAAO,KAAK,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAE9F,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,wBAAwB,CAAC;AAEhC,yFAAyF;AACzF,eAAO,MAAM,cAAc,YAAY,CAAC;AAExC,oEAAoE;AACpE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAOD,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;AA4CD,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": "f949c0f2abdd7cdae723d3c5e0b478955312e5519455f20f466f85267a324ebd",
4
+ "manifest.json": "a0a217bf875f2999a78da91e06dd0fe33165e52b6077800a76654f75c9e8354d",
5
5
  "meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
6
6
  "types/index.d.ts": "bdd54a4145a39a9dd4f7bbc8c500191acb6bc9057a87da0040475afac84db397",
7
7
  "rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
@@ -78,5 +78,5 @@
78
78
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
79
79
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
80
80
  },
81
- "composite": "2903da07a25bb2dc474145fdbefc01607599f0f8e64039b5c6428964a8c15b45"
81
+ "composite": "db3b08fa2fb816149260d6ea9f821c842bbd6317bcb181114380fbb14fd7a734"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.116.0",
3
+ "version": "0.118.0",
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.116.0",
3
+ "version": "0.118.0",
4
4
  "description": "GitHub Actions lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -61,7 +61,7 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.116.0",
64
+ "@intentius/chant": "^0.118.0",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -30,4 +30,23 @@ describe("generateGithubOpWavesPipeline", () => {
30
30
  expect(yaml).toContain("name: migrations-wave-2-decision");
31
31
  expect(yaml).toContain("environment:\n name: production");
32
32
  });
33
+
34
+ test("resume renders a scheduled workflow that re-runs approved waves with actions: write (#3683)", () => {
35
+ const { files } = generateGithubOpWavesPipeline({ ...spec, resume: { schedule: "*/10 * * * *" } }, { specFile: "waves.json" });
36
+ expect(files.map((f) => f.name)).toEqual([`${spec.name}.yml`, `${spec.name}-resume.yml`]);
37
+ const yaml = files[1]!.yaml;
38
+ expect(yaml).toContain("- cron: '*/10 * * * *'");
39
+ expect(yaml).toMatch(/permissions:\n\s+contents: read\n\s+actions: write/);
40
+ expect(yaml).toContain(`run: chant run resume --op ${spec.name}`);
41
+ expect(yaml).toContain("GITHUB_TOKEN: '${{ github.token }}'");
42
+ expect(generateGithubOpWavesPipeline(spec, { specFile: "waves.json" }).files).toHaveLength(1);
43
+ });
44
+
45
+ test("a pr-review wave adds a pull request workflow that records the head's plans (#3684)", () => {
46
+ const { files } = generateGithubOpWavesPipeline({ ...spec, waves: spec.waves.map((w, i) => (i === spec.waves.length - 1 ? { ...w, approval: "pr-review" as const } : w)) }, { specFile: "waves.json" });
47
+ expect(files.map((f) => f.name)).toEqual([`${spec.name}.yml`, `${spec.name}-plans.yml`]);
48
+ expect(files[1]!.yaml).toContain("pull_request:");
49
+ expect(files[1]!.yaml).toContain("run: chant run wave --spec waves.json --record-plans");
50
+ expect(files[0]!.yaml).toMatch(/permissions:\n\s+contents: write\n\s+pull-requests: read/);
51
+ });
33
52
  });
@@ -13,9 +13,19 @@
13
13
  * deciding job uploads its decision, even when it waits, and each share job
14
14
  * downloads it. The forgejo generator reuses {@link buildGithubOpWavesDoc}
15
15
  * and applies its dialect.
16
+ *
17
+ * With `resume` on the spec (#3683), a second workflow, `<name>-resume.yml`,
18
+ * runs `chant run resume --op <name>` on that schedule: it re-runs the failed
19
+ * jobs of a run whose waiting wave has been approved since, with the job's
20
+ * token and `actions: write`.
21
+ *
22
+ * When a wave's approval is `pr-review` (#3684), the workflow also asks for
23
+ * `pull-requests: read`, so a wave can read the merged pull request's
24
+ * reviews, and a second workflow, `<name>-plans.yml`, runs `chant run wave
25
+ * --record-plans` on each pull request to record the digests its head plans.
16
26
  */
17
27
 
18
- import { opWaveJobs, type OpWavesSpec } from "@intentius/chant/op/op-waves";
28
+ import { opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
19
29
  import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
20
30
  import { actionRef } from "../action-pins";
21
31
  import {
@@ -42,9 +52,10 @@ function dirOf(path: string): string {
42
52
  export function buildGithubOpWavesDoc(
43
53
  spec: OpWavesSpec,
44
54
  options: OpWavesPipelineOptions,
45
- ): { doc: GithubOpPipelineDoc; jobs: OpWavesPipelineResult["jobs"] } {
55
+ ): { doc: GithubOpPipelineDoc; jobs: OpWavesPipelineResult["jobs"]; resumeDoc?: GithubOpPipelineDoc; plansDoc?: GithubOpPipelineDoc } {
46
56
  const jobs = opWaveJobs(spec, options.specFile);
47
57
  const image = options.image ?? OP_WAVES_IMAGE;
58
+ const byReview = spec.waves.some((w) => w.approval === "pr-review");
48
59
  for (const wave of spec.waves) {
49
60
  assertSetupSteps(`${spec.name}/${wave.name}`, wave.setup ?? []);
50
61
  if (wave.environment) assertEnvironment(`${spec.name}/${wave.name}`, wave.environment);
@@ -96,10 +107,51 @@ export function buildGithubOpWavesDoc(
96
107
  on: { push: { branches: spec.branches?.length ? spec.branches : ["main"] }, workflow_dispatch: {} },
97
108
  ...(options.variables && Object.keys(options.variables).length > 0 ? { env: options.variables } : {}),
98
109
  concurrency: { group: spec.name, "cancel-in-progress": false },
99
- permissions: { contents: "write" },
110
+ permissions: { contents: "write", ...(byReview ? { "pull-requests": "read" } : {}) },
100
111
  jobsDoc,
101
112
  };
102
- return { doc, jobs };
113
+ return {
114
+ doc,
115
+ jobs,
116
+ ...(spec.resume ? { resumeDoc: buildResumeDoc(spec, options) } : {}),
117
+ ...(byReview ? { plansDoc: buildPlansDoc(spec, options) } : {}),
118
+ };
119
+ }
120
+
121
+ /** The scheduled resume workflow (#3683). */
122
+ function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
123
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
124
+ for (const line of options.beforeScript ?? []) steps.push({ run: line });
125
+ steps.push({
126
+ name: `Resume the waves of ${spec.name} whose approval arrived`,
127
+ run: opWavesResumeCommand(spec).join(" "),
128
+ env: { GITHUB_TOKEN: "${{ github.token }}" },
129
+ });
130
+ return {
131
+ name: `${spec.name}-resume`,
132
+ on: { schedule: [{ cron: spec.resume!.schedule }], workflow_dispatch: {} },
133
+ concurrency: { group: `${spec.name}-resume`, "cancel-in-progress": true },
134
+ // contents: read fetches chant/lifecycle; actions: write re-runs a waiting run.
135
+ permissions: { contents: "read", actions: "write" },
136
+ jobsDoc: { resume: { "runs-on": "ubuntu-latest", container: options.image ?? OP_WAVES_IMAGE, steps } },
137
+ };
138
+ }
139
+
140
+ /** The pull request workflow a `pr-review` wave needs (#3684): record the digests each head plans. */
141
+ function buildPlansDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
142
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } }];
143
+ for (const step of spec.waves[0]?.setup ?? []) steps.push({ ...step });
144
+ for (const line of options.beforeScript ?? []) steps.push({ run: line });
145
+ steps.push({ name: "Plan every wave at the head and record its digests", run: ["chant", "run", "wave", "--spec", options.specFile, "--record-plans"].join(" ") });
146
+ return {
147
+ name: `${spec.name}-plans`,
148
+ on: { pull_request: { branches: spec.branches?.length ? spec.branches : ["main"] } },
149
+ ...(options.variables && Object.keys(options.variables).length > 0 ? { env: options.variables } : {}),
150
+ concurrency: { group: spec.name + "-plans-${{ github.event.number }}", "cancel-in-progress": true },
151
+ // contents: write pushes the record to chant/lifecycle.
152
+ permissions: { contents: "write" },
153
+ jobsDoc: { "record-plans": { "runs-on": "ubuntu-latest", container: options.image ?? OP_WAVES_IMAGE, steps } },
154
+ };
103
155
  }
104
156
 
105
157
  function describeJob(kind: "wave" | "decide" | "share", wave: number, name: string, share?: number): string {
@@ -110,6 +162,9 @@ function describeJob(kind: "wave" | "decide" | "share", wave: number, name: stri
110
162
 
111
163
  /** Render an Op waves spec as one GitHub Actions workflow, `<name>.yml`. */
112
164
  export function generateGithubOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult {
113
- const { doc, jobs } = buildGithubOpWavesDoc(spec, options);
114
- return { files: [{ name: `${spec.name}.yml`, yaml: emitOpPipelineYAML(doc) }], jobs };
165
+ const { doc, jobs, resumeDoc, plansDoc } = buildGithubOpWavesDoc(spec, options);
166
+ const files = [{ name: `${spec.name}.yml`, yaml: emitOpPipelineYAML(doc) }];
167
+ if (resumeDoc) files.push({ name: `${spec.name}-resume.yml`, yaml: emitOpPipelineYAML(resumeDoc) });
168
+ if (plansDoc) files.push({ name: `${spec.name}-plans.yml`, yaml: emitOpPipelineYAML(plansDoc) });
169
+ return { files, jobs };
115
170
  }