@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.
@@ -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,CA6F1D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,mBAAmB,GAAG,MAAM,CAenE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAMxB"}
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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;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;AAmBD;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,iBAAiB,CA0FnB;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"}
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"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "ced24be037a4771c506d5c508c101750dc089c8b26dece0259cfca096577175b",
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": "06574aff8eec2ef2e8bcbd09e2f1012920840d23301938b7e12d286984e12e80"
80
+ "composite": "1809da7db679ed9d9380d1fa046864be900a4d0b5e5209152dfd61d5460dde3f"
81
81
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.79.0",
3
+ "version": "0.80.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitHub",
6
6
  "intrinsics": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-github",
3
- "version": "0.79.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.79.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,