@intentius/chant-lexicon-github 0.80.0 → 0.81.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.
@@ -90,8 +90,8 @@ export interface GithubOpPipelineDoc {
90
90
  header?: string[];
91
91
  /**
92
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.
93
+ * the workflow by its Op instead of its file name. The forgejo generator
94
+ * copies it too (#2601), since Forgejo Actions reads the key the same way.
95
95
  */
96
96
  name?: string;
97
97
  /**
@@ -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,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"}
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,EAET,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,CAkG1D;AAwCD;;;;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"}
@@ -39,6 +39,14 @@
39
39
  * job and runs `chant components promote`. A promote publishes from the
40
40
  * build archive on disk, so each component job uploads the files its
41
41
  * build steps wrote and the promote job downloads them to the same paths.
42
+ * Each component job with a publish step also writes the digest its run
43
+ * recorded (`--digest-file`) to a job output, and the promote job passes
44
+ * it back as `--digest <component>=<digest>` (#2602), so it promotes the
45
+ * release this run built rather than whatever is latest in the source
46
+ * environment.
47
+ * - For a workspace member (`options.member`, #2542) the workflow keeps its
48
+ * triggers, and its `run:` steps start in the member's directory. See
49
+ * `scopeToMember`.
42
50
  *
43
51
  * Cross-cutting changes (e.g. "sign every image before deploy") are made by
44
52
  * editing `GenerateGithubOptions.extraScript`/`beforeScript` (or the
@@ -69,6 +77,8 @@ export interface GithubPipelineDoc {
69
77
  environment: string;
70
78
  /** The `on:` trigger mapping (a bare `workflow_dispatch`). */
71
79
  on: Record<string, unknown>;
80
+ /** The workflow's `defaults:`, set only for a workspace member: its jobs' `run:` steps start in the member's directory (#2542). */
81
+ defaults?: Record<string, unknown>;
72
82
  /**
73
83
  * The `env:` mapping: the caller's `variables`, plus `CHANT_ENV` naming the
74
84
  * deployed environment (#2046) — the machine-readable identity on the
@@ -1 +1 @@
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
+ {"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAGjG,OAAO,KAAK,EACV,oBAAoB,IAAI,YAAY,EACpC,wBAAwB,IAAI,qBAAqB,EACjD,uBAAuB,IAAI,oBAAoB,EAEhD,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,mIAAmI;IACnI,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;;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;AA6BD;;;;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,CAmKnB;AA4CD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAU/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": "7c9fa1fee6182c66eda6e2ea0f281b518e72064c620740702f1c48dfbbdd46f1",
4
+ "manifest.json": "93741ee90f059cadf2ce97f95fca567e0e981e78e2cc50c75a85f614e117583e",
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": "1809da7db679ed9d9380d1fa046864be900a4d0b5e5209152dfd61d5460dde3f"
80
+ "composite": "7df3ad6322ccb0443c9fe7a07446d7a26c61bc4039099b893e9fb0f0c2d15d49"
81
81
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.80.0",
3
+ "version": "0.81.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.80.0",
3
+ "version": "0.81.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.80.0",
64
+ "@intentius/chant": "^0.81.0",
65
65
  "typescript": "^5.9.3"
66
66
  }
67
67
  }
@@ -71,7 +71,7 @@
71
71
  */
72
72
 
73
73
  import { emitYAML } from "@intentius/chant/yaml";
74
- import { resolveOpTrigger } from "@intentius/chant/lexicon";
74
+ import { memberPathFilter, resolveOpTrigger } from "@intentius/chant/lexicon";
75
75
  import type {
76
76
  ComponentPipelineOptions as GenerateGithubOpOptions,
77
77
  OpEnvironment,
@@ -80,6 +80,7 @@ import type {
80
80
  OpPipelineResult as GenerateGithubOpResult,
81
81
  OpSetupStep,
82
82
  OpTrigger,
83
+ PipelineMember,
83
84
  ScheduledOpSpec,
84
85
  } from "@intentius/chant/lexicon";
85
86
 
@@ -104,8 +105,8 @@ export interface GithubOpPipelineDoc {
104
105
  header?: string[];
105
106
  /**
106
107
  * 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.
108
+ * the workflow by its Op instead of its file name. The forgejo generator
109
+ * copies it too (#2601), since Forgejo Actions reads the key the same way.
109
110
  */
110
111
  name?: string;
111
112
  /**
@@ -764,9 +765,51 @@ export function buildGithubOpPipelineDocs(
764
765
  files.push({ name: `${spec.name}.yml`, doc });
765
766
  }
766
767
 
768
+ if (options.member) {
769
+ const member = options.member;
770
+ return { files: files.map((f) => scopeOpFileToMember(f, member)), jobs };
771
+ }
767
772
  return { files, jobs };
768
773
  }
769
774
 
775
+ /**
776
+ * Scope one Op's workflow to a workspace member (#2542, #2524 D19). The file
777
+ * and the workflow take the member's name (`<member>-<op>.yml`, `<member>/<op>`)
778
+ * and so does the concurrency group, which GitHub keys across the whole
779
+ * repository. A `push` or `pull_request` trigger is filtered to the member's
780
+ * paths and this file; a cron trigger has no paths to filter. The Op's own
781
+ * job starts its `run:` steps in the member's directory. The gate-notice job
782
+ * checks nothing out and keeps the default.
783
+ */
784
+ function scopeOpFileToMember(file: GithubOpPipelineFile, member: PipelineMember): GithubOpPipelineFile {
785
+ const name = `${member.name}-${file.name}`;
786
+ const paths = memberPathFilter(member, member.fileDir ? `${member.fileDir}/${name}` : undefined);
787
+ const on: Record<string, unknown> = { ...file.doc.on };
788
+ for (const event of ["push", "pull_request"]) {
789
+ if (event in on) on[event] = { ...(on[event] as Record<string, unknown>), paths };
790
+ }
791
+ const rooted = member.dir === "." || member.dir === "";
792
+ const jobsDoc = rooted
793
+ ? file.doc.jobsDoc
794
+ : Object.fromEntries(
795
+ Object.entries(file.doc.jobsDoc).map(([job, props]) => {
796
+ const { steps, ...rest } = props as Record<string, unknown>;
797
+ return [job, { ...rest, defaults: { run: { "working-directory": member.dir } }, steps }];
798
+ }),
799
+ );
800
+ const group = (file.doc.concurrency as { group?: unknown }).group;
801
+ return {
802
+ name,
803
+ doc: {
804
+ ...file.doc,
805
+ ...(file.doc.name ? { name: `${member.name}/${file.doc.name}` } : {}),
806
+ on,
807
+ concurrency: { ...file.doc.concurrency, ...(typeof group === "string" ? { group: `${member.name}-${group}` } : {}) },
808
+ jobsDoc,
809
+ },
810
+ };
811
+ }
812
+
770
813
  /**
771
814
  * Emit a `GithubOpPipelineDoc`'s `on`/`env`/`concurrency`/`permissions`/`jobs`
772
815
  * mappings as workflow YAML. Shared with the forgejo dialect (#969), which
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A workspace member's pipelines (#2542, #2524 D19): the component workflow
3
+ * and the Op workflows, scoped to one member through `options.member`.
4
+ * Without `options.member` both generators are unchanged, which the other
5
+ * suites in this directory hold.
6
+ */
7
+
8
+ import { describe, test, expect } from "vitest";
9
+ import { parseYAML } from "@intentius/chant/yaml";
10
+ import type { DriverComponent } from "@intentius/chant/components/driver";
11
+ import type { PipelineMember } from "@intentius/chant/lexicon";
12
+ import { buildGithubPipelineDoc, generateGithubPipeline } from "./generate-pipeline";
13
+ import { generateGithubOpPipeline } from "./generate-op-pipeline";
14
+
15
+ const components: DriverComponent[] = [
16
+ { name: "shared-alb", dependsOn: [], deploy: [] },
17
+ {
18
+ name: "api",
19
+ dependsOn: ["shared-alb"],
20
+ deploy: [
21
+ { phase: "Build", steps: [{ kind: "docker-build", context: ".", into: "dist/api.tar" }] },
22
+ { phase: "Publish", steps: [{ kind: "publish-image", from: "archive:dist/api.tar" }] },
23
+ ],
24
+ },
25
+ ];
26
+
27
+ const api: PipelineMember = { name: "api", dir: "services/api", file: ".github/workflows/chant-api-staging.yml" };
28
+
29
+ interface Step {
30
+ uses?: string;
31
+ run?: string;
32
+ with?: Record<string, unknown>;
33
+ }
34
+ type Doc = {
35
+ name: string;
36
+ on: Record<string, { paths?: string[]; branches?: string[] } | null>;
37
+ defaults?: { run: { "working-directory": string } };
38
+ jobs: Record<string, { steps: Step[]; defaults?: { run: { "working-directory": string } } }>;
39
+ };
40
+
41
+ describe("generateGithubPipeline for a workspace member", () => {
42
+ test("the triggers are the plain pipeline's: no push or pull_request is added to a deploy pipeline", () => {
43
+ const member = parseYAML(generateGithubPipeline(components, { env: "staging", member: api }).yaml) as unknown as Doc;
44
+ const plain = parseYAML(generateGithubPipeline(components, { env: "staging" }).yaml) as unknown as Doc;
45
+ expect(member.on).toEqual(plain.on);
46
+ expect(Object.keys(member.on)).toEqual(["workflow_dispatch"]);
47
+ });
48
+
49
+ test("run steps start in the member's directory, and the name carries the member", () => {
50
+ const doc = parseYAML(generateGithubPipeline(components, { env: "staging", member: api }).yaml) as unknown as Doc;
51
+ expect(doc.defaults).toEqual({ run: { "working-directory": "services/api" } });
52
+ expect(doc.name).toBe("chant-components-api-staging");
53
+ });
54
+
55
+ test("artifact paths move under the member's directory, where the run steps write them", () => {
56
+ const doc = parseYAML(generateGithubPipeline(components, { env: "staging", promoteTo: "prod", member: api }).yaml) as unknown as Doc;
57
+ const shared = doc.jobs["shared-alb"].steps.find((s) => s.uses?.startsWith("actions/upload-artifact"));
58
+ expect(shared?.with?.path).toBe("services/api/shared-alb.outputs.json");
59
+ const download = doc.jobs["api"].steps.find((s) => s.uses?.startsWith("actions/download-artifact"));
60
+ expect(download?.with?.path).toBe("services/api");
61
+ const archive = doc.jobs["api"].steps.find((s) => (s.with?.name as string | undefined) === "api-archive");
62
+ expect(archive?.with?.path).toBe("services/api/dist/api.tar");
63
+ const promoteDownload = doc.jobs["promote-prod"].steps.find((s) => s.uses?.startsWith("actions/download-artifact"));
64
+ expect(promoteDownload?.with?.path).toBe("services/api/dist");
65
+ // The digest the promote job pins (#2602) is written and read by run
66
+ // steps, so it needs no path change.
67
+ expect(doc.jobs["api"].steps.some((s) => s.run?.includes("--digest-file api.digest"))).toBe(true);
68
+ expect(doc.jobs["promote-prod"].steps.some((s) => s.run?.includes('--digest "api=${{ needs.api.outputs.digest }}"'))).toBe(true);
69
+ // The run lines themselves are unchanged: they run in the member's directory.
70
+ expect(doc.jobs["api"].steps.some((s) => s.run?.includes("--seed-outputs shared-alb.outputs.json"))).toBe(true);
71
+ });
72
+
73
+ test("a member at the root keeps the root as its directory", () => {
74
+ const root: PipelineMember = { name: "platform", dir: ".", exclude: ["services/api", "examples/a"], file: ".github/workflows/chant-platform-prod.yml" };
75
+ const doc = buildGithubPipelineDoc(components, { env: "prod", member: root });
76
+ expect(doc.on).toEqual({ workflow_dispatch: {} });
77
+ expect(doc.defaults).toBeUndefined();
78
+ const upload = (doc.jobsDoc["shared-alb"] as { steps: Step[] }).steps.find((s) => s.uses?.startsWith("actions/upload-artifact"));
79
+ expect(upload?.with?.path).toBe("shared-alb.outputs.json");
80
+ });
81
+
82
+ test("two members' pipelines differ only where the member does", () => {
83
+ const web: PipelineMember = { name: "web", dir: "apps/web", file: ".github/workflows/chant-web-staging.yml" };
84
+ const a = generateGithubPipeline(components, { env: "staging", member: api });
85
+ const b = generateGithubPipeline(components, { env: "staging", member: web });
86
+ expect(a.yaml).not.toBe(b.yaml);
87
+ expect(a.jobs).toEqual(b.jobs);
88
+ });
89
+ });
90
+
91
+ describe("generateGithubOpPipeline for a workspace member", () => {
92
+ const member: PipelineMember = { name: "api", dir: "services/api", fileDir: ".github/workflows" };
93
+
94
+ test("files, workflow names and concurrency groups carry the member's name", () => {
95
+ const result = generateGithubOpPipeline([{ name: "nightly", trigger: { kind: "cron", schedule: "0 6 * * *" } }], { member });
96
+ expect(result.files.map((f) => f.name)).toEqual(["api-nightly.yml"]);
97
+ const doc = parseYAML(result.files[0].yaml) as unknown as Doc & { concurrency: { group: string } };
98
+ expect(doc.name).toBe("api/nightly");
99
+ expect(doc.concurrency.group).toBe("api-nightly");
100
+ // A cron trigger has no paths to filter.
101
+ expect(JSON.stringify(doc.on)).not.toContain("paths");
102
+ expect(doc.jobs["nightly"].defaults).toEqual({ run: { "working-directory": "services/api" } });
103
+ });
104
+
105
+ test("push and pull_request triggers are filtered to the member and the Op's own file", () => {
106
+ const result = generateGithubOpPipeline(
107
+ [
108
+ { name: "plan", trigger: { kind: "pull_request" } },
109
+ { name: "apply", trigger: { kind: "push" } },
110
+ ],
111
+ { member },
112
+ );
113
+ const plan = parseYAML(result.files[0].yaml) as unknown as Doc;
114
+ expect(plan.on.pull_request).toEqual({ paths: ["services/api/**", ".github/workflows/api-plan.yml"] });
115
+ const apply = parseYAML(result.files[1].yaml) as unknown as Doc;
116
+ expect(apply.on.push).toEqual({ branches: ["main"], paths: ["services/api/**", ".github/workflows/api-apply.yml"] });
117
+ // The gate-notice job checks nothing out, so it keeps the default directory.
118
+ expect(apply.jobs["apply-gate-notice"].defaults).toBeUndefined();
119
+ expect(apply.jobs["apply"].defaults).toEqual({ run: { "working-directory": "services/api" } });
120
+ });
121
+ });
@@ -35,6 +35,7 @@ function pilotComponents(): DriverComponent[] {
35
35
 
36
36
  interface ParsedStep {
37
37
  name?: string;
38
+ id?: string;
38
39
  uses?: string;
39
40
  run?: string;
40
41
  with?: Record<string, unknown>;
@@ -44,6 +45,7 @@ interface ParsedJob {
44
45
  "runs-on"?: string;
45
46
  container?: string;
46
47
  needs?: string[];
48
+ outputs?: Record<string, string>;
47
49
  steps: ParsedStep[];
48
50
  }
49
51
 
@@ -342,7 +344,9 @@ describe("generateGithubPipeline: a promote job (#2575)", () => {
342
344
  { name: "api-archive", path: "dist/images" },
343
345
  { name: "worker-archive", path: "." },
344
346
  ]);
345
- expect(runLines(promote)).toEqual(["chant components promote --from staging --to prod"]);
347
+ expect(runLines(promote)).toEqual([
348
+ 'chant components promote --from staging --to prod --digest "api=${{ needs.api.outputs.digest }}" --digest "worker=${{ needs.worker.outputs.digest }}"',
349
+ ]);
346
350
 
347
351
  // The machine-readable views stay one entry per component.
348
352
  expect(result.jobs.map((j) => j.component).sort()).toEqual(["api", "shared-alb", "worker"]);
@@ -358,7 +362,7 @@ describe("generateGithubPipeline: a promote job (#2575)", () => {
358
362
  });
359
363
  expect(runLines(parsedJobs(result.yaml)["promote-prod"])).toEqual([
360
364
  "npm ci",
361
- "npx chant components promote --from staging --to prod",
365
+ 'npx chant components promote --from staging --to prod --digest "api=${{ needs.api.outputs.digest }}" --digest "worker=${{ needs.worker.outputs.digest }}"',
362
366
  "echo done",
363
367
  ]);
364
368
  });
@@ -372,10 +376,42 @@ describe("generateGithubPipeline: a promote job (#2575)", () => {
372
376
  });
373
377
 
374
378
  test("a component whose job name is the promote job's is refused", () => {
375
- const components: DriverComponent[] = [{ name: "promote-prod", deploy: [] }];
379
+ const components: DriverComponent[] = [
380
+ { name: "promote-prod", deploy: [{ phase: "Publish", steps: [{ kind: "publish-image", from: "archive:a.tar" }] }] },
381
+ ];
376
382
  expect(() => generateGithubPipeline(components, { promoteTo: "prod" })).toThrow(/same name as a component job/);
377
383
  });
378
384
 
385
+ test("each publishing job hands the digest its run recorded to the promote job, which pins it (#2602)", () => {
386
+ const jobs = parsedJobs(generateGithubPipeline(promotableComponents(), { env: "staging", promoteTo: "prod" }).yaml);
387
+
388
+ for (const name of ["api", "worker"]) {
389
+ const job = jobs[name];
390
+ // The run writes `<component>=<digest>` for the release it recorded ...
391
+ expect(runLines(job).find((l) => l.startsWith("chant run"))).toContain(`--digest-file ${name}.digest`);
392
+ // ... a step turns it into a step output, and the job exposes it.
393
+ const record = job.steps.find((s) => s.name === `Record ${name} digest`)!;
394
+ expect(record.id).toBe("digest");
395
+ expect(record.run).toBe(`echo "digest=$(cut -d= -f2- ${name}.digest)" >> "$GITHUB_OUTPUT"`);
396
+ expect(job.outputs).toEqual({ digest: "${{ steps.digest.outputs.digest }}" });
397
+ }
398
+
399
+ // shared-alb publishes nothing, so it records no release and is not pinned.
400
+ expect(jobs["shared-alb"].outputs).toBeUndefined();
401
+ expect(runLines(jobs["shared-alb"]).join("\n")).not.toContain("--digest-file");
402
+
403
+ const promote = runLines(jobs["promote-prod"]).find((l) => l.includes("components promote"))!;
404
+ expect(promote).toContain('--digest "api=${{ needs.api.outputs.digest }}"');
405
+ expect(promote).toContain('--digest "worker=${{ needs.worker.outputs.digest }}"');
406
+ expect(promote).not.toContain("shared-alb=");
407
+ });
408
+
409
+ test("with no component that publishes, there is no release to pin, and generation fails", () => {
410
+ const components: DriverComponent[] = [{ name: "shared-alb", dependsOn: [], deploy: [] }];
411
+ expect(() => generateGithubPipeline(components, { promoteTo: "prod" })).toThrow(/no component has a publish step/);
412
+ expect(() => generateGithubPipeline(components)).not.toThrow();
413
+ });
414
+
379
415
  test("artifactRoot is the deepest directory holding every file", () => {
380
416
  expect(artifactRoot(["archive"])).toBe(".");
381
417
  expect(artifactRoot(["dist/a.tar"])).toBe("dist");
@@ -39,6 +39,14 @@
39
39
  * job and runs `chant components promote`. A promote publishes from the
40
40
  * build archive on disk, so each component job uploads the files its
41
41
  * build steps wrote and the promote job downloads them to the same paths.
42
+ * Each component job with a publish step also writes the digest its run
43
+ * recorded (`--digest-file`) to a job output, and the promote job passes
44
+ * it back as `--digest <component>=<digest>` (#2602), so it promotes the
45
+ * release this run built rather than whatever is latest in the source
46
+ * environment.
47
+ * - For a workspace member (`options.member`, #2542) the workflow keeps its
48
+ * triggers, and its `run:` steps start in the member's directory. See
49
+ * `scopeToMember`.
42
50
  *
43
51
  * Cross-cutting changes (e.g. "sign every image before deploy") are made by
44
52
  * editing `GenerateGithubOptions.extraScript`/`beforeScript` (or the
@@ -50,11 +58,13 @@
50
58
 
51
59
  import { emitYAML } from "@intentius/chant/yaml";
52
60
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
53
- import { promoteArchivePaths } from "@intentius/chant/components/promote";
61
+ import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
62
+ import { memberRepoPath } from "@intentius/chant/lexicon";
54
63
  import type {
55
64
  ComponentPipelineJob as GeneratedJob,
56
65
  ComponentPipelineOptions as GenerateGithubOptions,
57
66
  ComponentPipelineResult as GenerateGithubResult,
67
+ PipelineMember,
58
68
  } from "@intentius/chant/lexicon";
59
69
 
60
70
  export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
@@ -78,6 +88,8 @@ export interface GithubPipelineDoc {
78
88
  environment: string;
79
89
  /** The `on:` trigger mapping (a bare `workflow_dispatch`). */
80
90
  on: Record<string, unknown>;
91
+ /** The workflow's `defaults:`, set only for a workspace member: its jobs' `run:` steps start in the member's directory (#2542). */
92
+ defaults?: Record<string, unknown>;
81
93
  /**
82
94
  * The `env:` mapping: the caller's `variables`, plus `CHANT_ENV` naming the
83
95
  * deployed environment (#2046) — the machine-readable identity on the
@@ -111,6 +123,11 @@ function artifactName(name: string): string {
111
123
  return `${name}-outputs`;
112
124
  }
113
125
 
126
+ /** The file a component job writes the digest its run recorded to, for the promote job (#2602). */
127
+ function digestFile(name: string): string {
128
+ return `${name}.digest`;
129
+ }
130
+
114
131
  /** The workflow artifact name a component's build archive is uploaded under, for the promote job (#2575). */
115
132
  function archiveArtifactName(name: string): string {
116
133
  return `${name}-archive`;
@@ -172,10 +189,17 @@ export function buildGithubPipelineDoc(
172
189
  // build steps wrote and the promote job downloads them.
173
190
  const promoteTo = options.promoteTo;
174
191
  const archives = new Map<string, string[]>();
192
+ // The components the promote job pins to this run's digest (#2602): those
193
+ // with a publish step, the only ones whose deploy records a release.
194
+ const pinned = new Set<string>();
175
195
  if (promoteTo !== undefined) {
176
196
  for (const c of components) {
177
197
  const paths = promoteArchivePaths(c);
178
198
  if (paths.length > 0) archives.set(c.name, paths);
199
+ if (hasPublishStep(c)) pinned.add(c.name);
200
+ }
201
+ if (pinned.size === 0) {
202
+ throw new Error("no component has a publish step, so no deploy records a release for the promote job to promote");
179
203
  }
180
204
  }
181
205
 
@@ -203,6 +227,7 @@ export function buildGithubPipelineDoc(
203
227
  const runParts = runCommand.map((part) => part.replace("{name}", name));
204
228
  for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
205
229
  if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
230
+ if (pinned.has(name)) runParts.push("--digest-file", digestFile(name));
206
231
 
207
232
  // One step per script line — mirrors gitlab's `script:` array of
208
233
  // discrete shell lines, rather than a single multi-line `run:` block, so
@@ -221,6 +246,15 @@ export function buildGithubPipelineDoc(
221
246
  steps.push({ run: runParts.join(" ") });
222
247
  for (const line of extraScript) steps.push({ run: line });
223
248
 
249
+ // The file holds `<component>=<digest>`; the job output holds the digest.
250
+ if (pinned.has(name)) {
251
+ steps.push({
252
+ name: `Record ${name} digest`,
253
+ id: "digest",
254
+ run: `echo "digest=$(cut -d= -f2- ${digestFile(name)})" >> "$GITHUB_OUTPUT"`,
255
+ });
256
+ }
257
+
224
258
  if (dependedUpon.has(name)) {
225
259
  steps.push({
226
260
  name: `Upload ${name} outputs`,
@@ -247,6 +281,7 @@ export function buildGithubPipelineDoc(
247
281
  "runs-on": "ubuntu-latest",
248
282
  ...(needs.length > 0 ? { needs } : {}),
249
283
  container: image,
284
+ ...(pinned.has(name) ? { outputs: { digest: "${{ steps.digest.outputs.digest }}" } } : {}),
250
285
  steps,
251
286
  };
252
287
  jobsDoc[jobName] = jobProps;
@@ -258,7 +293,10 @@ export function buildGithubPipelineDoc(
258
293
  if (promoteJob in jobsDoc) {
259
294
  throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
260
295
  }
261
- const command = options.promoteCommand ?? ["chant", "components", "promote", "--from", env, "--to", promoteTo];
296
+ const command = [...(options.promoteCommand ?? ["chant", "components", "promote", "--from", env, "--to", promoteTo])];
297
+ for (const name of [...pinned].sort()) {
298
+ command.push("--digest", `"${name}=\${{ needs.${jobNameByComponent.get(name)!}.outputs.digest }}"`);
299
+ }
262
300
  const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
263
301
  for (const [name, paths] of archives) {
264
302
  steps.push({
@@ -278,7 +316,7 @@ export function buildGithubPipelineDoc(
278
316
  };
279
317
  }
280
318
 
281
- return {
319
+ const doc: GithubPipelineDoc = {
282
320
  name: `chant-components-${env}`,
283
321
  environment: env,
284
322
  on: { workflow_dispatch: {} },
@@ -287,6 +325,49 @@ export function buildGithubPipelineDoc(
287
325
  stages,
288
326
  jobs,
289
327
  };
328
+ return options.member ? scopeToMember(doc, options.member) : doc;
329
+ }
330
+
331
+ /**
332
+ * Scope a pipeline to one workspace member (#2542, #2524 D19). Its name
333
+ * carries the member's name, and every `run:` step starts in the member's
334
+ * directory through `defaults.run.working-directory`.
335
+ *
336
+ * The triggers stay exactly the plain pipeline's. A deploy pipeline gains no
337
+ * `push` or `pull_request` trigger for a member, and its only trigger,
338
+ * `workflow_dispatch`, takes no `paths:` filter.
339
+ *
340
+ * `working-directory` applies to `run:` steps only. The artifact actions
341
+ * resolve `path` against the repository root, so each upload and download
342
+ * path moves under the member's directory, where the `run:` steps read and
343
+ * write the files.
344
+ */
345
+ function scopeToMember(doc: GithubPipelineDoc, member: PipelineMember): GithubPipelineDoc {
346
+ const rooted = member.dir === "." || member.dir === "";
347
+ const jobsDoc: Record<string, unknown> = {};
348
+ for (const [name, job] of Object.entries(doc.jobsDoc)) {
349
+ const props = job as { steps?: Array<Record<string, unknown>> };
350
+ jobsDoc[name] = rooted || !props.steps ? job : { ...props, steps: props.steps.map((step) => memberArtifactStep(step, member)) };
351
+ }
352
+ return {
353
+ ...doc,
354
+ name: `chant-components-${member.name}-${doc.environment}`,
355
+ ...(rooted ? {} : { defaults: { run: { "working-directory": member.dir } } }),
356
+ jobsDoc,
357
+ };
358
+ }
359
+
360
+ /** An `actions/upload-artifact` or `download-artifact` step with its `path` moved under the member's directory. */
361
+ function memberArtifactStep(step: Record<string, unknown>, member: PipelineMember): Record<string, unknown> {
362
+ const uses = typeof step.uses === "string" ? step.uses : "";
363
+ if (!/^actions\/(upload|download)-artifact@/.test(uses)) return step;
364
+ const withProps = step.with as Record<string, unknown> | undefined;
365
+ if (!withProps || typeof withProps.path !== "string") return step;
366
+ const path = withProps.path
367
+ .split("\n")
368
+ .map((line) => memberRepoPath(member, line))
369
+ .join("\n");
370
+ return { ...step, with: { ...withProps, path } };
290
371
  }
291
372
 
292
373
  /**
@@ -300,6 +381,7 @@ export function emitPipelineYAML(doc: GithubPipelineDoc): string {
300
381
  if (doc.env && Object.keys(doc.env).length > 0) {
301
382
  sections.push("env:" + emitYAML(doc.env, 1));
302
383
  }
384
+ if (doc.defaults) sections.push("defaults:" + emitYAML(doc.defaults, 1));
303
385
  sections.push("jobs:" + emitYAML(doc.jobsDoc, 1));
304
386
  return sections.join("\n\n") + "\n";
305
387
  }