@intentius/chant-lexicon-gitlab 0.80.0 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +35 -2
- package/src/components/generate-pipeline.ts +71 -3
|
@@ -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
|
+
});
|
|
@@ -281,7 +281,7 @@ describe("generateGitlabPipeline: a promote job (#2575)", () => {
|
|
|
281
281
|
const result = generateGitlabPipeline(components(), { env: "staging", promoteTo: "prod" });
|
|
282
282
|
const parsed = parseYAML(result.yaml);
|
|
283
283
|
|
|
284
|
-
expect((parsed["api"] as Record<string, unknown>).artifacts).toEqual({ paths: ["dist/api.tar"] });
|
|
284
|
+
expect((parsed["api"] as Record<string, unknown>).artifacts).toEqual({ paths: ["dist/api.tar", "api.digest"] });
|
|
285
285
|
expect((parsed["shared-alb"] as Record<string, unknown>).artifacts).toEqual({ paths: ["shared-alb.outputs.json"] });
|
|
286
286
|
|
|
287
287
|
expect(parsed.stages).toEqual(["wave-1", "wave-2", "promote"]);
|
|
@@ -289,8 +289,41 @@ describe("generateGitlabPipeline: a promote job (#2575)", () => {
|
|
|
289
289
|
expect(parsed["promote-prod"]).toEqual({
|
|
290
290
|
stage: "promote",
|
|
291
291
|
image: "node:22-slim",
|
|
292
|
-
script: [
|
|
292
|
+
script: ['chant components promote --from staging --to prod --digest "api=$(cut -d= -f2- api.digest)"'],
|
|
293
293
|
needs: ["api", "shared-alb"],
|
|
294
294
|
});
|
|
295
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
|
+
});
|
|
296
329
|
});
|
|
@@ -34,11 +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 { promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
37
|
+
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
38
|
+
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
38
39
|
import type {
|
|
39
40
|
ComponentPipelineJob as GeneratedJob,
|
|
40
41
|
ComponentPipelineOptions as GenerateGitlabOptions,
|
|
41
42
|
ComponentPipelineResult as GenerateGitlabResult,
|
|
43
|
+
PipelineMember,
|
|
42
44
|
} from "@intentius/chant/lexicon";
|
|
43
45
|
|
|
44
46
|
export type { GeneratedJob, GenerateGitlabOptions, GenerateGitlabResult };
|
|
@@ -86,13 +88,23 @@ export function generateGitlabPipeline(
|
|
|
86
88
|
// A promote job (#2575) runs apart from the deploy jobs, and a promote
|
|
87
89
|
// publishes from the build archive on disk, so each component job keeps the
|
|
88
90
|
// files its build steps wrote as artifacts. GitLab hands them to the promote
|
|
89
|
-
// job across its `needs:` edges.
|
|
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.
|
|
90
96
|
const promoteTo = options.promoteTo;
|
|
91
97
|
const archives = new Map<string, string[]>();
|
|
98
|
+
const pinned = new Set<string>();
|
|
99
|
+
const digestFile = (name: string) => `${name}.digest`;
|
|
92
100
|
if (promoteTo !== undefined) {
|
|
93
101
|
for (const c of components) {
|
|
94
102
|
const paths = promoteArchivePaths(c);
|
|
95
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");
|
|
96
108
|
}
|
|
97
109
|
}
|
|
98
110
|
|
|
@@ -129,6 +141,7 @@ export function generateGitlabPipeline(
|
|
|
129
141
|
const runParts = runCommand.map((part) => part.replace("{name}", name));
|
|
130
142
|
for (const dep of component.dependsOn ?? []) runParts.push("--seed-outputs", outputsFile(dep));
|
|
131
143
|
if (dependedUpon.has(name)) runParts.push("--dump-outputs", outputsFile(name));
|
|
144
|
+
if (pinned.has(name)) runParts.push("--digest-file", digestFile(name));
|
|
132
145
|
|
|
133
146
|
const script = [...beforeScript, runParts.join(" "), ...extraScript];
|
|
134
147
|
|
|
@@ -142,6 +155,7 @@ export function generateGitlabPipeline(
|
|
|
142
155
|
const artifactPaths = [
|
|
143
156
|
...(dependedUpon.has(name) ? [outputsFile(name)] : []),
|
|
144
157
|
...(archives.get(name) ?? []),
|
|
158
|
+
...(pinned.has(name) ? [digestFile(name)] : []),
|
|
145
159
|
];
|
|
146
160
|
if (artifactPaths.length > 0) jobProps.artifacts = { paths: artifactPaths };
|
|
147
161
|
doc[jobName] = jobProps;
|
|
@@ -154,7 +168,12 @@ export function generateGitlabPipeline(
|
|
|
154
168
|
if (promoteJob in doc) {
|
|
155
169
|
throw new Error(`the promote job "${promoteJob}" has the same name as a component job; rename the component`);
|
|
156
170
|
}
|
|
157
|
-
const command = options.promoteCommand ?? ["chant", "components", "promote", "--from", env, "--to", promoteTo];
|
|
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
|
+
}
|
|
158
177
|
doc[promoteJob] = {
|
|
159
178
|
stage: "promote",
|
|
160
179
|
image,
|
|
@@ -163,6 +182,10 @@ export function generateGitlabPipeline(
|
|
|
163
182
|
};
|
|
164
183
|
}
|
|
165
184
|
|
|
185
|
+
if (options.member) {
|
|
186
|
+
promoteJob = scopeToMember(doc, jobs, promoteJob, options.member, env);
|
|
187
|
+
}
|
|
188
|
+
|
|
166
189
|
const sections: string[] = [];
|
|
167
190
|
// `emitYAML` returns a `\n`-led block for a non-empty sequence and an inline
|
|
168
191
|
// `[]` for an empty one, so the header needs a space in the second case.
|
|
@@ -183,3 +206,48 @@ export function generateGitlabPipeline(
|
|
|
183
206
|
|
|
184
207
|
return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
|
|
185
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
|
+
}
|