@intentius/chant-lexicon-github 0.18.12 → 0.18.14
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.
- package/dist/components/generate-pipeline.d.ts +59 -0
- package/dist/components/generate-pipeline.d.ts.map +1 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/generate-pipeline.test.ts +279 -0
- package/src/components/generate-pipeline.ts +175 -0
- package/src/plugin.ts +3 -0
|
@@ -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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "5e6635f5045cb84027fe7fff1b39b2cceec7fc8336463bb644a32a07822e5930",
|
|
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": "
|
|
70
|
+
"composite": "cd7930421da0be3f6164dcbfa2a988b2f9b2c84761166ab1c6a37bc97025df5e"
|
|
71
71
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
3
|
+
"version": "0.18.14",
|
|
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.18.
|
|
64
|
+
"@intentius/chant": "^0.18.14",
|
|
65
65
|
"typescript": "^5.9.3"
|
|
66
66
|
}
|
|
67
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[] {
|