@intentius/chant-lexicon-github 0.79.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.
@@ -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 generator
94
+ * copies it too (#2601), since Forgejo Actions reads the key the same way.
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,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"}
@@ -35,6 +35,18 @@
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.
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`.
38
50
  *
39
51
  * Cross-cutting changes (e.g. "sign every image before deploy") are made by
40
52
  * editing `GenerateGithubOptions.extraScript`/`beforeScript` (or the
@@ -65,6 +77,8 @@ export interface GithubPipelineDoc {
65
77
  environment: string;
66
78
  /** The `on:` trigger mapping (a bare `workflow_dispatch`). */
67
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>;
68
82
  /**
69
83
  * The `env:` mapping: the caller's `variables`, plus `CHANT_ENV` naming the
70
84
  * deployed environment (#2046) — the machine-readable identity on the
@@ -80,6 +94,12 @@ export interface GithubPipelineDoc {
80
94
  /** Every generated job, for the machine-readable view. */
81
95
  jobs: GeneratedJob[];
82
96
  }
97
+ /**
98
+ * The directory `actions/upload-artifact` roots a set of files at: the
99
+ * deepest directory that holds all of them. Downloading into it puts each
100
+ * file back at the path it was uploaded from.
101
+ */
102
+ export declare function artifactRoot(paths: string[]): string;
83
103
  /**
84
104
  * Synthesize a `.github/workflows/*.yml` pipeline from a set of components:
85
105
  * 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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": "ced24be037a4771c506d5c508c101750dc089c8b26dece0259cfca096577175b",
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": "06574aff8eec2ef2e8bcbd09e2f1012920840d23301938b7e12d286984e12e80"
80
+ "composite": "7df3ad6322ccb0443c9fe7a07446d7a26c61bc4039099b893e9fb0f0c2d15d49"
81
81
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github",
3
- "version": "0.79.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.79.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.79.0",
64
+ "@intentius/chant": "^0.81.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 * * *'",
@@ -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
 
@@ -102,6 +103,12 @@ export interface GithubOpPipelineDoc {
102
103
  * rather than only in a build warning.
103
104
  */
104
105
  header?: string[];
106
+ /**
107
+ * The workflow's `name:` (#2580): the Op's name, so the Actions UI lists
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.
110
+ */
111
+ name?: string;
105
112
  /**
106
113
  * The `on:` trigger mapping, per {@link ScheduledOpSpec}'s trigger kind
107
114
  * (#2084): `{ schedule, workflow_dispatch }` for cron, `{ pull_request }`
@@ -713,6 +720,7 @@ export function buildGithubOpPipelineDocs(
713
720
  for (const line of extraScript) steps.push({ run: line });
714
721
 
715
722
  const doc: GithubOpPipelineDoc = {
723
+ name: spec.name,
716
724
  on: onFor(trigger),
717
725
  ...(options.variables && Object.keys(options.variables).length > 0 ? { env: options.variables } : {}),
718
726
  // One run at a time per Op — a slow audit must not overlap its own next
@@ -757,9 +765,51 @@ export function buildGithubOpPipelineDocs(
757
765
  files.push({ name: `${spec.name}.yml`, doc });
758
766
  }
759
767
 
768
+ if (options.member) {
769
+ const member = options.member;
770
+ return { files: files.map((f) => scopeOpFileToMember(f, member)), jobs };
771
+ }
760
772
  return { files, jobs };
761
773
  }
762
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
+
763
813
  /**
764
814
  * Emit a `GithubOpPipelineDoc`'s `on`/`env`/`concurrency`/`permissions`/`jobs`
765
815
  * mappings as workflow YAML. Shared with the forgejo dialect (#969), which
@@ -771,6 +821,7 @@ export function emitOpPipelineYAML(doc: GithubOpPipelineDoc): string {
771
821
  // one. Absent on github, so an unannotated document is emitted exactly as
772
822
  // it was before the field existed.
773
823
  if (doc.header && doc.header.length > 0) sections.push(doc.header.join("\n"));
824
+ if (doc.name) sections.push("name: " + emitYAML(doc.name, 0));
774
825
  sections.push("on:" + emitYAML(doc.on, 1));
775
826
  if (doc.env && Object.keys(doc.env).length > 0) sections.push("env:" + emitYAML(doc.env, 1));
776
827
  sections.push("concurrency:" + emitYAML(doc.concurrency, 1));
@@ -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
+ });
@@ -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";
@@ -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
 
@@ -295,3 +297,127 @@ describe("generateGithubPipeline: options", () => {
295
297
  expect(runLines(jobs["shared-alb"])[0]).toContain("--env production");
296
298
  });
297
299
  });
300
+
301
+ /** Two services that build and publish, one depending on the other, plus infra that builds nothing. */
302
+ function promotableComponents(): DriverComponent[] {
303
+ const service = (name: string, into: string, dependsOn: string[] = []): DriverComponent => ({
304
+ name,
305
+ dependsOn,
306
+ deploy: [
307
+ { phase: "Build", steps: [{ kind: "docker-build", context: ".", into }] },
308
+ { phase: "Publish", steps: [{ kind: "publish-image", from: `archive:${into}`, to: "$env.registry" }] },
309
+ ],
310
+ });
311
+ return [
312
+ { name: "shared-alb", dependsOn: [], deploy: [] },
313
+ service("api", "dist/images/api.tar", ["shared-alb"]),
314
+ service("worker", "worker.tar"),
315
+ ];
316
+ }
317
+
318
+ describe("generateGithubPipeline: a promote job (#2575)", () => {
319
+ test("without promoteTo the pipeline is unchanged: no archive upload, no promote job", () => {
320
+ const yaml = generateGithubPipeline(promotableComponents(), { env: "staging" }).yaml;
321
+ expect(yaml).not.toContain("promote");
322
+ expect(yaml).not.toContain("build archive");
323
+ });
324
+
325
+ test("each building job uploads its archive, and the promote job downloads it back to the same path", () => {
326
+ const result = generateGithubPipeline(promotableComponents(), { env: "staging", promoteTo: "prod" });
327
+ const jobs = parsedJobs(result.yaml);
328
+
329
+ const upload = jobs["api"].steps.find((s) => s.name === "Upload api build archive")!;
330
+ expect(upload.uses).toBe("actions/upload-artifact@v4");
331
+ expect(upload.with).toEqual({
332
+ name: "api-archive",
333
+ path: "dist/images/api.tar",
334
+ "if-no-files-found": "error",
335
+ "include-hidden-files": true,
336
+ });
337
+ expect(jobs["shared-alb"].steps.some((s) => s.name?.includes("build archive"))).toBe(false);
338
+
339
+ const promote = jobs["promote-prod"];
340
+ expect(promote.needs).toEqual(["api", "shared-alb", "worker"]);
341
+ expect(promote.container).toBe("node:22-slim");
342
+ const downloads = promote.steps.filter((s) => s.uses === "actions/download-artifact@v4");
343
+ expect(downloads.map((s) => s.with)).toEqual([
344
+ { name: "api-archive", path: "dist/images" },
345
+ { name: "worker-archive", path: "." },
346
+ ]);
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
+ ]);
350
+
351
+ // The machine-readable views stay one entry per component.
352
+ expect(result.jobs.map((j) => j.component).sort()).toEqual(["api", "shared-alb", "worker"]);
353
+ });
354
+
355
+ test("the promote job runs the same beforeScript and extraScript, and promoteCommand replaces its command", () => {
356
+ const result = generateGithubPipeline(promotableComponents(), {
357
+ env: "staging",
358
+ promoteTo: "prod",
359
+ beforeScript: ["npm ci"],
360
+ extraScript: ["echo done"],
361
+ promoteCommand: ["npx", "chant", "components", "promote", "--from", "staging", "--to", "prod"],
362
+ });
363
+ expect(runLines(parsedJobs(result.yaml)["promote-prod"])).toEqual([
364
+ "npm ci",
365
+ 'npx chant components promote --from staging --to prod --digest "api=${{ needs.api.outputs.digest }}" --digest "worker=${{ needs.worker.outputs.digest }}"',
366
+ "echo done",
367
+ ]);
368
+ });
369
+
370
+ test("a build step writing to a path known only at run time is refused", () => {
371
+ const components: DriverComponent[] = [
372
+ { name: "api", deploy: [{ phase: "Build", steps: [{ kind: "docker-build", into: "$env.archive" }] }] },
373
+ ];
374
+ expect(() => generateGithubPipeline(components, { promoteTo: "prod" })).toThrow(/not a literal path/);
375
+ expect(() => generateGithubPipeline(components)).not.toThrow();
376
+ });
377
+
378
+ test("a component whose job name is the promote job's is refused", () => {
379
+ const components: DriverComponent[] = [
380
+ { name: "promote-prod", deploy: [{ phase: "Publish", steps: [{ kind: "publish-image", from: "archive:a.tar" }] }] },
381
+ ];
382
+ expect(() => generateGithubPipeline(components, { promoteTo: "prod" })).toThrow(/same name as a component job/);
383
+ });
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
+
415
+ test("artifactRoot is the deepest directory holding every file", () => {
416
+ expect(artifactRoot(["archive"])).toBe(".");
417
+ expect(artifactRoot(["dist/a.tar"])).toBe("dist");
418
+ expect(artifactRoot(["dist/a/x.tar", "dist/b/y.zip"])).toBe("dist");
419
+ expect(artifactRoot(["dist/a.tar", "b.zip"])).toBe(".");
420
+ expect(artifactRoot(["./dist/a.tar"])).toBe("dist");
421
+ expect(artifactRoot(["/tmp/out/a.tar", "/tmp/out/b.tar"])).toBe("/tmp/out");
422
+ });
423
+ });
@@ -35,6 +35,18 @@
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.
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`.
38
50
  *
39
51
  * Cross-cutting changes (e.g. "sign every image before deploy") are made by
40
52
  * editing `GenerateGithubOptions.extraScript`/`beforeScript` (or the
@@ -46,10 +58,13 @@
46
58
 
47
59
  import { emitYAML } from "@intentius/chant/yaml";
48
60
  import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
61
+ import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
62
+ import { memberRepoPath } from "@intentius/chant/lexicon";
49
63
  import type {
50
64
  ComponentPipelineJob as GeneratedJob,
51
65
  ComponentPipelineOptions as GenerateGithubOptions,
52
66
  ComponentPipelineResult as GenerateGithubResult,
67
+ PipelineMember,
53
68
  } from "@intentius/chant/lexicon";
54
69
 
55
70
  export type { GeneratedJob, GenerateGithubOptions, GenerateGithubResult };
@@ -73,6 +88,8 @@ export interface GithubPipelineDoc {
73
88
  environment: string;
74
89
  /** The `on:` trigger mapping (a bare `workflow_dispatch`). */
75
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>;
76
93
  /**
77
94
  * The `env:` mapping: the caller's `variables`, plus `CHANT_ENV` naming the
78
95
  * deployed environment (#2046) — the machine-readable identity on the
@@ -106,6 +123,34 @@ function artifactName(name: string): string {
106
123
  return `${name}-outputs`;
107
124
  }
108
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
+
131
+ /** The workflow artifact name a component's build archive is uploaded under, for the promote job (#2575). */
132
+ function archiveArtifactName(name: string): string {
133
+ return `${name}-archive`;
134
+ }
135
+
136
+ /**
137
+ * The directory `actions/upload-artifact` roots a set of files at: the
138
+ * deepest directory that holds all of them. Downloading into it puts each
139
+ * file back at the path it was uploaded from.
140
+ */
141
+ export function artifactRoot(paths: string[]): string {
142
+ const dirs = paths.map((p) => {
143
+ const parts = p.replace(/\/+$/, "").split("/");
144
+ parts.pop();
145
+ return parts.filter((part) => part !== "" && part !== ".");
146
+ });
147
+ const common: string[] = [];
148
+ for (let i = 0; dirs.every((d) => i < d.length && d[i] === dirs[0][i]); i++) common.push(dirs[0][i]);
149
+ const absolute = paths.every((p) => p.startsWith("/"));
150
+ if (common.length === 0) return absolute ? "/" : ".";
151
+ return (absolute ? "/" : "") + common.join("/");
152
+ }
153
+
109
154
  /**
110
155
  * Synthesize a `.github/workflows/*.yml` pipeline from a set of components:
111
156
  * one job per component, `needs:` expressing the wave-ordered dependency DAG
@@ -139,6 +184,25 @@ export function buildGithubPipelineDoc(
139
184
  const dependedUpon = new Set<string>();
140
185
  for (const c of components) for (const dep of c.dependsOn ?? []) dependedUpon.add(dep);
141
186
 
187
+ // A promote job (#2575) runs on its own runner, and a promote publishes from
188
+ // the build archive on disk, so each component job uploads the files its
189
+ // build steps wrote and the promote job downloads them.
190
+ const promoteTo = options.promoteTo;
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>();
195
+ if (promoteTo !== undefined) {
196
+ for (const c of components) {
197
+ const paths = promoteArchivePaths(c);
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");
203
+ }
204
+ }
205
+
142
206
  const stages = waves.map((_, i) => `wave-${i + 1}`);
143
207
  const jobs: GeneratedJob[] = [];
144
208
  const jobNameByComponent = new Map<string, string>();
@@ -163,6 +227,7 @@ export function buildGithubPipelineDoc(
163
227
  const runParts = runCommand.map((part) => part.replace("{name}", name));
164
228
  for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
165
229
  if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
230
+ if (pinned.has(name)) runParts.push("--digest-file", digestFile(name));
166
231
 
167
232
  // One step per script line — mirrors gitlab's `script:` array of
168
233
  // discrete shell lines, rather than a single multi-line `run:` block, so
@@ -181,6 +246,15 @@ export function buildGithubPipelineDoc(
181
246
  steps.push({ run: runParts.join(" ") });
182
247
  for (const line of extraScript) steps.push({ run: line });
183
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
+
184
258
  if (dependedUpon.has(name)) {
185
259
  steps.push({
186
260
  name: `Upload ${name} outputs`,
@@ -189,17 +263,60 @@ export function buildGithubPipelineDoc(
189
263
  });
190
264
  }
191
265
 
266
+ const archive = archives.get(name);
267
+ if (archive) {
268
+ steps.push({
269
+ name: `Upload ${name} build archive`,
270
+ uses: "actions/upload-artifact@v4",
271
+ with: {
272
+ name: archiveArtifactName(name),
273
+ path: archive.join("\n"),
274
+ "if-no-files-found": "error",
275
+ "include-hidden-files": true,
276
+ },
277
+ });
278
+ }
279
+
192
280
  const jobProps: Record<string, unknown> = {
193
281
  "runs-on": "ubuntu-latest",
194
282
  ...(needs.length > 0 ? { needs } : {}),
195
283
  container: image,
284
+ ...(pinned.has(name) ? { outputs: { digest: "${{ steps.digest.outputs.digest }}" } } : {}),
196
285
  steps,
197
286
  };
198
287
  jobsDoc[jobName] = jobProps;
199
288
  }
200
289
  });
201
290
 
202
- return {
291
+ if (promoteTo !== undefined) {
292
+ const promoteJob = `promote-${toJobName(promoteTo)}`;
293
+ if (promoteJob in jobsDoc) {
294
+ throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
295
+ }
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
+ }
300
+ const steps: Array<Record<string, unknown>> = [{ uses: "actions/checkout@v4" }];
301
+ for (const [name, paths] of archives) {
302
+ steps.push({
303
+ name: `Download ${name} build archive`,
304
+ uses: "actions/download-artifact@v4",
305
+ with: { name: archiveArtifactName(name), path: artifactRoot(paths) },
306
+ });
307
+ }
308
+ for (const line of beforeScript) steps.push({ run: line });
309
+ steps.push({ run: command.join(" ") });
310
+ for (const line of extraScript) steps.push({ run: line });
311
+ jobsDoc[promoteJob] = {
312
+ "runs-on": "ubuntu-latest",
313
+ needs: [...jobNameByComponent.values()].sort(),
314
+ container: image,
315
+ steps,
316
+ };
317
+ }
318
+
319
+ const doc: GithubPipelineDoc = {
203
320
  name: `chant-components-${env}`,
204
321
  environment: env,
205
322
  on: { workflow_dispatch: {} },
@@ -208,6 +325,49 @@ export function buildGithubPipelineDoc(
208
325
  stages,
209
326
  jobs,
210
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 } };
211
371
  }
212
372
 
213
373
  /**
@@ -221,6 +381,7 @@ export function emitPipelineYAML(doc: GithubPipelineDoc): string {
221
381
  if (doc.env && Object.keys(doc.env).length > 0) {
222
382
  sections.push("env:" + emitYAML(doc.env, 1));
223
383
  }
384
+ if (doc.defaults) sections.push("defaults:" + emitYAML(doc.defaults, 1));
224
385
  sections.push("jobs:" + emitYAML(doc.jobsDoc, 1));
225
386
  return sections.join("\n\n") + "\n";
226
387
  }