@intentius/chant-lexicon-gitlab 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.map +1 -1
- 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 +3 -3
- package/src/components/generate-op-pipeline.ts +15 -7
- package/src/components/generate-pipeline.member.test.ts +98 -0
- package/src/components/generate-pipeline.test.ts +70 -0
- package/src/components/generate-pipeline.ts +107 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAqShE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,
|
|
1
|
+
{"version":3,"file":"generate-op-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-op-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAIH,OAAO,KAAK,EACV,wBAAwB,IAAI,uBAAuB,EAInD,gBAAgB,IAAI,sBAAsB,EAE1C,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAElC,YAAY,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,CAAC;AAqShE;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,eAAe,EAAE,EACtB,OAAO,GAAE,uBAA4B,GACpC,sBAAsB,CAgHxB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAGH,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,oCAAoC,CAAC;
|
|
1
|
+
{"version":3,"file":"generate-pipeline.d.ts","sourceRoot":"","sources":["../../src/components/generate-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;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;AAS1E;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,UAAU,EAAE,eAAe,EAAE,EAC7B,OAAO,GAAE,qBAA0B,GAClC,oBAAoB,CA8ItB"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "e23beb8934ca2c67212955955495a0b00ce12ba9e793a433602d14fda561f397",
|
|
5
5
|
"meta.json": "931fc3246a55645b1493080bbeb160e5d205e42349e8bdca96ca243adb5f0da3",
|
|
6
6
|
"types/index.d.ts": "5cd2e99f135a929b72bdd822d00d780d39b1cd407ba0cfb3d511c7ac9d667b58",
|
|
7
7
|
"rules/artifact-no-expiry.ts": "3f3cabf9792cbf8207e53a25f506715466b19ec25e9c3b4d0d77fed6b2eb4542",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
|
|
56
56
|
"skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
|
|
57
57
|
},
|
|
58
|
-
"composite": "
|
|
58
|
+
"composite": "5abfb372c339e030eaac05059cc8b7ebb333c81abd61323a468fb5c66e432256"
|
|
59
59
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-gitlab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.0",
|
|
4
4
|
"description": "GitLab CI lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"typescript": "^5.9.3"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
|
-
"@intentius/chant": "^0.
|
|
71
|
-
"@intentius/chant-lexicon-github": "^0.
|
|
70
|
+
"@intentius/chant": "^0.81.0",
|
|
71
|
+
"@intentius/chant-lexicon-github": "^0.81.0",
|
|
72
72
|
"typescript": "^5.9.3"
|
|
73
73
|
},
|
|
74
74
|
"peerDependenciesMeta": {
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
*/
|
|
107
107
|
|
|
108
108
|
import { emitYAML } from "@intentius/chant/yaml";
|
|
109
|
-
import { resolveOpTrigger } from "@intentius/chant/lexicon";
|
|
109
|
+
import { memberGitlabChanges, memberRepoPath, memberShellDir, resolveOpTrigger } from "@intentius/chant/lexicon";
|
|
110
110
|
import type {
|
|
111
111
|
ComponentPipelineOptions as GenerateGitlabOpOptions,
|
|
112
112
|
OpEnvironment,
|
|
@@ -425,8 +425,16 @@ export function generateGitlabOpPipeline(
|
|
|
425
425
|
const extraScript = options.extraScript ?? [];
|
|
426
426
|
const stage = options.opsStage ?? DEFAULT_STAGE;
|
|
427
427
|
assertOpsStage(stage);
|
|
428
|
-
const
|
|
429
|
-
assertOpsFileName(
|
|
428
|
+
const baseFileName = options.opsFileName ?? `${stage}.gitlab-ci.yml`;
|
|
429
|
+
assertOpsFileName(baseFileName);
|
|
430
|
+
// A workspace member's file, jobs and resource groups carry its name
|
|
431
|
+
// (#2542): the root .gitlab-ci.yml includes every member's file, and job
|
|
432
|
+
// names share one namespace across them.
|
|
433
|
+
const member = options.member;
|
|
434
|
+
const fileName = member ? `${member.name}-${baseFileName}` : baseFileName;
|
|
435
|
+
const jobPrefix = member ? `${member.name}-` : "";
|
|
436
|
+
const changes = member ? memberGitlabChanges(member, member.fileDir ? `${member.fileDir}/${fileName}` : undefined) : undefined;
|
|
437
|
+
const cdLine = member && member.dir !== "." && member.dir !== "" ? [`cd ${memberShellDir(member)}`] : [];
|
|
430
438
|
|
|
431
439
|
const jobs: OpPipelineJob[] = [];
|
|
432
440
|
const doc: Record<string, unknown> = { stages: [stage] };
|
|
@@ -443,7 +451,7 @@ export function generateGitlabOpPipeline(
|
|
|
443
451
|
assertTriggerSupportsMode(spec.name, findingMode, trigger);
|
|
444
452
|
if (trigger.kind === "cron") anyCron = true;
|
|
445
453
|
|
|
446
|
-
const jobName = toJobName(spec.name);
|
|
454
|
+
const jobName = jobPrefix + toJobName(spec.name);
|
|
447
455
|
jobs.push({ jobName, op: spec.name, trigger, findingMode });
|
|
448
456
|
opLines.push(headerLineFor(spec, trigger, jobName, findingMode));
|
|
449
457
|
if (spec.environment) {
|
|
@@ -484,14 +492,14 @@ export function generateGitlabOpPipeline(
|
|
|
484
492
|
}
|
|
485
493
|
: {}),
|
|
486
494
|
...(Object.keys(jobVariables).length > 0 ? { variables: jobVariables } : {}),
|
|
487
|
-
rules: rulesFor(spec, trigger),
|
|
488
|
-
script: [...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
|
|
495
|
+
rules: changes && trigger.kind !== "cron" ? rulesFor(spec, trigger).map((r) => ({ ...r, changes })) : rulesFor(spec, trigger),
|
|
496
|
+
script: [...cdLine, ...setupScript, ...beforeScript, invocation.join(" "), ...extraScript],
|
|
489
497
|
// `when: always` because the run this publishes for is the green one: a
|
|
490
498
|
// gated apply succeeds, and the block is the only thing that says a
|
|
491
499
|
// human still has to act. A run that walked through its gate writes no
|
|
492
500
|
// block, and GitLab reports the empty upload as a warning, not a failure.
|
|
493
501
|
...(gated
|
|
494
|
-
? { artifacts: { when: "always", paths: [gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
|
|
502
|
+
? { artifacts: { when: "always", paths: [member ? memberRepoPath(member, gateSummary) : gateSummary], expire_in: GATE_ARTIFACT_EXPIRY } }
|
|
495
503
|
: {}),
|
|
496
504
|
};
|
|
497
505
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A workspace member's pipelines on GitLab (#2542, #2524 D19). GitLab reads
|
|
3
|
+
* one .gitlab-ci.yml, which includes each member's file, so job names carry
|
|
4
|
+
* the member's name and each job carries its own `rules: changes:`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
9
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
10
|
+
import type { PipelineMember } from "@intentius/chant/lexicon";
|
|
11
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
12
|
+
import { generateGitlabOpPipeline } from "./generate-op-pipeline";
|
|
13
|
+
|
|
14
|
+
const components: DriverComponent[] = [
|
|
15
|
+
{ name: "shared-alb", dependsOn: [], deploy: [] },
|
|
16
|
+
{
|
|
17
|
+
name: "api",
|
|
18
|
+
dependsOn: ["shared-alb"],
|
|
19
|
+
deploy: [
|
|
20
|
+
{ phase: "Build", steps: [{ kind: "docker-build", context: ".", into: "dist/api.tar" }] },
|
|
21
|
+
{ phase: "Publish", steps: [{ kind: "publish-image", from: "archive:dist/api.tar" }] },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const api: PipelineMember = { name: "api", dir: "services/api", file: ".gitlab/ci/chant-api-staging.gitlab-ci.yml" };
|
|
27
|
+
|
|
28
|
+
type Job = { script: string[]; needs?: string[]; rules?: unknown[]; artifacts?: { paths: string[] }; stage: string };
|
|
29
|
+
|
|
30
|
+
describe("generateGitlabPipeline for a workspace member", () => {
|
|
31
|
+
test("jobs take the member's name, run in its directory and only on its changes", () => {
|
|
32
|
+
const result = generateGitlabPipeline(components, { env: "staging", member: api });
|
|
33
|
+
const doc = parseYAML(result.yaml) as Record<string, unknown>;
|
|
34
|
+
expect(doc.workflow).toEqual({ name: "chant-components-api-staging" });
|
|
35
|
+
expect(doc["shared-alb"]).toBeUndefined();
|
|
36
|
+
|
|
37
|
+
const job = doc["api-api"] as Job;
|
|
38
|
+
expect(job.script).toEqual(["cd services/api", "chant run --components api --env staging --seed-outputs shared-alb.outputs.json"]);
|
|
39
|
+
expect(job.needs).toEqual(["api-shared-alb"]);
|
|
40
|
+
expect(job.rules).toEqual([{ changes: ["services/api/**/*", ".gitlab/ci/chant-api-staging.gitlab-ci.yml"] }]);
|
|
41
|
+
|
|
42
|
+
const shared = doc["api-shared-alb"] as Job;
|
|
43
|
+
expect(shared.artifacts).toEqual({ paths: ["services/api/shared-alb.outputs.json"] });
|
|
44
|
+
|
|
45
|
+
expect(result.jobs.map((j) => [j.jobName, j.needs])).toEqual([
|
|
46
|
+
["api-shared-alb", []],
|
|
47
|
+
["api-api", ["api-shared-alb"]],
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("the promote job is renamed and needs the renamed jobs; archive paths move under the member", () => {
|
|
52
|
+
const doc = parseYAML(generateGitlabPipeline(components, { env: "staging", promoteTo: "prod", member: api }).yaml) as Record<string, unknown>;
|
|
53
|
+
const promote = doc["api-promote-prod"] as Job;
|
|
54
|
+
expect(promote.needs).toEqual(["api-api", "api-shared-alb"]);
|
|
55
|
+
expect(promote.script[0]).toBe("cd services/api");
|
|
56
|
+
expect((doc["api-api"] as Job).artifacts?.paths).toEqual(["services/api/dist/api.tar", "services/api/api.digest"]);
|
|
57
|
+
// The promote job reads the digest file after its cd, from where the api job wrote it (#2602).
|
|
58
|
+
expect(promote.script[1]).toContain('--digest "api=$(cut -d= -f2- api.digest)"');
|
|
59
|
+
expect(doc.stages).toEqual(["wave-1", "wave-2", "promote"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a member at the root has no changes rule, since GitLab's changes can't exclude the other members", () => {
|
|
63
|
+
const doc = parseYAML(generateGitlabPipeline(components, { env: "prod", member: { name: "platform", dir: ".", exclude: ["services/api"] } }).yaml) as Record<string, unknown>;
|
|
64
|
+
const job = doc["platform-api"] as Job;
|
|
65
|
+
expect(job.rules).toBeUndefined();
|
|
66
|
+
expect(job.script[0]).toMatch(/^chant run/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a directory with a space is quoted for cd", () => {
|
|
70
|
+
const doc = parseYAML(generateGitlabPipeline(components, { env: "prod", member: { name: "odd", dir: "my apps/odd" } }).yaml) as Record<string, unknown>;
|
|
71
|
+
expect((doc["odd-api"] as Job).script[0]).toBe("cd 'my apps/odd'");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("generateGitlabOpPipeline for a workspace member", () => {
|
|
76
|
+
const member: PipelineMember = { name: "api", dir: "services/api", fileDir: ".gitlab/ci" };
|
|
77
|
+
|
|
78
|
+
test("the file and job names carry the member, push and merge-request rules gain changes, cron does not", () => {
|
|
79
|
+
const result = generateGitlabOpPipeline(
|
|
80
|
+
[
|
|
81
|
+
{ name: "nightly", trigger: { kind: "cron", schedule: "0 6 * * *" } },
|
|
82
|
+
{ name: "plan", trigger: { kind: "pull_request" } },
|
|
83
|
+
{ name: "apply", trigger: { kind: "push" } },
|
|
84
|
+
],
|
|
85
|
+
{ member },
|
|
86
|
+
);
|
|
87
|
+
expect(result.files.map((f) => f.name)).toEqual(["api-ops.gitlab-ci.yml"]);
|
|
88
|
+
const doc = parseYAML(result.files[0].yaml) as Record<string, Omit<Job, "rules"> & { resource_group: string; rules: Array<Record<string, unknown>> }>;
|
|
89
|
+
const changes = ["services/api/**/*", ".gitlab/ci/api-ops.gitlab-ci.yml"];
|
|
90
|
+
|
|
91
|
+
expect(doc["api-nightly"].rules.every((r) => !("changes" in r))).toBe(true);
|
|
92
|
+
expect(doc["api-plan"].rules.every((r) => JSON.stringify(r.changes) === JSON.stringify(changes))).toBe(true);
|
|
93
|
+
expect(doc["api-apply"].rules.every((r) => JSON.stringify(r.changes) === JSON.stringify(changes))).toBe(true);
|
|
94
|
+
expect(doc["api-apply"].resource_group).toBe("api-apply");
|
|
95
|
+
expect(doc["api-apply"].script[0]).toBe("cd services/api");
|
|
96
|
+
expect(doc["api-apply"].artifacts?.paths).toEqual(["services/api/chant-gate-api-apply.md"]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -257,3 +257,73 @@ describe("generateGitlabPipeline: options", () => {
|
|
|
257
257
|
expect((job.script as string[])[0]).toContain("--env production");
|
|
258
258
|
});
|
|
259
259
|
});
|
|
260
|
+
|
|
261
|
+
describe("generateGitlabPipeline: a promote job (#2575)", () => {
|
|
262
|
+
const components = (): DriverComponent[] => [
|
|
263
|
+
{ name: "shared-alb", dependsOn: [], deploy: [] },
|
|
264
|
+
{
|
|
265
|
+
name: "api",
|
|
266
|
+
dependsOn: ["shared-alb"],
|
|
267
|
+
deploy: [
|
|
268
|
+
{ phase: "Build", steps: [{ kind: "docker-build", context: ".", into: "dist/api.tar" }] },
|
|
269
|
+
{ phase: "Publish", steps: [{ kind: "publish-image", from: "archive:dist/api.tar" }] },
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
test("without promoteTo the pipeline is unchanged", () => {
|
|
275
|
+
const yaml = generateGitlabPipeline(components(), { env: "staging" }).yaml;
|
|
276
|
+
expect(yaml).not.toContain("promote");
|
|
277
|
+
expect(yaml).not.toContain("dist/api.tar");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("building jobs keep their archive as artifacts, and a promote stage job needs every component job", () => {
|
|
281
|
+
const result = generateGitlabPipeline(components(), { env: "staging", promoteTo: "prod" });
|
|
282
|
+
const parsed = parseYAML(result.yaml);
|
|
283
|
+
|
|
284
|
+
expect((parsed["api"] as Record<string, unknown>).artifacts).toEqual({ paths: ["dist/api.tar", "api.digest"] });
|
|
285
|
+
expect((parsed["shared-alb"] as Record<string, unknown>).artifacts).toEqual({ paths: ["shared-alb.outputs.json"] });
|
|
286
|
+
|
|
287
|
+
expect(parsed.stages).toEqual(["wave-1", "wave-2", "promote"]);
|
|
288
|
+
expect(result.stages).toEqual(["wave-1", "wave-2"]);
|
|
289
|
+
expect(parsed["promote-prod"]).toEqual({
|
|
290
|
+
stage: "promote",
|
|
291
|
+
image: "node:22-slim",
|
|
292
|
+
script: ['chant components promote --from staging --to prod --digest "api=$(cut -d= -f2- api.digest)"'],
|
|
293
|
+
needs: ["api", "shared-alb"],
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("the promote job promotes the digest this pipeline's component job recorded (#2602)", () => {
|
|
298
|
+
const parsed = parseYAML(generateGitlabPipeline(components(), { env: "staging", promoteTo: "prod" }).yaml);
|
|
299
|
+
const job = (name: string) => parsed[name] as { script: string[]; artifacts?: { paths: string[] } };
|
|
300
|
+
|
|
301
|
+
// The component job writes the digest its run recorded, and keeps the file
|
|
302
|
+
// as an artifact, which GitLab hands to the promote job across `needs:`.
|
|
303
|
+
expect(job("api").script).toEqual(["chant run --components api --env staging --seed-outputs shared-alb.outputs.json --digest-file api.digest"]);
|
|
304
|
+
expect(job("api").artifacts?.paths).toContain("api.digest");
|
|
305
|
+
// shared-alb publishes nothing, so it records no release and is not pinned.
|
|
306
|
+
expect(job("shared-alb").script[0]).not.toContain("--digest-file");
|
|
307
|
+
|
|
308
|
+
const promote = job("promote-prod").script[0];
|
|
309
|
+
expect(promote).toContain('--digest "api=$(cut -d= -f2- api.digest)"');
|
|
310
|
+
expect(promote).not.toContain("shared-alb=");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("promoteCommand still gets the pinned digests", () => {
|
|
314
|
+
const parsed = parseYAML(generateGitlabPipeline(components(), {
|
|
315
|
+
env: "staging",
|
|
316
|
+
promoteTo: "prod",
|
|
317
|
+
promoteCommand: ["npx", "chant", "components", "promote", "--from", "staging", "--to", "prod"],
|
|
318
|
+
}).yaml);
|
|
319
|
+
expect((parsed["promote-prod"] as { script: string[] }).script).toEqual([
|
|
320
|
+
'npx chant components promote --from staging --to prod --digest "api=$(cut -d= -f2- api.digest)"',
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("with no component that publishes, there is no release to pin, and generation fails", () => {
|
|
325
|
+
const infraOnly: DriverComponent[] = [{ name: "shared-alb", dependsOn: [], deploy: [] }];
|
|
326
|
+
expect(() => generateGitlabPipeline(infraOnly, { promoteTo: "prod" })).toThrow(/no component has a publish step/);
|
|
327
|
+
expect(() => generateGitlabPipeline(infraOnly)).not.toThrow();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -34,10 +34,13 @@
|
|
|
34
34
|
|
|
35
35
|
import { emitYAML } from "@intentius/chant/yaml";
|
|
36
36
|
import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
|
|
37
|
+
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
38
|
+
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
37
39
|
import type {
|
|
38
40
|
ComponentPipelineJob as GeneratedJob,
|
|
39
41
|
ComponentPipelineOptions as GenerateGitlabOptions,
|
|
40
42
|
ComponentPipelineResult as GenerateGitlabResult,
|
|
43
|
+
PipelineMember,
|
|
41
44
|
} from "@intentius/chant/lexicon";
|
|
42
45
|
|
|
43
46
|
export type { GeneratedJob, GenerateGitlabOptions, GenerateGitlabResult };
|
|
@@ -82,6 +85,29 @@ export function generateGitlabPipeline(
|
|
|
82
85
|
for (const c of components) for (const dep of c.dependsOn ?? []) dependedUpon.add(dep);
|
|
83
86
|
const outputsFile = (name: string) => `${name}.outputs.json`;
|
|
84
87
|
|
|
88
|
+
// A promote job (#2575) runs apart from the deploy jobs, and a promote
|
|
89
|
+
// publishes from the build archive on disk, so each component job keeps the
|
|
90
|
+
// files its build steps wrote as artifacts. GitLab hands them to the promote
|
|
91
|
+
// job across its `needs:` edges. Each component job with a publish step
|
|
92
|
+
// also keeps the digest its run recorded (`--digest-file`), and the promote
|
|
93
|
+
// job passes it back as `--digest <component>=<digest>` (#2602), so it
|
|
94
|
+
// promotes the release this run built rather than whatever is latest in the
|
|
95
|
+
// source environment.
|
|
96
|
+
const promoteTo = options.promoteTo;
|
|
97
|
+
const archives = new Map<string, string[]>();
|
|
98
|
+
const pinned = new Set<string>();
|
|
99
|
+
const digestFile = (name: string) => `${name}.digest`;
|
|
100
|
+
if (promoteTo !== undefined) {
|
|
101
|
+
for (const c of components) {
|
|
102
|
+
const paths = promoteArchivePaths(c);
|
|
103
|
+
if (paths.length > 0) archives.set(c.name, paths);
|
|
104
|
+
if (hasPublishStep(c)) pinned.add(c.name);
|
|
105
|
+
}
|
|
106
|
+
if (pinned.size === 0) {
|
|
107
|
+
throw new Error("no component has a publish step, so no deploy records a release for the promote job to promote");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
const stages = waves.map((_, i) => `wave-${i + 1}`);
|
|
86
112
|
const jobs: GeneratedJob[] = [];
|
|
87
113
|
const jobNameByComponent = new Map<string, string>();
|
|
@@ -115,6 +141,7 @@ export function generateGitlabPipeline(
|
|
|
115
141
|
const runParts = runCommand.map((part) => part.replace("{name}", name));
|
|
116
142
|
for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
|
|
117
143
|
if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
|
|
144
|
+
if (pinned.has(name)) runParts.push("--digest-file", digestFile(name));
|
|
118
145
|
|
|
119
146
|
const script = [...beforeScript, runParts.join(" "), ...extraScript];
|
|
120
147
|
|
|
@@ -125,11 +152,40 @@ export function generateGitlabPipeline(
|
|
|
125
152
|
};
|
|
126
153
|
if (needs.length > 0) jobProps.needs = needs;
|
|
127
154
|
// Publish this component's dumped outputs so dependent jobs receive it.
|
|
128
|
-
|
|
155
|
+
const artifactPaths = [
|
|
156
|
+
...(dependedUpon.has(name) ? [outputsFile(name)] : []),
|
|
157
|
+
...(archives.get(name) ?? []),
|
|
158
|
+
...(pinned.has(name) ? [digestFile(name)] : []),
|
|
159
|
+
];
|
|
160
|
+
if (artifactPaths.length > 0) jobProps.artifacts = { paths: artifactPaths };
|
|
129
161
|
doc[jobName] = jobProps;
|
|
130
162
|
}
|
|
131
163
|
});
|
|
132
164
|
|
|
165
|
+
let promoteJob: string | undefined;
|
|
166
|
+
if (promoteTo !== undefined) {
|
|
167
|
+
promoteJob = `promote-${toJobName(promoteTo)}`;
|
|
168
|
+
if (promoteJob in doc) {
|
|
169
|
+
throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
|
|
170
|
+
}
|
|
171
|
+
const command = [...(options.promoteCommand ?? ["chant", "components", "promote", "--from", env, "--to", promoteTo])];
|
|
172
|
+
// The file holds `<component>=<digest>`; a missing or empty one leaves
|
|
173
|
+
// `<component>=`, which the promote refuses.
|
|
174
|
+
for (const name of [...pinned].sort()) {
|
|
175
|
+
command.push("--digest", `"${name}=$(cut -d= -f2- ${digestFile(name)})"`);
|
|
176
|
+
}
|
|
177
|
+
doc[promoteJob] = {
|
|
178
|
+
stage: "promote",
|
|
179
|
+
image,
|
|
180
|
+
script: [...beforeScript, command.join(" "), ...extraScript],
|
|
181
|
+
needs: [...jobNameByComponent.values()].sort(),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (options.member) {
|
|
186
|
+
promoteJob = scopeToMember(doc, jobs, promoteJob, options.member, env);
|
|
187
|
+
}
|
|
188
|
+
|
|
133
189
|
const sections: string[] = [];
|
|
134
190
|
// `emitYAML` returns a `\n`-led block for a non-empty sequence and an inline
|
|
135
191
|
// `[]` for an empty one, so the header needs a space in the second case.
|
|
@@ -137,12 +193,61 @@ export function generateGitlabPipeline(
|
|
|
137
193
|
// when whitespace or the line's end follows it — which is how GitLab's own
|
|
138
194
|
// reader takes it, and now how `parseYAML` does too (chant #2013).
|
|
139
195
|
sections.push("workflow:" + emitYAML(doc.workflow, 1));
|
|
140
|
-
|
|
196
|
+
// The promote stage is YAML only: `stages` in the result stays one entry
|
|
197
|
+
// per graph wave.
|
|
198
|
+
const yamlStages = promoteJob ? [...stages, "promote"] : stages;
|
|
199
|
+
sections.push(yamlStages.length > 0 ? "stages:" + emitYAML(yamlStages, 1) : "stages: []");
|
|
141
200
|
if (doc.variables) sections.push("variables:" + emitYAML(doc.variables, 1));
|
|
142
201
|
for (const job of jobs) {
|
|
143
202
|
const props = doc[job.jobName] as Record<string, unknown>;
|
|
144
203
|
sections.push(`${job.jobName}:` + emitYAML(props, 1));
|
|
145
204
|
}
|
|
205
|
+
if (promoteJob) sections.push(`${promoteJob}:` + emitYAML(doc[promoteJob], 1));
|
|
146
206
|
|
|
147
207
|
return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
|
|
148
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Scope a pipeline to one workspace member (#2542, #2524 D19), in place on
|
|
212
|
+
* `doc` and `jobs`. Returns the promote job's new name.
|
|
213
|
+
*
|
|
214
|
+
* GitLab reads one `.gitlab-ci.yml`, so a member's pipeline is a file that
|
|
215
|
+
* the root file includes, and job names share one namespace across every
|
|
216
|
+
* included file. Each job therefore takes the member's name as a prefix. A
|
|
217
|
+
* `rules: changes:` entry limits each job to changes under the member's
|
|
218
|
+
* directory or to the pipeline file; for a member at `"."` there is no such
|
|
219
|
+
* rule, since `changes` has no exclusions. Each job's script starts with a
|
|
220
|
+
* `cd` into the member's directory, and artifact paths, which GitLab resolves
|
|
221
|
+
* against the repository root, move under it.
|
|
222
|
+
*/
|
|
223
|
+
function scopeToMember(
|
|
224
|
+
doc: Record<string, unknown>,
|
|
225
|
+
jobs: GeneratedJob[],
|
|
226
|
+
promoteJob: string | undefined,
|
|
227
|
+
member: PipelineMember,
|
|
228
|
+
env: string,
|
|
229
|
+
): string | undefined {
|
|
230
|
+
const rooted = member.dir === "." || member.dir === "";
|
|
231
|
+
const changes = memberGitlabChanges(member);
|
|
232
|
+
const renamed = new Map<string, string>();
|
|
233
|
+
for (const job of jobs) renamed.set(job.jobName, `${member.name}-${job.jobName}`);
|
|
234
|
+
if (promoteJob) renamed.set(promoteJob, `${member.name}-${promoteJob}`);
|
|
235
|
+
|
|
236
|
+
for (const [from, to] of renamed) {
|
|
237
|
+
const props = doc[from] as Record<string, unknown>;
|
|
238
|
+
delete doc[from];
|
|
239
|
+
const next: Record<string, unknown> = { ...props };
|
|
240
|
+
if (!rooted) next.script = [`cd ${memberShellDir(member)}`, ...(props.script as string[])];
|
|
241
|
+
if (Array.isArray(props.needs)) next.needs = (props.needs as string[]).map((n) => renamed.get(n) ?? n);
|
|
242
|
+
const artifacts = props.artifacts as { paths?: string[] } | undefined;
|
|
243
|
+
if (artifacts?.paths) next.artifacts = { ...artifacts, paths: artifacts.paths.map((p) => memberRepoPath(member, p)) };
|
|
244
|
+
if (changes) next.rules = [{ changes }];
|
|
245
|
+
doc[to] = next;
|
|
246
|
+
}
|
|
247
|
+
for (const job of jobs) {
|
|
248
|
+
job.jobName = renamed.get(job.jobName)!;
|
|
249
|
+
job.needs = job.needs.map((n) => renamed.get(n) ?? n);
|
|
250
|
+
}
|
|
251
|
+
doc.workflow = { name: `chant-components-${member.name}-${env}` };
|
|
252
|
+
return promoteJob ? renamed.get(promoteJob) : undefined;
|
|
253
|
+
}
|