@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.
- package/dist/components/generate-op-pipeline.d.ts +6 -0
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.d.ts +20 -0
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/generate-op-pipeline.test.ts +4 -1
- package/src/components/generate-op-pipeline.ts +52 -1
- package/src/components/generate-pipeline.member.test.ts +121 -0
- package/src/components/generate-pipeline.test.ts +127 -1
- package/src/components/generate-pipeline.ts +162 -1
|
@@ -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,
|
|
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
|
|
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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
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": "
|
|
80
|
+
"composite": "7df3ad6322ccb0443c9fe7a07446d7a26c61bc4039099b893e9fb0f0c2d15d49"
|
|
81
81
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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.
|
|
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
|
-
|
|
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
|
}
|