@intentius/chant-lexicon-github 0.80.0 → 0.82.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 +2 -2
- package/dist/components/generate-op-pipeline.d.ts.map +1 -1
- package/dist/components/generate-pipeline.d.ts +10 -0
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/generate-op-pipeline.ts +46 -3
- package/src/components/generate-pipeline.member.test.ts +121 -0
- package/src/components/generate-pipeline.test.ts +39 -3
- package/src/components/generate-pipeline.ts +85 -3
|
@@ -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
|
|
94
|
-
*
|
|
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,
|
|
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
|
|
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": "e2c34c0eb49fff6d71f126a0752d31e9804d0dd4ee74cdd6ee2cad3494cdab9b",
|
|
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": "e4124f381061044d53cc93be2a0a0f34899ca7bd4636aaff8c894a7c06aa74c0"
|
|
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.82.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.82.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
|
|
108
|
-
*
|
|
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([
|
|
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
|
-
|
|
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[] = [
|
|
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
|
-
|
|
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
|
}
|