@intentius/chant-lexicon-github 0.115.0 → 0.117.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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Op waves → GitHub Actions workflow (#3679).
3
+ *
4
+ * One workflow for the whole spec, on a push to its branches (and by hand),
5
+ * one run at a time. Each job is one of core's `opWaveJobs`: a wave, or a wide
6
+ * wave's deciding job and share jobs, chained by `needs:` so a wave that waits
7
+ * at its gate (exit 3) or fails stops every later wave. Re-running the failed
8
+ * jobs after `chant approve` carries on from that wave.
9
+ *
10
+ * Every job checks out the full history, because the runner reads the gate
11
+ * policy from the spec file at the base commit and records pending gates on
12
+ * `chant/lifecycle`, which is why the workflow asks for `contents: write`. A
13
+ * deciding job uploads its decision, even when it waits, and each share job
14
+ * downloads it. The forgejo generator reuses {@link buildGithubOpWavesDoc}
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
+ import { type OpWavesSpec } from "@intentius/chant/op/op-waves";
23
+ import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
24
+ import { type GithubOpPipelineDoc } from "./generate-op-pipeline.js";
25
+ /** The default image: it carries git, which the runner needs to read the base commit. */
26
+ export declare const OP_WAVES_IMAGE = "node:22";
27
+ /** The artifact a wide wave's deciding job hands its share jobs. */
28
+ export declare function opWaveRecordArtifact(spec: Pick<OpWavesSpec, "name">, wave: number): string;
29
+ /** The workflow document, before emission, and the jobs it holds. */
30
+ export declare function buildGithubOpWavesDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): {
31
+ doc: GithubOpPipelineDoc;
32
+ jobs: OpWavesPipelineResult["jobs"];
33
+ resumeDoc?: GithubOpPipelineDoc;
34
+ };
35
+ /** Render an Op waves spec as one GitHub Actions workflow, `<name>.yml`. */
36
+ export declare function generateGithubOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult;
37
+ //# sourceMappingURL=generate-op-waves-pipeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generate-op-waves-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-waves-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;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,CAAA;CAAE,CA0DpG;AA2BD,4EAA4E;AAC5E,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB,GAAG,qBAAqB,CAKvH"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "54bbeec529a529e3ddf864c759cc27e02ebc975452e5f12f6e379a0f9527da72",
4
+ "manifest.json": "80cd8fc60162a6b30fc79202a8992ab61d34b4d245c93553194e489340c32abd",
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": "cd58401e7371b5b3af04db5f6d0b5025c96bb3ed91f5ec7c26eb15949eaf9208"
81
+ "composite": "56d98c73201dd7cf4c5a2ca9153dfd1324ae6cc21adf35f9a956e389f98f51b8"
82
82
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.115.0",
3
+ "version": "0.117.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AA6B7F,eAAO,MAAM,YAAY,EAAE,aA8U1B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AA8B7F,eAAO,MAAM,YAAY,EAAE,aAgV1B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.115.0",
3
+ "version": "0.117.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.115.0",
64
+ "@intentius/chant": "^0.117.0",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { OpWavesSpec } from "@intentius/chant/op/op-waves";
3
+ import { generateGithubOpWavesPipeline } from "./generate-op-waves-pipeline";
4
+
5
+ const spec: OpWavesSpec = {
6
+ name: "migrations",
7
+ op: "migrate",
8
+ plan: ["./bin/plan", "{target}", "{plan}"],
9
+ waves: [
10
+ { name: "dev", runs: [{ target: "dev" }], gate: "never" },
11
+ { name: "prod", runs: [{ target: "a" }, { target: "b" }], gate: "on-destructive", shares: 2, environment: { name: "production" } },
12
+ ],
13
+ };
14
+
15
+ describe("generateGithubOpWavesPipeline", () => {
16
+ test("one workflow; each wave needs the one before, and a wide wave hands its decision to its shares", () => {
17
+ const { files, jobs } = generateGithubOpWavesPipeline(spec, { specFile: "waves.json" });
18
+ expect(files.map((f) => f.name)).toEqual(["migrations.yml"]);
19
+ expect(jobs.map((j) => j.jobName)).toEqual(["wave-1-dev", "wave-2-prod-decide", "wave-2-prod-share-1", "wave-2-prod-share-2"]);
20
+ const yaml = files[0]!.yaml;
21
+ expect(yaml).toContain("push:\n branches:\n - main");
22
+ expect(yaml).toContain("permissions:\n contents: write");
23
+ expect(yaml).toContain("fetch-depth: 0");
24
+ expect(yaml).toContain("run: chant run wave --spec waves.json --wave 1\n");
25
+ expect(yaml).toMatch(/wave-2-prod-decide:\n\s+runs-on: ubuntu-latest\n\s+needs:\n\s+- wave-1-dev/);
26
+ expect(yaml).toContain("run: chant run wave --spec waves.json --wave 2 --decide");
27
+ expect(yaml).toMatch(/wave-2-prod-share-2:\n\s+runs-on: ubuntu-latest\n\s+needs:\n\s+- wave-2-prod-decide/);
28
+ expect(yaml).toContain("run: chant run wave --spec waves.json --wave 2 --share 2");
29
+ expect(yaml).toContain("uses: actions/upload-artifact@v4");
30
+ expect(yaml).toContain("name: migrations-wave-2-decision");
31
+ expect(yaml).toContain("environment:\n name: production");
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
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Op waves → GitHub Actions workflow (#3679).
3
+ *
4
+ * One workflow for the whole spec, on a push to its branches (and by hand),
5
+ * one run at a time. Each job is one of core's `opWaveJobs`: a wave, or a wide
6
+ * wave's deciding job and share jobs, chained by `needs:` so a wave that waits
7
+ * at its gate (exit 3) or fails stops every later wave. Re-running the failed
8
+ * jobs after `chant approve` carries on from that wave.
9
+ *
10
+ * Every job checks out the full history, because the runner reads the gate
11
+ * policy from the spec file at the base commit and records pending gates on
12
+ * `chant/lifecycle`, which is why the workflow asks for `contents: write`. A
13
+ * deciding job uploads its decision, even when it waits, and each share job
14
+ * downloads it. The forgejo generator reuses {@link buildGithubOpWavesDoc}
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
+
23
+ import { opWaveJobs, opWavesResumeCommand, type OpWavesSpec } from "@intentius/chant/op/op-waves";
24
+ import type { OpWavesPipelineOptions, OpWavesPipelineResult } from "@intentius/chant/lexicon";
25
+ import { actionRef } from "../action-pins";
26
+ import {
27
+ assertEnvironment,
28
+ assertSetupSteps,
29
+ emitOpPipelineYAML,
30
+ type GithubOpPipelineDoc,
31
+ } from "./generate-op-pipeline";
32
+
33
+ /** The default image: it carries git, which the runner needs to read the base commit. */
34
+ export const OP_WAVES_IMAGE = "node:22";
35
+
36
+ /** The artifact a wide wave's deciding job hands its share jobs. */
37
+ export function opWaveRecordArtifact(spec: Pick<OpWavesSpec, "name">, wave: number): string {
38
+ return `${spec.name}-wave-${wave}-decision`;
39
+ }
40
+
41
+ function dirOf(path: string): string {
42
+ const i = path.lastIndexOf("/");
43
+ return i < 0 ? "." : path.slice(0, i);
44
+ }
45
+
46
+ /** The workflow document, before emission, and the jobs it holds. */
47
+ export function buildGithubOpWavesDoc(
48
+ spec: OpWavesSpec,
49
+ options: OpWavesPipelineOptions,
50
+ ): { doc: GithubOpPipelineDoc; jobs: OpWavesPipelineResult["jobs"]; resumeDoc?: GithubOpPipelineDoc } {
51
+ const jobs = opWaveJobs(spec, options.specFile);
52
+ const image = options.image ?? OP_WAVES_IMAGE;
53
+ for (const wave of spec.waves) {
54
+ assertSetupSteps(`${spec.name}/${wave.name}`, wave.setup ?? []);
55
+ if (wave.environment) assertEnvironment(`${spec.name}/${wave.name}`, wave.environment);
56
+ }
57
+
58
+ const jobsDoc: Record<string, unknown> = {};
59
+ for (const job of jobs) {
60
+ const wave = spec.waves[job.wave - 1]!;
61
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout"), with: { "fetch-depth": 0 } }];
62
+ if (job.kind === "share") {
63
+ steps.push({
64
+ name: `Download wave ${job.wave}'s decision`,
65
+ uses: "actions/download-artifact@v4",
66
+ with: { name: opWaveRecordArtifact(spec, job.wave), path: dirOf(job.record!) },
67
+ });
68
+ }
69
+ for (const step of wave.setup ?? []) steps.push({ ...step });
70
+ for (const line of options.beforeScript ?? []) steps.push({ run: line });
71
+ steps.push({ name: describeJob(job.kind, job.wave, wave.name, job.share), run: job.command.join(" ") });
72
+ for (const line of options.extraScript ?? []) steps.push({ run: line });
73
+ if (job.kind === "decide") {
74
+ steps.push({
75
+ name: `Keep wave ${job.wave}'s decision`,
76
+ if: "always()",
77
+ uses: "actions/upload-artifact@v4",
78
+ with: {
79
+ name: opWaveRecordArtifact(spec, job.wave),
80
+ path: job.record!,
81
+ "if-no-files-found": "ignore",
82
+ "include-hidden-files": true,
83
+ overwrite: true,
84
+ },
85
+ });
86
+ }
87
+ jobsDoc[job.jobName] = {
88
+ "runs-on": "ubuntu-latest",
89
+ ...(job.needs.length > 0 ? { needs: job.needs } : {}),
90
+ container: image,
91
+ ...(wave.environment
92
+ ? { environment: { name: wave.environment.name, ...(wave.environment.url ? { url: wave.environment.url } : {}) } }
93
+ : {}),
94
+ ...(wave.variables && Object.keys(wave.variables).length > 0 ? { env: wave.variables } : {}),
95
+ steps,
96
+ };
97
+ }
98
+
99
+ const doc: GithubOpPipelineDoc = {
100
+ name: spec.name,
101
+ on: { push: { branches: spec.branches?.length ? spec.branches : ["main"] }, workflow_dispatch: {} },
102
+ ...(options.variables && Object.keys(options.variables).length > 0 ? { env: options.variables } : {}),
103
+ concurrency: { group: spec.name, "cancel-in-progress": false },
104
+ permissions: { contents: "write" },
105
+ jobsDoc,
106
+ };
107
+ return { doc, jobs, ...(spec.resume ? { resumeDoc: buildResumeDoc(spec, options) } : {}) };
108
+ }
109
+
110
+ /** The scheduled resume workflow (#3683). */
111
+ function buildResumeDoc(spec: OpWavesSpec, options: OpWavesPipelineOptions): GithubOpPipelineDoc {
112
+ const steps: Array<Record<string, unknown>> = [{ uses: actionRef("actions/checkout") }];
113
+ for (const line of options.beforeScript ?? []) steps.push({ run: line });
114
+ steps.push({
115
+ name: `Resume the waves of ${spec.name} whose approval arrived`,
116
+ run: opWavesResumeCommand(spec).join(" "),
117
+ env: { GITHUB_TOKEN: "${{ github.token }}" },
118
+ });
119
+ return {
120
+ name: `${spec.name}-resume`,
121
+ on: { schedule: [{ cron: spec.resume!.schedule }], workflow_dispatch: {} },
122
+ concurrency: { group: `${spec.name}-resume`, "cancel-in-progress": true },
123
+ // contents: read fetches chant/lifecycle; actions: write re-runs a waiting run.
124
+ permissions: { contents: "read", actions: "write" },
125
+ jobsDoc: { resume: { "runs-on": "ubuntu-latest", container: options.image ?? OP_WAVES_IMAGE, steps } },
126
+ };
127
+ }
128
+
129
+ function describeJob(kind: "wave" | "decide" | "share", wave: number, name: string, share?: number): string {
130
+ if (kind === "decide") return `Plan wave ${wave} (${name}) and decide its gate`;
131
+ if (kind === "share") return `Apply share ${share} of wave ${wave} (${name})`;
132
+ return `Plan, gate and apply wave ${wave} (${name})`;
133
+ }
134
+
135
+ /** Render an Op waves spec as one GitHub Actions workflow, `<name>.yml`. */
136
+ export function generateGithubOpWavesPipeline(spec: OpWavesSpec, options: OpWavesPipelineOptions): OpWavesPipelineResult {
137
+ const { doc, jobs, resumeDoc } = buildGithubOpWavesDoc(spec, options);
138
+ const files = [{ name: `${spec.name}.yml`, yaml: emitOpPipelineYAML(doc) }];
139
+ if (resumeDoc) files.push({ name: `${spec.name}-resume.yml`, yaml: emitOpPipelineYAML(resumeDoc) });
140
+ return { files, jobs };
141
+ }
package/src/plugin.ts CHANGED
@@ -34,6 +34,7 @@ import { GitHubActionsGenerator } from "./import/generator";
34
34
  import { githubContextTools } from "./mcp/context-tools";
35
35
  import { generateGithubPipeline } from "./components/generate-pipeline";
36
36
  import { generateGithubOpPipeline } from "./components/generate-op-pipeline";
37
+ import { generateGithubOpWavesPipeline } from "./components/generate-op-waves-pipeline";
37
38
 
38
39
  export const githubPlugin: LexiconPlugin = {
39
40
  name: "github",
@@ -42,6 +43,8 @@ export const githubPlugin: LexiconPlugin = {
42
43
  generateComponentPipeline: (components, options) => generateGithubPipeline(components, options),
43
44
  // Generate mode, Op counterpart (#927): synthesize one cron-triggered workflow per scheduled Op.
44
45
  generateOpPipeline: (ops, options) => generateGithubOpPipeline(ops, options),
46
+ // Ordered waves of Op runs, each behind one gate (#3679).
47
+ generateOpWavesPipeline: (spec, options) => generateGithubOpWavesPipeline(spec, options),
45
48
  serializer: githubSerializer,
46
49
 
47
50
  lintRules(): LintRule[] {