@intentius/chant-lexicon-github 0.79.0 → 0.80.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.
- package/dist/components/generate-op-pipeline.d.ts +6 -0
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.d.ts +10 -0
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/generate-op-pipeline.test.ts +4 -1
- package/src/components/generate-op-pipeline.ts +8 -0
- package/src/components/generate-pipeline.test.ts +91 -1
- package/src/components/generate-pipeline.ts +79 -0
|
@@ -88,6 +88,12 @@ export interface GithubOpPipelineDoc {
|
|
|
88
88
|
* rather than only in a build warning.
|
|
89
89
|
*/
|
|
90
90
|
header?: string[];
|
|
91
|
+
/**
|
|
92
|
+
* The workflow's `name:` (#2580): the Op's name, so the Actions UI lists
|
|
93
|
+
* the workflow by its Op instead of its file name. The forgejo dialect
|
|
94
|
+
* does not copy it, so forgejo output is unchanged.
|
|
95
|
+
*/
|
|
96
|
+
name?: string;
|
|
91
97
|
/**
|
|
92
98
|
* The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
|
|
93
99
|
* (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EACnD,aAAa,EAEb,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EACT,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAmUD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,GAAG,IAAI,CAwBhF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AA6BD;;;;;GAKG;AACH,MAAM,WAAW,wCAAwC;IACvD;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,EACrC,eAAe,GAAE,wCAA6C,GAC7D;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,
|
|
1
|
+
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsEG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EACnD,aAAa,EAEb,aAAa,EACb,gBAAgB,IAAI,sBAAsB,EAC1C,WAAW,EACX,SAAS,EACT,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B,2DAA2D;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,oBAAoB;IACnC,4FAA4F;IAC5F,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,mBAAmB,CAAC;CAC1B;AAmUD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,IAAI,CA8BzE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,aAAa,GAAG,IAAI,CAwBhF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EAC1C,OAAO,EAAE,SAAS,GACjB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAqClC;AA6BD;;;;;GAKG;AACH,MAAM,WAAW,wCAAwC;IACvD;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,EACrC,eAAe,GAAE,wCAA6C,GAC7D;IAAE,KAAK,EAAE,oBAAoB,EAAE,CAAC;IAAC,IAAI,EAAE,aAAa,EAAE,CAAA;CAAE,CA8F1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAgBnE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
* uses, and matches gitlab's artifact-passing 1:1 in intent — only the
|
|
36
36
|
* transport differs (explicit upload/download steps vs. GitLab's implicit
|
|
37
37
|
* `needs:` artifact passing).
|
|
38
|
+
* - With `options.promoteTo` (#2575), one more job needs every component
|
|
39
|
+
* job and runs `chant components promote`. A promote publishes from the
|
|
40
|
+
* build archive on disk, so each component job uploads the files its
|
|
41
|
+
* build steps wrote and the promote job downloads them to the same paths.
|
|
38
42
|
*
|
|
39
43
|
* Cross-cutting changes (e.g. "sign every image before deploy") are made by
|
|
40
44
|
* editing `GenerateGithubOptions.extraScript`/`beforeScript` (or the
|
|
@@ -80,6 +84,12 @@ export interface GithubPipelineDoc {
|
|
|
80
84
|
/** Every generated job, for the machine-readable view. */
|
|
81
85
|
jobs: GeneratedJob[];
|
|
82
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The directory `actions/upload-artifact` roots a set of files at: the
|
|
89
|
+
* deepest directory that holds all of them. Downloading into it puts each
|
|
90
|
+
* file back at the path it was uploaded from.
|
|
91
|
+
*/
|
|
92
|
+
export declare function artifactRoot(paths: string[]): string;
|
|
83
93
|
/**
|
|
84
94
|
* Synthesize a `.github/workflows/*.yml` pipeline from a set of components:
|
|
85
95
|
* one job per component, `needs:` expressing the wave-ordered dependency DAG
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAEjG,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;AAE1E;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,6FAA6F;IAC7F,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5B;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,uEAAuE;IACvE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,EAAE,CAAC;CACtB;AAwBD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAWpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA6InB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAS/D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CAGtB"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "7c9fa1fee6182c66eda6e2ea0f281b518e72064c620740702f1c48dfbbdd46f1",
|
|
5
5
|
"meta.json": "c50e0ef3845c370c3ad2210b8bba455980417c0f818aa6ed46e06d4fb8b1b9f9",
|
|
6
6
|
"types/index.d.ts": "bdd54a4145a39a9dd4f7bbc8c500191acb6bc9057a87da0040475afac84db397",
|
|
7
7
|
"rules/deprecated-action-version.ts": "d41e6e532ab7f623af1bee4ac5279fcb2baada7defa1c5d022a5bc71983e8797",
|
|
@@ -77,5 +77,5 @@
|
|
|
77
77
|
"skills/chant-github-patterns.md": "bb3abef289a8fdfcf07d6bb2d7289dcb2f38bc0cb0321ea320b78b45a6f548c0",
|
|
78
78
|
"skills/chant-github-security.md": "aab111cb0871cad30281ce48d7da23663689619351029219e2be019a1a61e394"
|
|
79
79
|
},
|
|
80
|
-
"composite": "
|
|
80
|
+
"composite": "1809da7db679ed9d9380d1fa046864be900a4d0b5e5209152dfd61d5460dde3f"
|
|
81
81
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.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.
|
|
64
|
+
"@intentius/chant": "^0.80.0",
|
|
65
65
|
"typescript": "^5.9.3"
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -597,10 +597,13 @@ describe("generateGithubOpPipeline: a deployment environment on the Op's job (#2
|
|
|
597
597
|
* The exact document a spec with no `environment` emitted before the option
|
|
598
598
|
* existed, produced by the generator at the commit this change branched
|
|
599
599
|
* from. The claim the option makes is that it is additive; this is what
|
|
600
|
-
* makes that claim falsifiable rather than a sentence in a PR body.
|
|
600
|
+
* makes that claim falsifiable rather than a sentence in a PR body. The
|
|
601
|
+
* `name:` line came later, from #2580, for every spec alike.
|
|
601
602
|
*/
|
|
602
603
|
const AUDIT_YAML_BEFORE_2257 =
|
|
603
604
|
[
|
|
605
|
+
"name: actions-audit",
|
|
606
|
+
"",
|
|
604
607
|
"on:",
|
|
605
608
|
" schedule:",
|
|
606
609
|
" - cron: '0 6 * * *'",
|
|
@@ -102,6 +102,12 @@ export interface GithubOpPipelineDoc {
|
|
|
102
102
|
* rather than only in a build warning.
|
|
103
103
|
*/
|
|
104
104
|
header?: string[];
|
|
105
|
+
/**
|
|
106
|
+
* The workflow's `name:` (#2580): the Op's name, so the Actions UI lists
|
|
107
|
+
* the workflow by its Op instead of its file name. The forgejo dialect
|
|
108
|
+
* does not copy it, so forgejo output is unchanged.
|
|
109
|
+
*/
|
|
110
|
+
name?: string;
|
|
105
111
|
/**
|
|
106
112
|
* The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
|
|
107
113
|
* (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
|
|
@@ -713,6 +719,7 @@ export function buildGithubOpPipelineDocs(
|
|
|
713
719
|
for (const line of extraScript) steps.push({ run: line });
|
|
714
720
|
|
|
715
721
|
const doc: GithubOpPipelineDoc = {
|
|
722
|
+
name: spec.name,
|
|
716
723
|
on: onFor(trigger),
|
|
717
724
|
...(options.variables && Object.keys(options.variables).length > 0 ? { env: options.variables } : {}),
|
|
718
725
|
// One run at a time per Op — a slow audit must not overlap its own next
|
|
@@ -771,6 +778,7 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
|
|
|
771
778
|
// one. Absent on github, so an unannotated document is emitted exactly as
|
|
772
779
|
// it was before the field existed.
|
|
773
780
|
if (doc.header && doc.header.length > 0) sections.push(doc.header.join("\n"));
|
|
781
|
+
if (doc.name) sections.push("name: " + emitYAML(doc.name, 0));
|
|
774
782
|
sections.push("on:" + emitYAML(doc.on, 1));
|
|
775
783
|
if (doc.env && Object.keys(doc.env).length > 0) sections.push("env:" + emitYAML(doc.env, 1));
|
|
776
784
|
sections.push("concurrency:" + emitYAML(doc.concurrency, 1));
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { describe, test, expect } from "vitest";
|
|
21
21
|
import { parseYAML } from "@intentius/chant/yaml";
|
|
22
|
-
import { generateGithubPipeline } from "./generate-pipeline";
|
|
22
|
+
import { artifactRoot, generateGithubPipeline } from "./generate-pipeline";
|
|
23
23
|
import { resolveComponentGraph, DependencyCycleError, UnknownDependencyError, type DriverComponent } from "@intentius/chant/components/driver";
|
|
24
24
|
import { searchService } from "@intentius/chant/components/pilots/alb-ecs.pilot";
|
|
25
25
|
import { ordersTable } from "@intentius/chant/components/pilots/dynamodb.pilot";
|
|
@@ -295,3 +295,93 @@ describe("generateGithubPipeline: options", () => {
|
|
|
295
295
|
expect(runLines(jobs["shared-alb"])[0]).toContain("--env production");
|
|
296
296
|
});
|
|
297
297
|
});
|
|
298
|
+
|
|
299
|
+
/** Two services that build and publish, one depending on the other, plus infra that builds nothing. */
|
|
300
|
+
function promotableComponents(): DriverComponent[] {
|
|
301
|
+
const service = (name: string, into: string, dependsOn: string[] = []): DriverComponent => ({
|
|
302
|
+
name,
|
|
303
|
+
dependsOn,
|
|
304
|
+
deploy: [
|
|
305
|
+
{ phase: "Build", steps: [{ kind: "docker-build", context: ".", into }] },
|
|
306
|
+
{ phase: "Publish", steps: [{ kind: "publish-image", from: `archive:${into}`, to: "$env.registry" }] },
|
|
307
|
+
],
|
|
308
|
+
});
|
|
309
|
+
return [
|
|
310
|
+
{ name: "shared-alb", dependsOn: [], deploy: [] },
|
|
311
|
+
service("api", "dist/images/api.tar", ["shared-alb"]),
|
|
312
|
+
service("worker", "worker.tar"),
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
describe("generateGithubPipeline: a promote job (#2575)", () => {
|
|
317
|
+
test("without promoteTo the pipeline is unchanged: no archive upload, no promote job", () => {
|
|
318
|
+
const yaml = generateGithubPipeline(promotableComponents(), { env: "staging" }).yaml;
|
|
319
|
+
expect(yaml).not.toContain("promote");
|
|
320
|
+
expect(yaml).not.toContain("build archive");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("each building job uploads its archive, and the promote job downloads it back to the same path", () => {
|
|
324
|
+
const result = generateGithubPipeline(promotableComponents(), { env: "staging", promoteTo: "prod" });
|
|
325
|
+
const jobs = parsedJobs(result.yaml);
|
|
326
|
+
|
|
327
|
+
const upload = jobs["api"].steps.find((s) => s.name === "Upload api build archive")!;
|
|
328
|
+
expect(upload.uses).toBe("actions/upload-artifact@v4");
|
|
329
|
+
expect(upload.with).toEqual({
|
|
330
|
+
name: "api-archive",
|
|
331
|
+
path: "dist/images/api.tar",
|
|
332
|
+
"if-no-files-found": "error",
|
|
333
|
+
"include-hidden-files": true,
|
|
334
|
+
});
|
|
335
|
+
expect(jobs["shared-alb"].steps.some((s) => s.name?.includes("build archive"))).toBe(false);
|
|
336
|
+
|
|
337
|
+
const promote = jobs["promote-prod"];
|
|
338
|
+
expect(promote.needs).toEqual(["api", "shared-alb", "worker"]);
|
|
339
|
+
expect(promote.container).toBe("node:22-slim");
|
|
340
|
+
const downloads = promote.steps.filter((s) => s.uses === "actions/download-artifact@v4");
|
|
341
|
+
expect(downloads.map((s) => s.with)).toEqual([
|
|
342
|
+
{ name: "api-archive", path: "dist/images" },
|
|
343
|
+
{ name: "worker-archive", path: "." },
|
|
344
|
+
]);
|
|
345
|
+
expect(runLines(promote)).toEqual(["chant components promote --from staging --to prod"]);
|
|
346
|
+
|
|
347
|
+
// The machine-readable views stay one entry per component.
|
|
348
|
+
expect(result.jobs.map((j) => j.component).sort()).toEqual(["api", "shared-alb", "worker"]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("the promote job runs the same beforeScript and extraScript, and promoteCommand replaces its command", () => {
|
|
352
|
+
const result = generateGithubPipeline(promotableComponents(), {
|
|
353
|
+
env: "staging",
|
|
354
|
+
promoteTo: "prod",
|
|
355
|
+
beforeScript: ["npm ci"],
|
|
356
|
+
extraScript: ["echo done"],
|
|
357
|
+
promoteCommand: ["npx", "chant", "components", "promote", "--from", "staging", "--to", "prod"],
|
|
358
|
+
});
|
|
359
|
+
expect(runLines(parsedJobs(result.yaml)["promote-prod"])).toEqual([
|
|
360
|
+
"npm ci",
|
|
361
|
+
"npx chant components promote --from staging --to prod",
|
|
362
|
+
"echo done",
|
|
363
|
+
]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("a build step writing to a path known only at run time is refused", () => {
|
|
367
|
+
const components: DriverComponent[] = [
|
|
368
|
+
{ name: "api", deploy: [{ phase: "Build", steps: [{ kind: "docker-build", into: "$env.archive" }] }] },
|
|
369
|
+
];
|
|
370
|
+
expect(() => generateGithubPipeline(components, { promoteTo: "prod" })).toThrow(/not a literal path/);
|
|
371
|
+
expect(() => generateGithubPipeline(components)).not.toThrow();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("a component whose job name is the promote job's is refused", () => {
|
|
375
|
+
const components: DriverComponent[] = [{ name: "promote-prod", deploy: [] }];
|
|
376
|
+
expect(() => generateGithubPipeline(components, { promoteTo: "prod" })).toThrow(/same name as a component job/);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("artifactRoot is the deepest directory holding every file", () => {
|
|
380
|
+
expect(artifactRoot(["archive"])).toBe(".");
|
|
381
|
+
expect(artifactRoot(["dist/a.tar"])).toBe("dist");
|
|
382
|
+
expect(artifactRoot(["dist/a/x.tar", "dist/b/y.zip"])).toBe("dist");
|
|
383
|
+
expect(artifactRoot(["dist/a.tar", "b.zip"])).toBe(".");
|
|
384
|
+
expect(artifactRoot(["./dist/a.tar"])).toBe("dist");
|
|
385
|
+
expect(artifactRoot(["/tmp/out/a.tar", "/tmp/out/b.tar"])).toBe("/tmp/out");
|
|
386
|
+
});
|
|
387
|
+
});
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
* uses, and matches gitlab's artifact-passing 1:1 in intent — only the
|
|
36
36
|
* transport differs (explicit upload/download steps vs. GitLab's implicit
|
|
37
37
|
* `needs:` artifact passing).
|
|
38
|
+
* - With `options.promoteTo` (#2575), one more job needs every component
|
|
39
|
+
* job and runs `chant components promote`. A promote publishes from the
|
|
40
|
+
* build archive on disk, so each component job uploads the files its
|
|
41
|
+
* build steps wrote and the promote job downloads them to the same paths.
|
|
38
42
|
*
|
|
39
43
|
* Cross-cutting changes (e.g. "sign every image before deploy") are made by
|
|
40
44
|
* editing `GenerateGithubOptions.extraScript`/`beforeScript` (or the
|
|
@@ -46,6 +50,7 @@
|
|
|
46
50
|
|
|
47
51
|
import { emitYAML } from "@intentius/chant/yaml";
|
|
48
52
|
import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
|
|
53
|
+
import { promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
49
54
|
import type {
|
|
50
55
|
ComponentPipelineJob as GeneratedJob,
|
|
51
56
|
ComponentPipelineOptions as GenerateGithubOptions,
|
|
@@ -106,6 +111,29 @@ function artifactName(name: string): string {
|
|
|
106
111
|
return `${name}-outputs`;
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
/** The workflow artifact name a component's build archive is uploaded under, for the promote job (#2575). */
|
|
115
|
+
function archiveArtifactName(name: string): string {
|
|
116
|
+
return `${name}-archive`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The directory `actions/upload-artifact` roots a set of files at: the
|
|
121
|
+
* deepest directory that holds all of them. Downloading into it puts each
|
|
122
|
+
* file back at the path it was uploaded from.
|
|
123
|
+
*/
|
|
124
|
+
export function artifactRoot(paths: string[]): string {
|
|
125
|
+
const dirs = paths.map((p) => {
|
|
126
|
+
const parts = p.replace(/\/+$/, "").split("/");
|
|
127
|
+
parts.pop();
|
|
128
|
+
return parts.filter((part) => part !== "" && part !== ".");
|
|
129
|
+
});
|
|
130
|
+
const common: string[] = [];
|
|
131
|
+
for (let i = 0; dirs.every((d) => i < d.length && d[i] === dirs[0][i]); i++) common.push(dirs[0][i]);
|
|
132
|
+
const absolute = paths.every((p) => p.startsWith("/"));
|
|
133
|
+
if (common.length === 0) return absolute ? "/" : ".";
|
|
134
|
+
return (absolute ? "/" : "") + common.join("/");
|
|
135
|
+
}
|
|
136
|
+
|
|
109
137
|
/**
|
|
110
138
|
* Synthesize a `.github/workflows/*.yml` pipeline from a set of components:
|
|
111
139
|
* one job per component, `needs:` expressing the wave-ordered dependency DAG
|
|
@@ -139,6 +167,18 @@ export function buildGithubPipelineDoc(
|
|
|
139
167
|
const dependedUpon = new Set<string>();
|
|
140
168
|
for (const c of components) for (const dep of c.dependsOn ?? []) dependedUpon.add(dep);
|
|
141
169
|
|
|
170
|
+
// A promote job (#2575) runs on its own runner, and a promote publishes from
|
|
171
|
+
// the build archive on disk, so each component job uploads the files its
|
|
172
|
+
// build steps wrote and the promote job downloads them.
|
|
173
|
+
const promoteTo = options.promoteTo;
|
|
174
|
+
const archives = new Map<string, string[]>();
|
|
175
|
+
if (promoteTo !== undefined) {
|
|
176
|
+
for (const c of components) {
|
|
177
|
+
const paths = promoteArchivePaths(c);
|
|
178
|
+
if (paths.length > 0) archives.set(c.name, paths);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
142
182
|
const stages = waves.map((_, i) => `wave-${i + 1}`);
|
|
143
183
|
const jobs: GeneratedJob[] = [];
|
|
144
184
|
const jobNameByComponent = new Map<string, string>();
|
|
@@ -189,6 +229,20 @@ export function buildGithubPipelineDoc(
|
|
|
189
229
|
});
|
|
190
230
|
}
|
|
191
231
|
|
|
232
|
+
const archive = archives.get(name);
|
|
233
|
+
if (archive) {
|
|
234
|
+
steps.push({
|
|
235
|
+
name: `Upload ${name} build archive`,
|
|
236
|
+
uses: "actions/upload-artifact@v4",
|
|
237
|
+
with: {
|
|
238
|
+
name: archiveArtifactName(name),
|
|
239
|
+
path: archive.join("\n"),
|
|
240
|
+
"if-no-files-found": "error",
|
|
241
|
+
"include-hidden-files": true,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
192
246
|
const jobProps: Record<string, unknown> = {
|
|
193
247
|
"runs-on": "ubuntu-latest",
|
|
194
248
|
...(needs.length > 0 ? { needs } : {}),
|
|
@@ -199,6 +253,31 @@ export function buildGithubPipelineDoc(
|
|
|
199
253
|
}
|
|
200
254
|
});
|
|
201
255
|
|
|
256
|
+
if (promoteTo !== undefined) {
|
|
257
|
+
const promoteJob = `promote-${toJobName(promoteTo)}`;
|
|
258
|
+
if (promoteJob in jobsDoc) {
|
|
259
|
+
throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
|
|
260
|
+
}
|
|
261
|
+
const command = options.promoteCommand ?? ["chant", "components", "promote", "--from", env, "--to", promoteTo];
|
|
262
|
+
const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
|
|
263
|
+
for (const [name, paths] of archives) {
|
|
264
|
+
steps.push({
|
|
265
|
+
name: `Download ${name} build archive`,
|
|
266
|
+
uses: "actions/download-artifact@v4",
|
|
267
|
+
with: { name: archiveArtifactName(name), path: artifactRoot(paths) },
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
for (const line of beforeScript) steps.push({ run: line });
|
|
271
|
+
steps.push({ run: command.join(" ") });
|
|
272
|
+
for (const line of extraScript) steps.push({ run: line });
|
|
273
|
+
jobsDoc[promoteJob] = {
|
|
274
|
+
"runs-on": "ubuntu-latest",
|
|
275
|
+
needs: [...jobNameByComponent.values()].sort(),
|
|
276
|
+
container: image,
|
|
277
|
+
steps,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
202
281
|
return {
|
|
203
282
|
name: `chant-components-${env}`,
|
|
204
283
|
environment: env,
|