@intentius/chant-lexicon-github 0.18.11 → 0.18.13

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,59 @@
1
+ /**
2
+ * Generate mode — component → GitHub Actions workflow YAML (#891, epic #885).
3
+ *
4
+ * The github counterpart to the gitlab generator (`../../gitlab/src/components/generate-pipeline.ts`,
5
+ * #563/#688): the same `chant build --components --generate <lexicon>` seam,
6
+ * synthesizing a **thin** `.github/workflows/*.yml` from the discovered
7
+ * component graph instead of interpreting it directly.
8
+ *
9
+ * The generated workflow is a trigger, not the deploy logic:
10
+ * - Ordering + parallel-safe waves are resolved once, generically, by
11
+ * `resolveComponentGraph` (core's driver) — the exact function the local
12
+ * interpret driver and the gitlab generator both use. This module does not
13
+ * re-derive or duplicate that graph logic.
14
+ * - GitHub Actions has no `stage` concept, so wave ordering is expressed
15
+ * entirely through `needs:` edges between jobs: one job per component,
16
+ * with `needs:` pointing at the jobs for its direct `dependsOn` entries.
17
+ * Independent components therefore run in parallel, and dependents wait
18
+ * on their dependencies via GitHub's own `needs:` scheduling.
19
+ * `ComponentPipelineResult.stages` is still populated with the wave-ordered
20
+ * names (one per wave) purely for parity with gitlab's machine-readable
21
+ * `--format json` view — GitHub's YAML itself has no `stages:` section.
22
+ * - Each job's trigger step is exactly one invocation that hands off to the
23
+ * component's own composition (`chant run --components <name> ...` by
24
+ * default) — never inlined build/publish/apply steps. The deploy logic
25
+ * lives in the component's `deploy` phases and the capabilities they
26
+ * reference, not in this YAML.
27
+ * - A component that something else depends on can't hand its resolved
28
+ * stack outputs to a dependent in-memory, since each job is a separate
29
+ * GitHub Actions runner. It dumps its outputs to a file and uploads that
30
+ * file as a workflow artifact (`actions/upload-artifact`); each direct
31
+ * dependent downloads it (`actions/download-artifact`) and seeds from it,
32
+ * so a `stackOutput()` / `@<dep>.publish.*` reference still resolves even
33
+ * though the producer ran in a different job. This mirrors the dump/seed
34
+ * model `cli-support.ts` (`runComponents`'s `componentOutputs`) already
35
+ * uses, and matches gitlab's artifact-passing 1:1 in intent — only the
36
+ * transport differs (explicit upload/download steps vs. GitLab's implicit
37
+ * `needs:` artifact passing).
38
+ *
39
+ * Cross-cutting changes (e.g. "sign every image before deploy") are made by
40
+ * editing `GenerateGithubOptions.extraScript`/`beforeScript` (or the
41
+ * component's own composition) ONCE here — never per generated job. See
42
+ * `generate-pipeline.test.ts`'s "cross-cutting change" cases for a
43
+ * demonstration: one generator-option edit reflects in every job without
44
+ * touching the component declarations.
45
+ */
46
+ import { type DriverComponent } from "@intentius/chant/components/driver";
47
+ import type { ComponentPipelineJob as GeneratedJob, ComponentPipelineOptions as GenerateGithubOptions, ComponentPipelineResult as GenerateGithubResult } from "@intentius/chant/lexicon";
48
+ export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
49
+ /**
50
+ * Synthesize a `.github/workflows/*.yml` pipeline from a set of components:
51
+ * one job per component, `needs:` expressing the wave-ordered dependency DAG
52
+ * from `resolveComponentGraph`. Throws `DependencyCycleError`/
53
+ * `UnknownDependencyError` (from core's driver) exactly like the interpret
54
+ * driver and the gitlab generator do, since all three consume the same graph
55
+ * resolution. Wired into core's generate mode via the github lexicon plugin's
56
+ * `generateComponentPipeline` (../plugin.ts).
57
+ */
58
+ export declare function generateGithubPipeline(components: DriverComponent[], options?: GenerateGithubOptions): GenerateGithubResult;
59
+ //# sourceMappingURL=generate-pipeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AACjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAChD,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;AAmB1E;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAyFtB"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "cef1e1427168eee87c3cb670ddec0cb4884458354faab869d59864df084538a4",
4
+ "manifest.json": "2069048f6398b48670abbfab59d1d912a1af6d055e3fb2de7768a47b1da06ba9",
5
5
  "meta.json": "798c5f5f37b0174756d2451299acfdc95c014f11d06d32b9153bbdfa9f3a580c",
6
6
  "types/index.d.ts": "39c0791f0e58025bcef82e0f7a92240508f899fc2771d5dd51b2369b4d330f13",
7
7
  "rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
@@ -67,5 +67,5 @@
67
67
  "skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
68
68
  "skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
69
69
  },
70
- "composite": "25693795d5519d222229fa5e6a482379fe7cc523e3437632a598083b29e00271"
70
+ "composite": "493d6a781c8f97667ae56406133bd29af39bd1357d8e38c28133dd3dc1527858"
71
71
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.18.11",
3
+ "version": "0.18.13",
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;AAEH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AA2B7F,eAAO,MAAM,YAAY,EAAE,aA6S1B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AA4B7F,eAAO,MAAM,YAAY,EAAE,aA+S1B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.18.11",
3
+ "version": "0.18.13",
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,7 @@
61
61
  "typescript": "^5.9.3"
62
62
  },
63
63
  "peerDependencies": {
64
- "@intentius/chant": "^0.18.11"
64
+ "@intentius/chant": "^0.18.13",
65
+ "typescript": "^5.9.3"
65
66
  }
66
67
  }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Tests for generate mode's component → GitHub Actions workflow YAML
3
+ * synthesis (#891, epic #885). Mirrors the gitlab generator's own test suite
4
+ * (`lexicons/gitlab/src/components/generate-pipeline.test.ts`) so both
5
+ * providers prove the same seam contract:
6
+ *
7
+ * 1. A component set produces a structurally valid GitHub Actions workflow
8
+ * (parses back via `../yaml.ts`'s `parseYAML`, with `on:` + a `jobs:` map
9
+ * carrying one entry per component, each job with `runs-on:`/`steps:`).
10
+ * 2. Jobs map onto components in wave order — a component's job depends
11
+ * (`needs:`) only on its own `dependsOn` edges, never on unrelated jobs,
12
+ * and lands in the same wave `resolveComponentGraph` (the driver's own
13
+ * graph resolution) computes — even though GitHub Actions has no `stage`
14
+ * concept, so ordering is expressed purely through `needs:`.
15
+ * 3. A cross-cutting generator change (e.g. "sign every image before
16
+ * deploy") is a single edit to the generator's options — reflected in
17
+ * every job's steps — never a per-component edit.
18
+ */
19
+
20
+ import { describe, test, expect } from "vitest";
21
+ import { parseYAML } from "@intentius/chant/yaml";
22
+ import { generateGithubPipeline } from "./generate-pipeline";
23
+ import { resolveComponentGraph, DependencyCycleError, UnknownDependencyError, type DriverComponent } from "@intentius/chant/components/driver";
24
+ import { searchService } from "@intentius/chant/components/pilots/alb-ecs.pilot";
25
+ import { ordersTable } from "@intentius/chant/components/pilots/dynamodb.pilot";
26
+
27
+ /** Real pilot components (#555) plus the shared-alb infra `search-service` depends on, as a realistic multi-wave input. */
28
+ function pilotComponents(): DriverComponent[] {
29
+ return [
30
+ { name: ordersTable.name, dependsOn: ordersTable.dependsOn, deploy: ordersTable.deploy },
31
+ { name: "shared-alb", dependsOn: [], deploy: [] },
32
+ { name: searchService.name, dependsOn: searchService.dependsOn, deploy: searchService.deploy },
33
+ ];
34
+ }
35
+
36
+ interface ParsedStep {
37
+ name?: string;
38
+ uses?: string;
39
+ run?: string;
40
+ with?: Record<string, unknown>;
41
+ }
42
+
43
+ interface ParsedJob {
44
+ "runs-on"?: string;
45
+ container?: string;
46
+ needs?: string[];
47
+ steps: ParsedStep[];
48
+ }
49
+
50
+ function parsedJobs(yaml: string): Record<string, ParsedJob> {
51
+ const parsed = parseYAML(yaml);
52
+ return (parsed.jobs ?? {}) as Record<string, ParsedJob>;
53
+ }
54
+
55
+ function runLines(job: ParsedJob): string[] {
56
+ return job.steps.filter((s) => typeof s.run === "string").map((s) => s.run as string);
57
+ }
58
+
59
+ describe("generateGithubPipeline: cross-stack output threading (artifacts)", () => {
60
+ test("a depended-upon component uploads its outputs as an artifact; its dependents download and seed from it", () => {
61
+ // search-service dependsOn shared-alb; orders-table is depended on by nothing.
62
+ const jobs = parsedJobs(generateGithubPipeline(pilotComponents()).yaml);
63
+
64
+ // Producer: shared-alb hands its resolved outputs to a separate downstream
65
+ // job, so it dumps them and uploads the file as a workflow artifact.
66
+ const shared = jobs["shared-alb"];
67
+ expect(runLines(shared).some((line) => line.includes("--dump-outputs shared-alb.outputs.json"))).toBe(true);
68
+ const uploadStep = shared.steps.find((s) => s.uses === "actions/upload-artifact@v4");
69
+ expect(uploadStep?.with).toEqual({ name: "shared-alb-outputs", path: "shared-alb.outputs.json" });
70
+
71
+ // Consumer: search-service downloads shared-alb's uploaded artifact and
72
+ // seeds from the dumped file so its stackOutput() references resolve.
73
+ const svc = jobs["search-service"];
74
+ const downloadStep = svc.steps.find((s) => s.uses === "actions/download-artifact@v4");
75
+ expect(downloadStep?.with).toEqual({ name: "shared-alb-outputs", path: "." });
76
+ expect(runLines(svc).some((line) => line.includes("--seed-outputs shared-alb.outputs.json"))).toBe(true);
77
+
78
+ // A component nothing depends on neither dumps nor uploads an artifact.
79
+ const orders = jobs["orders-table"];
80
+ expect(runLines(orders).some((line) => line.includes("--dump-outputs"))).toBe(false);
81
+ expect(orders.steps.some((s) => s.uses === "actions/upload-artifact@v4")).toBe(false);
82
+ expect(orders.steps.some((s) => s.uses === "actions/download-artifact@v4")).toBe(false);
83
+ expect(runLines(orders).some((line) => line.includes("--seed-outputs"))).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe("generateGithubPipeline: structurally valid YAML", () => {
88
+ test("produces YAML with an on: trigger and one job per component under jobs:", () => {
89
+ const result = generateGithubPipeline(pilotComponents(), { env: "staging" });
90
+
91
+ const parsed = parseYAML(result.yaml);
92
+ expect(parsed.on).toEqual({ workflow_dispatch: {} });
93
+ expect(result.stages).toEqual(["wave-1", "wave-2"]);
94
+
95
+ const jobs = parsedJobs(result.yaml);
96
+ for (const name of ["orders-table", "shared-alb", "search-service"]) {
97
+ const job = jobs[name];
98
+ expect(job).toBeDefined();
99
+ expect(job["runs-on"]).toBe("ubuntu-latest");
100
+ expect(typeof job.container).toBe("string");
101
+ expect(Array.isArray(job.steps)).toBe(true);
102
+ expect(job.steps.length).toBeGreaterThan(0);
103
+ // Every job starts with a checkout step — a job runs on a fresh runner
104
+ // with no repo present, unlike GitLab's auto-cloned workspace.
105
+ expect(job.steps[0].uses).toBe("actions/checkout@v4");
106
+ }
107
+ });
108
+
109
+ test("every job's trigger step is a single thin invocation, not inlined deploy steps", () => {
110
+ const result = generateGithubPipeline(pilotComponents(), { env: "staging" });
111
+ const jobs = parsedJobs(result.yaml);
112
+
113
+ for (const job of result.jobs) {
114
+ const props = jobs[job.jobName];
115
+ const lines = runLines(props);
116
+ // The thin trigger: hands off to the component via `chant run`, carrying
117
+ // no build/publish/apply/cfn-deploy/ecs-update-service keywords — those
118
+ // verbs live in the component's own composition, never in the YAML.
119
+ expect(lines.some((line) => line.includes(`chant run --components ${job.component}`))).toBe(true);
120
+ for (const line of lines) {
121
+ expect(line).not.toMatch(/docker build|docker push|aws cloudformation|ecs update-service/);
122
+ }
123
+ }
124
+ });
125
+
126
+ test("an empty component set produces a pipeline with no jobs", () => {
127
+ const result = generateGithubPipeline([]);
128
+ expect(result.stages).toEqual([]);
129
+ expect(result.jobs).toEqual([]);
130
+ const jobs = parsedJobs(result.yaml);
131
+ expect(Object.keys(jobs)).toHaveLength(0);
132
+ });
133
+
134
+ test("propagates a dependency cycle error the same way the interpret driver's own graph resolution does", () => {
135
+ const cyclical: DriverComponent[] = [
136
+ { name: "a", dependsOn: ["b"], deploy: [] },
137
+ { name: "b", dependsOn: ["a"], deploy: [] },
138
+ ];
139
+ expect(() => generateGithubPipeline(cyclical)).toThrow(DependencyCycleError);
140
+ });
141
+
142
+ test("propagates an unknown-dependency error for a dangling dependsOn", () => {
143
+ const dangling: DriverComponent[] = [{ name: "a", dependsOn: ["ghost"], deploy: [] }];
144
+ expect(() => generateGithubPipeline(dangling)).toThrow(UnknownDependencyError);
145
+ });
146
+ });
147
+
148
+ describe("generateGithubPipeline: jobs map to components in wave order", () => {
149
+ test("independent components share a wave; a dependent lands in a strictly later wave", () => {
150
+ const components = pilotComponents();
151
+ const { waves } = resolveComponentGraph(components);
152
+ const result = generateGithubPipeline(components);
153
+
154
+ // Same wave count/membership as the driver's own graph resolution — no
155
+ // separate, divergent ordering logic in the generator.
156
+ expect(result.stages).toHaveLength(waves.length);
157
+
158
+ const stageOf = (name: string) => result.jobs.find((j) => j.component === name)!.stage;
159
+ expect(stageOf("orders-table")).toBe(stageOf("shared-alb")); // independent, same wave
160
+ expect(result.stages.indexOf(stageOf("search-service"))).toBeGreaterThan(
161
+ result.stages.indexOf(stageOf("shared-alb")),
162
+ ); // dependent, later wave
163
+ });
164
+
165
+ test("a job's needs: are exactly its component's dependsOn edges, no more and no less", () => {
166
+ const result = generateGithubPipeline(pilotComponents());
167
+
168
+ const ordersJob = result.jobs.find((j) => j.component === "orders-table")!;
169
+ const albJob = result.jobs.find((j) => j.component === "shared-alb")!;
170
+ const searchJob = result.jobs.find((j) => j.component === "search-service")!;
171
+
172
+ expect(ordersJob.needs).toEqual([]);
173
+ expect(albJob.needs).toEqual([]);
174
+ expect(searchJob.needs).toEqual(["shared-alb"]);
175
+
176
+ // needs: renders in the YAML too, not just the result object.
177
+ const jobs = parsedJobs(generateGithubPipeline(pilotComponents()).yaml);
178
+ expect(jobs["search-service"].needs).toEqual(["shared-alb"]);
179
+ expect(jobs["orders-table"].needs ?? []).toEqual([]);
180
+ });
181
+
182
+ test("a three-wave fan-out produces three waves in dependency order", () => {
183
+ const components: DriverComponent[] = [
184
+ { name: "base", dependsOn: [], deploy: [] },
185
+ { name: "middle", dependsOn: ["base"], deploy: [] },
186
+ { name: "top", dependsOn: ["middle"], deploy: [] },
187
+ ];
188
+ const result = generateGithubPipeline(components);
189
+
190
+ expect(result.stages).toEqual(["wave-1", "wave-2", "wave-3"]);
191
+ expect(result.jobs.find((j) => j.component === "base")!.stage).toBe("wave-1");
192
+ expect(result.jobs.find((j) => j.component === "middle")!.stage).toBe("wave-2");
193
+ expect(result.jobs.find((j) => j.component === "top")!.stage).toBe("wave-3");
194
+ });
195
+ });
196
+
197
+ describe("generateGithubPipeline: a cross-cutting change is one generator edit, not per-pipeline", () => {
198
+ test("adding extraScript (e.g. image signing) appears in every job with no per-component changes", () => {
199
+ const components = pilotComponents();
200
+
201
+ const before = generateGithubPipeline(components, { env: "production" });
202
+ const beforeJobs = parsedJobs(before.yaml);
203
+ for (const job of before.jobs) {
204
+ expect(runLines(beforeJobs[job.jobName])).not.toContain("cosign sign --yes $IMAGE_REF");
205
+ }
206
+
207
+ // Simulates the cross-cutting change from the epic's worked example
208
+ // ("sign every image"): ONE edit to the generator's options, applied
209
+ // uniformly, with the component declarations themselves untouched.
210
+ const after = generateGithubPipeline(components, {
211
+ env: "production",
212
+ extraScript: ["cosign sign --yes $IMAGE_REF"],
213
+ });
214
+
215
+ const afterJobs = parsedJobs(after.yaml);
216
+ expect(after.jobs.length).toBe(before.jobs.length);
217
+ for (const job of after.jobs) {
218
+ expect(runLines(afterJobs[job.jobName])).toContain("cosign sign --yes $IMAGE_REF");
219
+ }
220
+ });
221
+
222
+ test("changing the trigger command (runCommand) updates every job's script uniformly", () => {
223
+ const components = pilotComponents();
224
+ const result = generateGithubPipeline(components, {
225
+ runCommand: ["chant", "run", "--components", "{name}", "--env", "staging", "--temporal"],
226
+ });
227
+ const jobs = parsedJobs(result.yaml);
228
+
229
+ for (const job of result.jobs) {
230
+ const lines = runLines(jobs[job.jobName]);
231
+ // The runCommand prefix reflects in every job; output-threading flags
232
+ // (--seed-outputs/--dump-outputs) may be appended per the dependency graph.
233
+ expect(lines[0].startsWith(`chant run --components ${job.component} --env staging --temporal`)).toBe(true);
234
+ }
235
+ });
236
+
237
+ test("changing beforeScript (e.g. a registry login) prepends to every job uniformly", () => {
238
+ const components = pilotComponents();
239
+ const result = generateGithubPipeline(components, {
240
+ beforeScript: ["echo $REGISTRY_PASSWORD | docker login -u $REGISTRY_USER --password-stdin $REGISTRY"],
241
+ });
242
+ const jobs = parsedJobs(result.yaml);
243
+
244
+ for (const job of result.jobs) {
245
+ const lines = runLines(jobs[job.jobName]);
246
+ expect(lines[0]).toMatch(/docker login/);
247
+ }
248
+ });
249
+ });
250
+
251
+ describe("generateGithubPipeline: options", () => {
252
+ test("emits a top-level env: block when variables are provided", () => {
253
+ const result = generateGithubPipeline(pilotComponents(), {
254
+ variables: { CHANT_ENV: "staging" },
255
+ });
256
+ const parsed = parseYAML(result.yaml);
257
+ expect(parsed.env).toEqual({ CHANT_ENV: "staging" });
258
+ });
259
+
260
+ test("omits the env: block when no variables are provided", () => {
261
+ const result = generateGithubPipeline(pilotComponents());
262
+ const parsed = parseYAML(result.yaml);
263
+ expect(parsed.env).toBeUndefined();
264
+ });
265
+
266
+ test("uses a custom container image for every job when provided", () => {
267
+ const result = generateGithubPipeline(pilotComponents(), { image: "chant/cli:latest" });
268
+ const jobs = parsedJobs(result.yaml);
269
+ for (const job of result.jobs) {
270
+ expect(jobs[job.jobName].container).toBe("chant/cli:latest");
271
+ }
272
+ });
273
+
274
+ test("defaults env to production when not provided", () => {
275
+ const result = generateGithubPipeline(pilotComponents());
276
+ const jobs = parsedJobs(result.yaml);
277
+ expect(runLines(jobs["shared-alb"])[0]).toContain("--env production");
278
+ });
279
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Generate mode — component → GitHub Actions workflow YAML (#891, epic #885).
3
+ *
4
+ * The github counterpart to the gitlab generator (`../../gitlab/src/components/generate-pipeline.ts`,
5
+ * #563/#688): the same `chant build --components --generate <lexicon>` seam,
6
+ * synthesizing a **thin** `.github/workflows/*.yml` from the discovered
7
+ * component graph instead of interpreting it directly.
8
+ *
9
+ * The generated workflow is a trigger, not the deploy logic:
10
+ * - Ordering + parallel-safe waves are resolved once, generically, by
11
+ * `resolveComponentGraph` (core's driver) — the exact function the local
12
+ * interpret driver and the gitlab generator both use. This module does not
13
+ * re-derive or duplicate that graph logic.
14
+ * - GitHub Actions has no `stage` concept, so wave ordering is expressed
15
+ * entirely through `needs:` edges between jobs: one job per component,
16
+ * with `needs:` pointing at the jobs for its direct `dependsOn` entries.
17
+ * Independent components therefore run in parallel, and dependents wait
18
+ * on their dependencies via GitHub's own `needs:` scheduling.
19
+ * `ComponentPipelineResult.stages` is still populated with the wave-ordered
20
+ * names (one per wave) purely for parity with gitlab's machine-readable
21
+ * `--format json` view — GitHub's YAML itself has no `stages:` section.
22
+ * - Each job's trigger step is exactly one invocation that hands off to the
23
+ * component's own composition (`chant run --components <name> ...` by
24
+ * default) — never inlined build/publish/apply steps. The deploy logic
25
+ * lives in the component's `deploy` phases and the capabilities they
26
+ * reference, not in this YAML.
27
+ * - A component that something else depends on can't hand its resolved
28
+ * stack outputs to a dependent in-memory, since each job is a separate
29
+ * GitHub Actions runner. It dumps its outputs to a file and uploads that
30
+ * file as a workflow artifact (`actions/upload-artifact`); each direct
31
+ * dependent downloads it (`actions/download-artifact`) and seeds from it,
32
+ * so a `stackOutput()` / `@<dep>.publish.*` reference still resolves even
33
+ * though the producer ran in a different job. This mirrors the dump/seed
34
+ * model `cli-support.ts` (`runComponents`'s `componentOutputs`) already
35
+ * uses, and matches gitlab's artifact-passing 1:1 in intent — only the
36
+ * transport differs (explicit upload/download steps vs. GitLab's implicit
37
+ * `needs:` artifact passing).
38
+ *
39
+ * Cross-cutting changes (e.g. "sign every image before deploy") are made by
40
+ * editing `GenerateGithubOptions.extraScript`/`beforeScript` (or the
41
+ * component's own composition) ONCE here — never per generated job. See
42
+ * `generate-pipeline.test.ts`'s "cross-cutting change" cases for a
43
+ * demonstration: one generator-option edit reflects in every job without
44
+ * touching the component declarations.
45
+ */
46
+
47
+ import { emitYAML } from "@intentius/chant/yaml";
48
+ import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
49
+ import type {
50
+ ComponentPipelineJob as GeneratedJob,
51
+ ComponentPipelineOptions as GenerateGithubOptions,
52
+ ComponentPipelineResult as GenerateGithubResult,
53
+ } from "@intentius/chant/lexicon";
54
+
55
+ export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
56
+
57
+ /** GitHub Actions job ids must match `[a-zA-Z_][a-zA-Z0-9_-]*`; component names are already kebab-case in every fixture, but normalize defensively (mirrors gitlab's `toJobName`). */
58
+ function toJobName(componentName: string): string {
59
+ return componentName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
60
+ }
61
+
62
+ const DEFAULT_IMAGE = "node:22-slim";
63
+
64
+ /** The file a producer dumps its resolved outputs to, and a dependent seeds from — one per component, matching gitlab's naming so both generators produce interchangeable artifacts. */
65
+ function outputsFile(name: string): string {
66
+ return `${name}.outputs.json`;
67
+ }
68
+
69
+ /** The workflow artifact name a producer's dumped outputs are uploaded under. */
70
+ function artifactName(name: string): string {
71
+ return `${name}-outputs`;
72
+ }
73
+
74
+ /**
75
+ * Synthesize a `.github/workflows/*.yml` pipeline from a set of components:
76
+ * one job per component, `needs:` expressing the wave-ordered dependency DAG
77
+ * from `resolveComponentGraph`. Throws `DependencyCycleError`/
78
+ * `UnknownDependencyError` (from core's driver) exactly like the interpret
79
+ * driver and the gitlab generator do, since all three consume the same graph
80
+ * resolution. Wired into core's generate mode via the github lexicon plugin's
81
+ * `generateComponentPipeline` (../plugin.ts).
82
+ */
83
+ export function generateGithubPipeline(
84
+ components: DriverComponent[],
85
+ options: GenerateGithubOptions = {},
86
+ ): GenerateGithubResult {
87
+ const env = options.env ?? "production";
88
+ const image = options.image ?? DEFAULT_IMAGE;
89
+ const runCommand = options.runCommand ?? ["chant", "run", "--components", "{name}", "--env", env];
90
+ const beforeScript = options.beforeScript ?? [];
91
+ const extraScript = options.extraScript ?? [];
92
+
93
+ const { waves } = resolveComponentGraph(components);
94
+ const byName = new Map(components.map((c) => [c.name, c]));
95
+
96
+ // Components that something else depends on must hand their resolved outputs
97
+ // (stack outputs, published artifact refs) to their dependents, which run as
98
+ // separate jobs on separate runners. Each such producer dumps its outputs to
99
+ // a file and uploads it as a workflow artifact; each dependent downloads that
100
+ // artifact and seeds from it, so a `stackOutput()` / `@<dep>.publish.*`
101
+ // reference resolves even though the producer ran in a different job.
102
+ // Without this, a single-component job has no in-memory outputs for its
103
+ // dependencies — see epic #551 / the adopt-alb-services example.
104
+ const dependedUpon = new Set<string>();
105
+ for (const c of components) for (const dep of c.dependsOn ?? []) dependedUpon.add(dep);
106
+
107
+ const stages = waves.map((_, i) => `wave-${i + 1}`);
108
+ const jobs: GeneratedJob[] = [];
109
+ const jobNameByComponent = new Map<string, string>();
110
+ for (const wave of waves) {
111
+ for (const name of wave) jobNameByComponent.set(name, toJobName(name));
112
+ }
113
+
114
+ const jobsDoc: Record<string, unknown> = {};
115
+
116
+ waves.forEach((wave, waveIndex) => {
117
+ const stage = stages[waveIndex];
118
+ for (const name of wave) {
119
+ const component = byName.get(name)!;
120
+ const jobName = jobNameByComponent.get(name)!;
121
+ const needs = (component.dependsOn ?? []).map((dep) => jobNameByComponent.get(dep)!).sort();
122
+
123
+ jobs.push({ jobName, component: name, stage, needs });
124
+
125
+ // Build the run invocation, then append output-threading flags: seed from
126
+ // each dependency's dumped outputs (downloaded as an artifact below), and
127
+ // dump this component's own outputs if a dependent will need them.
128
+ const runParts = runCommand.map((part) => part.replace("{name}", name));
129
+ for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
130
+ if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
131
+
132
+ // One step per script line — mirrors gitlab's `script:` array of
133
+ // discrete shell lines, rather than a single multi-line `run:` block, so
134
+ // each line is independently inspectable (and machine-parseable).
135
+ const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
136
+
137
+ for (const dep of component.dependsOn ?? []) {
138
+ steps.push({
139
+ name: `Download ${dep} outputs`,
140
+ uses: "actions/download-artifact@v4",
141
+ with: { name: artifactName(dep), path: "." },
142
+ });
143
+ }
144
+
145
+ for (const line of beforeScript) steps.push({ run: line });
146
+ steps.push({ run: runParts.join(" ") });
147
+ for (const line of extraScript) steps.push({ run: line });
148
+
149
+ if (dependedUpon.has(name)) {
150
+ steps.push({
151
+ name: `Upload ${name} outputs`,
152
+ uses: "actions/upload-artifact@v4",
153
+ with: { name: artifactName(name), path: outputsFile(name) },
154
+ });
155
+ }
156
+
157
+ const jobProps: Record<string, unknown> = {
158
+ "runs-on": "ubuntu-latest",
159
+ ...(needs.length > 0 ? { needs } : {}),
160
+ container: image,
161
+ steps,
162
+ };
163
+ jobsDoc[jobName] = jobProps;
164
+ }
165
+ });
166
+
167
+ const sections: string[] = [];
168
+ sections.push("on:" + emitYAML({ workflow_dispatch: {} }, 1));
169
+ if (options.variables && Object.keys(options.variables).length > 0) {
170
+ sections.push("env:" + emitYAML(options.variables, 1));
171
+ }
172
+ sections.push("jobs:" + emitYAML(jobsDoc, 1));
173
+
174
+ return { yaml: sections.join("\n\n") + "\n", stages, jobs };
175
+ }
package/src/plugin.ts CHANGED
@@ -31,10 +31,13 @@ import { githubHover } from "./lsp/hover";
31
31
  import { GitHubActionsParser } from "./import/parser";
32
32
  import { GitHubActionsGenerator } from "./import/generator";
33
33
  import { githubContextTools } from "./mcp/context-tools";
34
+ import { generateGithubPipeline } from "./components/generate-pipeline";
34
35
 
35
36
  export const githubPlugin: LexiconPlugin = {
36
37
  name: "github",
37
38
  auditCatalog: () => githubAuditCatalog,
39
+ // Generate mode (#891): synthesize a GitHub Actions workflow from the component graph.
40
+ generateComponentPipeline: (components, options) => generateGithubPipeline(components, options),
38
41
  serializer: githubSerializer,
39
42
 
40
43
  lintRules(): LintRule[] {