@intentius/chant-lexicon-gitlab 0.101.0 → 0.104.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/codegen/naming.d.ts.map +1 -1
- package/dist/components/generate-pipeline.d.ts.map +1 -1
- package/dist/composites/node-pipeline.d.ts +16 -16
- package/dist/composites/node-pipeline.d.ts.map +1 -1
- package/dist/generated/index.d.ts +19 -19
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/integrity.json +10 -10
- package/dist/intrinsics.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl013.d.ts +3 -1
- package/dist/lint/post-synth/wgl013.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl017.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl020.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl021.d.ts.map +1 -1
- package/dist/lint/post-synth/wgl050.d.ts.map +1 -1
- package/dist/lint/post-synth/yaml-helpers.d.ts +57 -8
- package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migrate/from-github/provenance.d.ts.map +1 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/rules/wgl013.ts +4 -2
- package/dist/rules/wgl017.ts +3 -11
- package/dist/rules/wgl020.ts +4 -5
- package/dist/rules/wgl021.ts +4 -5
- package/dist/rules/wgl048.ts +2 -2
- package/dist/rules/wgl049.ts +4 -4
- package/dist/rules/wgl050.ts +5 -8
- package/dist/rules/yaml-helpers.ts +176 -113
- package/dist/variables.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/components/__fixtures__/pr-loop.gitlab.golden.yml +43 -0
- package/src/components/generate-op-pipeline.ts +4 -4
- package/src/components/generate-pipeline.test.ts +33 -0
- package/src/components/generate-pipeline.ts +117 -11
- package/src/components/generate-pr-pipeline.test.ts +70 -0
- package/src/lint/post-synth/post-synth.test.ts +29 -0
- package/src/lint/post-synth/wgl013.test.ts +53 -0
- package/src/lint/post-synth/wgl013.ts +4 -2
- package/src/lint/post-synth/wgl014.test.ts +23 -0
- package/src/lint/post-synth/wgl015.test.ts +35 -0
- package/src/lint/post-synth/wgl017.test.ts +14 -0
- package/src/lint/post-synth/wgl017.ts +3 -11
- package/src/lint/post-synth/wgl020.test.ts +26 -0
- package/src/lint/post-synth/wgl020.ts +4 -5
- package/src/lint/post-synth/wgl021.test.ts +29 -0
- package/src/lint/post-synth/wgl021.ts +4 -5
- package/src/lint/post-synth/wgl028.test.ts +27 -0
- package/src/lint/post-synth/wgl042.test.ts +14 -0
- package/src/lint/post-synth/wgl048.test.ts +14 -0
- package/src/lint/post-synth/wgl048.ts +2 -2
- package/src/lint/post-synth/wgl049.test.ts +25 -0
- package/src/lint/post-synth/wgl049.ts +4 -4
- package/src/lint/post-synth/wgl050.test.ts +16 -0
- package/src/lint/post-synth/wgl050.ts +5 -8
- package/src/lint/post-synth/yaml-helpers.test.ts +249 -0
- package/src/lint/post-synth/yaml-helpers.ts +176 -113
- package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected.gitlab-ci.yml +3 -1
- package/src/serializer.ts +5 -5
|
@@ -32,9 +32,11 @@
|
|
|
32
32
|
* touching the component declarations.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import {
|
|
35
|
+
import { emitYAMLEntry } from "@intentius/chant/yaml";
|
|
36
36
|
import { resolveComponentGraph, type DriverComponent } from "@intentius/chant/components/driver";
|
|
37
37
|
import { hasPublishStep, promoteArchivePaths } from "@intentius/chant/components/promote";
|
|
38
|
+
import { GATED_WAVE_RECORD, gatedWaveJobs } from "@intentius/chant/components/gated-wave-pipeline";
|
|
39
|
+
import { PR_LOOP_REPORT_DIR, prApplyGroup, prLoopJobs } from "@intentius/chant/components/pr-pipeline";
|
|
38
40
|
import { memberGitlabChanges, memberRepoPath, memberShellDir } from "@intentius/chant/lexicon";
|
|
39
41
|
import type {
|
|
40
42
|
ComponentPipelineJob as GeneratedJob,
|
|
@@ -73,6 +75,19 @@ export function generateGitlabPipeline(
|
|
|
73
75
|
const { waves } = resolveComponentGraph(components);
|
|
74
76
|
const byName = new Map(components.map((c) => [c.name, c]));
|
|
75
77
|
|
|
78
|
+
if (options.prLoop) {
|
|
79
|
+
if (options.gatedWaves || options.promoteTo !== undefined) {
|
|
80
|
+
throw new Error("a pull-request pipeline has no wave or promote jobs; drop --wave-gate and --promote-to, or --pr-loop");
|
|
81
|
+
}
|
|
82
|
+
if (options.member) throw new Error("a pull-request pipeline is not generated for a workspace member yet; generate it from the project outside the workspace");
|
|
83
|
+
return prLoopGitlabPipeline(env, image, beforeScript, extraScript, options);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options.gatedWaves) {
|
|
87
|
+
if (options.promoteTo !== undefined) throw new Error("a gated-wave pipeline has no promote job; drop --promote-to or --wave-gate");
|
|
88
|
+
return gatedWaveGitlabPipeline(components, env, image, beforeScript, extraScript, options);
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
// Components that something else depends on must hand their resolved outputs
|
|
77
92
|
// (stack outputs, published artifact refs) to their dependents, which run as
|
|
78
93
|
// separate jobs/processes. Each such producer dumps its outputs to a file and
|
|
@@ -187,23 +202,114 @@ export function generateGitlabPipeline(
|
|
|
187
202
|
}
|
|
188
203
|
|
|
189
204
|
const sections: string[] = [];
|
|
190
|
-
|
|
191
|
-
// `[]` for an empty one, so the header needs a space in the second case.
|
|
192
|
-
// `stages:[]` is the plain scalar "stages:[]" — a colon opens a mapping only
|
|
193
|
-
// when whitespace or the line's end follows it — which is how GitLab's own
|
|
194
|
-
// reader takes it, and now how `parseYAML` does too (chant #2013).
|
|
195
|
-
sections.push("workflow:" + emitYAML(doc.workflow, 1));
|
|
205
|
+
sections.push(emitYAMLEntry("workflow", doc.workflow));
|
|
196
206
|
// The promote stage is YAML only: `stages` in the result stays one entry
|
|
197
207
|
// per graph wave.
|
|
198
208
|
const yamlStages = promoteJob ? [...stages, "promote"] : stages;
|
|
199
|
-
sections.push(
|
|
200
|
-
if (doc.variables) sections.push("variables
|
|
209
|
+
sections.push(emitYAMLEntry("stages", yamlStages));
|
|
210
|
+
if (doc.variables) sections.push(emitYAMLEntry("variables", doc.variables));
|
|
201
211
|
for (const job of jobs) {
|
|
202
212
|
const props = doc[job.jobName] as Record<string, unknown>;
|
|
203
|
-
sections.push(
|
|
213
|
+
sections.push(emitYAMLEntry(job.jobName, props));
|
|
214
|
+
}
|
|
215
|
+
if (promoteJob) sections.push(emitYAMLEntry(promoteJob, doc[promoteJob]));
|
|
216
|
+
|
|
217
|
+
return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The gated-wave pipeline (#3049): one job per wave, each running one wave of
|
|
222
|
+
* `chant components fan-out --wave-gate`, in order through `needs:`. The
|
|
223
|
+
* attempt record is the artifact GitLab hands from job to job, kept even when
|
|
224
|
+
* a job stops at a gate (exit 3) so the re-run after `chant approve` reads it.
|
|
225
|
+
* `GIT_DEPTH: "0"` gives the job the history `--base` diffs against.
|
|
226
|
+
*/
|
|
227
|
+
function gatedWaveGitlabPipeline(
|
|
228
|
+
components: DriverComponent[],
|
|
229
|
+
env: string,
|
|
230
|
+
image: string,
|
|
231
|
+
beforeScript: string[],
|
|
232
|
+
extraScript: string[],
|
|
233
|
+
options: GenerateGitlabOptions,
|
|
234
|
+
): GenerateGitlabResult {
|
|
235
|
+
const waveJobs = gatedWaveJobs(components, env, options.gatedWaves!);
|
|
236
|
+
const stages = waveJobs.map((j) => j.jobName);
|
|
237
|
+
const doc: Record<string, unknown> = {
|
|
238
|
+
workflow: { name: `chant-components-${env}` },
|
|
239
|
+
stages,
|
|
240
|
+
variables: { ...options.variables, CHANT_ENV: env },
|
|
241
|
+
};
|
|
242
|
+
const jobs: GeneratedJob[] = [];
|
|
243
|
+
for (const job of waveJobs) {
|
|
244
|
+
jobs.push({ jobName: job.jobName, component: `wave ${job.wave}`, stage: job.jobName, needs: job.needs });
|
|
245
|
+
doc[job.jobName] = {
|
|
246
|
+
stage: job.jobName,
|
|
247
|
+
image,
|
|
248
|
+
variables: { GIT_DEPTH: "0" },
|
|
249
|
+
script: [...beforeScript, job.command.join(" "), ...extraScript],
|
|
250
|
+
...(job.needs.length > 0 ? { needs: job.needs } : {}),
|
|
251
|
+
artifacts: { when: "always", paths: [GATED_WAVE_RECORD] },
|
|
252
|
+
};
|
|
204
253
|
}
|
|
205
|
-
if (
|
|
254
|
+
if (options.member) scopeToMember(doc, jobs, undefined, options.member, env);
|
|
255
|
+
const sections = [
|
|
256
|
+
emitYAMLEntry("workflow", doc.workflow),
|
|
257
|
+
emitYAMLEntry("stages", stages),
|
|
258
|
+
emitYAMLEntry("variables", doc.variables),
|
|
259
|
+
...jobs.map((job) => emitYAMLEntry(job.jobName, doc[job.jobName])),
|
|
260
|
+
];
|
|
261
|
+
return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
|
|
262
|
+
}
|
|
206
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The merge-request pipeline (#3183): `plan` in each merge request pipeline,
|
|
266
|
+
* `apply` on each push to the target branch, one apply at a time per
|
|
267
|
+
* environment (`resource_group`). `GIT_DEPTH: "0"` gives both the history
|
|
268
|
+
* they measure the change with. The jobs talk to GitLab with
|
|
269
|
+
* `CHANT_FORGE_TOKEN`, a project or group access token with the `api` scope
|
|
270
|
+
* set as a masked CI/CD variable, since a job token cannot write notes.
|
|
271
|
+
*/
|
|
272
|
+
function prLoopGitlabPipeline(
|
|
273
|
+
env: string,
|
|
274
|
+
image: string,
|
|
275
|
+
beforeScript: string[],
|
|
276
|
+
extraScript: string[],
|
|
277
|
+
options: GenerateGitlabOptions,
|
|
278
|
+
): GenerateGitlabResult {
|
|
279
|
+
const loop = options.prLoop!;
|
|
280
|
+
const [plan, apply] = prLoopJobs("gitlab", env, loop);
|
|
281
|
+
const onMergeRequest = '$CI_PIPELINE_SOURCE == "merge_request_event"';
|
|
282
|
+
const target = loop.branch ? JSON.stringify(loop.branch) : "$CI_DEFAULT_BRANCH";
|
|
283
|
+
const onMerge = `$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == ${target}`;
|
|
284
|
+
const script = (job: typeof plan): string[] => [...beforeScript, job.command, ...extraScript];
|
|
285
|
+
const stages = ["plan", "apply"];
|
|
286
|
+
const doc: Record<string, unknown> = {
|
|
287
|
+
workflow: { name: `chant-pr-${env}`, rules: [{ if: onMergeRequest }, { if: onMerge }] },
|
|
288
|
+
stages,
|
|
289
|
+
variables: { ...options.variables, CHANT_ENV: env, GIT_DEPTH: "0" },
|
|
290
|
+
plan: {
|
|
291
|
+
stage: "plan",
|
|
292
|
+
image,
|
|
293
|
+
rules: [{ if: onMergeRequest }],
|
|
294
|
+
variables: plan.env,
|
|
295
|
+
script: script(plan),
|
|
296
|
+
artifacts: { when: "always", paths: [PR_LOOP_REPORT_DIR] },
|
|
297
|
+
},
|
|
298
|
+
apply: {
|
|
299
|
+
stage: "apply",
|
|
300
|
+
image,
|
|
301
|
+
resource_group: prApplyGroup(env),
|
|
302
|
+
rules: [{ if: onMerge }],
|
|
303
|
+
variables: apply.env,
|
|
304
|
+
script: script(apply),
|
|
305
|
+
artifacts: { when: "always", paths: [PR_LOOP_REPORT_DIR] },
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
const jobs: GeneratedJob[] = [
|
|
309
|
+
{ jobName: "plan", component: "merge request plan", stage: "plan", needs: [] },
|
|
310
|
+
{ jobName: "apply", component: "merge request apply", stage: "apply", needs: [] },
|
|
311
|
+
];
|
|
312
|
+
const sections = ["workflow", "stages", "variables", "plan", "apply"].map((key) => emitYAMLEntry(key, doc[key]));
|
|
207
313
|
return { yaml: sections.join("\n\n") + "\n", stages, jobs, env };
|
|
208
314
|
}
|
|
209
315
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated merge-request pipeline for GitLab CI (#3183): a plan job in
|
|
3
|
+
* each merge request pipeline, an apply job on each push to the default
|
|
4
|
+
* branch. The whole file has a golden, and it validates against the GitLab
|
|
5
|
+
* CI schema this lexicon vendors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { describe, expect, test } from "vitest";
|
|
12
|
+
import { parseYAML } from "@intentius/chant/yaml";
|
|
13
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
14
|
+
import { generateGitlabPipeline } from "./generate-pipeline";
|
|
15
|
+
|
|
16
|
+
const ESTATE: DriverComponent[] = [
|
|
17
|
+
{ name: "net", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "net" }] }] },
|
|
18
|
+
{ name: "app", dependsOn: ["net"], deploy: [{ phase: "Apply", steps: [{ kind: "terraform-apply", root: "app" }] }] },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const GOLDEN = join(import.meta.dirname, "__fixtures__", "pr-loop.gitlab.golden.yml");
|
|
22
|
+
const SCHEMA = join(import.meta.dirname, "..", "codegen", "vendored", "gitlab-ci-schema.json");
|
|
23
|
+
|
|
24
|
+
interface Job {
|
|
25
|
+
rules: Array<{ if: string }>;
|
|
26
|
+
variables: Record<string, string>;
|
|
27
|
+
script: string[];
|
|
28
|
+
resource_group?: string;
|
|
29
|
+
artifacts: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("the GitLab merge-request pipeline", () => {
|
|
33
|
+
const result = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: {} });
|
|
34
|
+
const doc = parseYAML(result.yaml) as { workflow: { rules: unknown[] }; variables: Record<string, string>; plan: Job; apply: Job };
|
|
35
|
+
|
|
36
|
+
test("matches its golden", () => {
|
|
37
|
+
if (process.env.UPDATE_GOLDEN) writeFileSync(GOLDEN, result.yaml);
|
|
38
|
+
expect(result.yaml).toBe(readFileSync(GOLDEN, "utf-8"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("validates against the vendored GitLab CI schema", () => {
|
|
42
|
+
const req = createRequire(import.meta.url);
|
|
43
|
+
const mod = req("ajv") as { default?: unknown };
|
|
44
|
+
const Ajv = (mod.default ?? mod) as new (opts: object) => { compile(s: object): ((d: unknown) => boolean) & { errors?: unknown } };
|
|
45
|
+
const validate = new Ajv({ strict: false, allErrors: true }).compile(JSON.parse(readFileSync(SCHEMA, "utf-8")));
|
|
46
|
+
const ok = validate(doc);
|
|
47
|
+
expect(ok, JSON.stringify(validate.errors, null, 2)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("plans in a merge request pipeline from its diff base, applies on a push to the default branch, one at a time", () => {
|
|
51
|
+
expect(doc.plan.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "merge_request_event"' }]);
|
|
52
|
+
expect(doc.plan.variables).toEqual({ BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID" });
|
|
53
|
+
expect(doc.plan.script).toEqual(['chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge gitlab']);
|
|
54
|
+
expect(doc.apply.rules).toEqual([{ if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' }]);
|
|
55
|
+
expect(doc.apply.variables).toEqual({ BASE_SHA: "$CI_COMMIT_BEFORE_SHA" });
|
|
56
|
+
expect(doc.apply.script[0]).toContain("--require-review");
|
|
57
|
+
expect(doc.apply.resource_group).toBe("chant-apply-prod");
|
|
58
|
+
expect(doc.variables.GIT_DEPTH).toBe("0");
|
|
59
|
+
expect(doc.apply.artifacts).toEqual({ when: "always", paths: [".chant/pr"] });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a named branch replaces the default branch in the apply rule", () => {
|
|
63
|
+
const yaml = generateGitlabPipeline(ESTATE, { env: "prod", prLoop: { branch: "trunk" } }).yaml;
|
|
64
|
+
expect(yaml).toContain('$CI_COMMIT_BRANCH == "trunk"');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("refuses a wave or promote job beside it", () => {
|
|
68
|
+
expect(() => generateGitlabPipeline(ESTATE, { prLoop: {}, gatedWaves: { gate: "g" } })).toThrow(/pull-request pipeline has no wave or promote jobs/);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -153,3 +153,32 @@ describe("WGL011: unreachable job", () => {
|
|
|
153
153
|
expect(diags).toHaveLength(0);
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
|
+
|
|
157
|
+
describe("WGL010: job ids the old line parser skipped (#3256)", () => {
|
|
158
|
+
test("flags a job whose id starts with a capital", () => {
|
|
159
|
+
const yaml = `stages:
|
|
160
|
+
- build
|
|
161
|
+
|
|
162
|
+
Build_Docs:
|
|
163
|
+
stage: docs
|
|
164
|
+
script:
|
|
165
|
+
- make docs
|
|
166
|
+
`;
|
|
167
|
+
const diags = wgl010.check(makeCtx(yaml));
|
|
168
|
+
expect(diags).toHaveLength(1);
|
|
169
|
+
expect(diags[0].entity).toBe("Build_Docs");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("reads a flow-list stages: and jobs with no blank line between them", () => {
|
|
173
|
+
const yaml = `stages: [build, test]
|
|
174
|
+
build:
|
|
175
|
+
stage: build
|
|
176
|
+
script: make
|
|
177
|
+
lint:
|
|
178
|
+
stage: lint
|
|
179
|
+
script: make lint
|
|
180
|
+
`;
|
|
181
|
+
const diags = wgl010.check(makeCtx(yaml));
|
|
182
|
+
expect(diags.map((d) => d.entity)).toEqual(["lint"]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -162,3 +162,56 @@ build:
|
|
|
162
162
|
expect(diags[1].message).toContain("ghost2");
|
|
163
163
|
});
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
describe("WGL013: needs: forms the old line parser misread (#3256)", () => {
|
|
167
|
+
test("a dangling need after an artifacts: line is reported", () => {
|
|
168
|
+
const yaml = `build:
|
|
169
|
+
script: make
|
|
170
|
+
|
|
171
|
+
deploy:
|
|
172
|
+
needs:
|
|
173
|
+
- job: build
|
|
174
|
+
artifacts: true
|
|
175
|
+
- job: missing
|
|
176
|
+
script: ./deploy.sh
|
|
177
|
+
`;
|
|
178
|
+
const diags = checkInvalidNeeds(makeCtx(yaml));
|
|
179
|
+
expect(diags).toHaveLength(1);
|
|
180
|
+
expect(diags[0].message).toContain('"missing"');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a need on a job whose id starts with a capital is not dangling", () => {
|
|
184
|
+
const yaml = `Build_Docs:
|
|
185
|
+
script: make docs
|
|
186
|
+
|
|
187
|
+
deploy:
|
|
188
|
+
needs: [Build_Docs]
|
|
189
|
+
script: ./deploy.sh
|
|
190
|
+
`;
|
|
191
|
+
expect(checkInvalidNeeds(makeCtx(yaml))).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("an optional need on an absent job is not reported", () => {
|
|
195
|
+
const yaml = `deploy:
|
|
196
|
+
needs:
|
|
197
|
+
- job: maybe-build
|
|
198
|
+
optional: true
|
|
199
|
+
script: ./deploy.sh
|
|
200
|
+
`;
|
|
201
|
+
expect(checkInvalidNeeds(makeCtx(yaml))).toHaveLength(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("cross-pipeline needs are not checked against this pipeline", () => {
|
|
205
|
+
const yaml = `deploy:
|
|
206
|
+
needs:
|
|
207
|
+
- pipeline: $PARENT_PIPELINE_ID
|
|
208
|
+
job: generate
|
|
209
|
+
- project: group/other
|
|
210
|
+
job: build
|
|
211
|
+
ref: main
|
|
212
|
+
artifacts: true
|
|
213
|
+
script: ./deploy.sh
|
|
214
|
+
`;
|
|
215
|
+
expect(checkInvalidNeeds(makeCtx(yaml))).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* Both cause GitLab pipeline validation failures.
|
|
9
9
|
*
|
|
10
10
|
* Caveat: when `include:` is present, referenced jobs may come from
|
|
11
|
-
* included files, so the check is skipped.
|
|
11
|
+
* included files, so the check is skipped. A `- job: x` entry with
|
|
12
|
+
* `optional: true` may name a job that is not in the pipeline (GitLab
|
|
13
|
+
* ignores it), so it is not reported as dangling.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
@@ -36,7 +38,7 @@ export function checkInvalidNeeds(ctx: PostSynthContext): PostSynthDiagnostic[]
|
|
|
36
38
|
entity: jobName,
|
|
37
39
|
lexicon: "gitlab",
|
|
38
40
|
});
|
|
39
|
-
} else if (!jobNames.has(need)) {
|
|
41
|
+
} else if (!jobNames.has(need) && !job.optionalNeeds?.includes(need)) {
|
|
40
42
|
diagnostics.push({
|
|
41
43
|
checkId: "WGL013",
|
|
42
44
|
severity: "error",
|
|
@@ -95,3 +95,26 @@ deploy:
|
|
|
95
95
|
expect(diags[0].message).toContain(".missing");
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
describe("WGL014: templates the old line parser skipped (#3256)", () => {
|
|
100
|
+
test("extends of a hidden template whose name has a capital is resolved", () => {
|
|
101
|
+
const yaml = `.Base:
|
|
102
|
+
image: node:20
|
|
103
|
+
|
|
104
|
+
lint:
|
|
105
|
+
extends: .Base
|
|
106
|
+
script: npm run lint
|
|
107
|
+
`;
|
|
108
|
+
expect(checkInvalidExtends(makeCtx(yaml))).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a job whose id starts with a capital is checked", () => {
|
|
112
|
+
const yaml = `Lint:
|
|
113
|
+
extends: .missing
|
|
114
|
+
script: npm run lint
|
|
115
|
+
`;
|
|
116
|
+
const diags = checkInvalidExtends(makeCtx(yaml));
|
|
117
|
+
expect(diags).toHaveLength(1);
|
|
118
|
+
expect(diags[0].entity).toBe("Lint");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -137,3 +137,38 @@ job-a:
|
|
|
137
137
|
expect(diags).toHaveLength(0);
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
describe("WGL015: needs the old line parser dropped (#3256)", () => {
|
|
142
|
+
test("finds a cycle through a need listed after an artifacts: line", () => {
|
|
143
|
+
const yaml = `build:
|
|
144
|
+
needs: [deploy]
|
|
145
|
+
script: make
|
|
146
|
+
|
|
147
|
+
deploy:
|
|
148
|
+
needs:
|
|
149
|
+
- job: lint
|
|
150
|
+
artifacts: true
|
|
151
|
+
- job: build
|
|
152
|
+
script: ./deploy.sh
|
|
153
|
+
|
|
154
|
+
lint:
|
|
155
|
+
script: make lint
|
|
156
|
+
`;
|
|
157
|
+
const diags = checkCircularNeeds(makeCtx(yaml));
|
|
158
|
+
expect(diags).toHaveLength(1);
|
|
159
|
+
expect(diags[0].message).toContain("build");
|
|
160
|
+
expect(diags[0].message).toContain("deploy");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("finds a cycle through a job whose id starts with a capital", () => {
|
|
164
|
+
const yaml = `Build:
|
|
165
|
+
needs: [deploy]
|
|
166
|
+
script: make
|
|
167
|
+
|
|
168
|
+
deploy:
|
|
169
|
+
needs: [Build]
|
|
170
|
+
script: ./deploy.sh
|
|
171
|
+
`;
|
|
172
|
+
expect(checkCircularNeeds(makeCtx(yaml))).toHaveLength(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -51,3 +51,17 @@ describe("WGL017: Insecure Registry", () => {
|
|
|
51
51
|
expect(diags).toHaveLength(0);
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
describe("WGL017: job ids the old line parser skipped (#3256)", () => {
|
|
56
|
+
test("flags a job whose id starts with a capital, with no blank line before it", () => {
|
|
57
|
+
const yaml = `build:
|
|
58
|
+
script: make
|
|
59
|
+
Push_Image:
|
|
60
|
+
script:
|
|
61
|
+
- docker push http://registry.local/app:latest
|
|
62
|
+
`;
|
|
63
|
+
const diags = checkInsecureRegistry(yaml);
|
|
64
|
+
expect(diags).toHaveLength(1);
|
|
65
|
+
expect(diags[0].entity).toBe("Push_Image");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -6,23 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
-
import { getPrimaryOutput,
|
|
9
|
+
import { getPrimaryOutput, topLevelSections } from "./yaml-helpers";
|
|
10
10
|
|
|
11
11
|
const INSECURE_REGISTRY_PATTERN = /docker\s+(push|pull|tag|login)\s+.*http:\/\/[^\s]+/;
|
|
12
12
|
|
|
13
13
|
export function checkInsecureRegistry(yaml: string): PostSynthDiagnostic[] {
|
|
14
14
|
const diagnostics: PostSynthDiagnostic[] = [];
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const lines = section.split("\n");
|
|
19
|
-
if (lines.length === 0) continue;
|
|
20
|
-
|
|
21
|
-
const topMatch = lines[0].match(/^(\.?[a-z][a-z0-9_.-]*):/);
|
|
22
|
-
if (!topMatch) continue;
|
|
23
|
-
const jobName = topMatch[1];
|
|
24
|
-
|
|
25
|
-
for (const line of lines) {
|
|
16
|
+
for (const { key: jobName, text } of topLevelSections(yaml)) {
|
|
17
|
+
for (const line of text.split("\n")) {
|
|
26
18
|
if (INSECURE_REGISTRY_PATTERN.test(line)) {
|
|
27
19
|
diagnostics.push({
|
|
28
20
|
checkId: "WGL017",
|
|
@@ -52,3 +52,29 @@ variables:
|
|
|
52
52
|
expect(diags).toHaveLength(0);
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
describe("WGL020: keys the old line parser skipped (#3256)", () => {
|
|
57
|
+
test("flags a duplicated job id that starts with a capital", () => {
|
|
58
|
+
const yaml = `Build_Docs:
|
|
59
|
+
script: a
|
|
60
|
+
|
|
61
|
+
Build_Docs:
|
|
62
|
+
script: b
|
|
63
|
+
`;
|
|
64
|
+
const diags = checkDuplicateJobNames(yaml);
|
|
65
|
+
expect(diags).toHaveLength(1);
|
|
66
|
+
expect(diags[0].entity).toBe("Build_Docs");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("treats a quoted and a plain key as the same job, and skips global keywords", () => {
|
|
70
|
+
const yaml = `image: node:20
|
|
71
|
+
build:
|
|
72
|
+
script: a
|
|
73
|
+
"build":
|
|
74
|
+
script: b
|
|
75
|
+
image: node:22
|
|
76
|
+
`;
|
|
77
|
+
const diags = checkDuplicateJobNames(yaml);
|
|
78
|
+
expect(diags.map((d) => d.entity)).toEqual(["build"]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
-
import { getPrimaryOutput,
|
|
10
|
+
import { getPrimaryOutput, topLevelKey, RESERVED_TOP_LEVEL_KEYS } from "./yaml-helpers";
|
|
11
11
|
|
|
12
12
|
export function checkDuplicateJobNames(yaml: string): PostSynthDiagnostic[] {
|
|
13
13
|
const diagnostics: PostSynthDiagnostic[] = [];
|
|
@@ -18,10 +18,9 @@ export function checkDuplicateJobNames(yaml: string): PostSynthDiagnostic[] {
|
|
|
18
18
|
const lines = yaml.split("\n");
|
|
19
19
|
|
|
20
20
|
for (const line of lines) {
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
if (["stages", "default", "workflow", "variables", "include"].includes(name)) continue;
|
|
21
|
+
const name = topLevelKey(line);
|
|
22
|
+
if (name !== undefined) {
|
|
23
|
+
if (RESERVED_TOP_LEVEL_KEYS.has(name)) continue;
|
|
25
24
|
keyCounts.set(name, (keyCounts.get(name) ?? 0) + 1);
|
|
26
25
|
}
|
|
27
26
|
}
|
|
@@ -60,3 +60,32 @@ deploy-job:
|
|
|
60
60
|
expect(diags).toHaveLength(0);
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
|
+
|
|
64
|
+
describe("WGL021: expanded variables (#3256)", () => {
|
|
65
|
+
test("reads the value:/description: form by variable name", () => {
|
|
66
|
+
const yaml = `variables:
|
|
67
|
+
DEPLOY_ENV:
|
|
68
|
+
value: staging
|
|
69
|
+
description: Target environment
|
|
70
|
+
|
|
71
|
+
deploy:
|
|
72
|
+
script:
|
|
73
|
+
- ./deploy.sh $DEPLOY_ENV
|
|
74
|
+
`;
|
|
75
|
+
expect(checkUnusedVariables(yaml)).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("reports an unused expanded variable by its name", () => {
|
|
79
|
+
const yaml = `variables:
|
|
80
|
+
UNUSED_ENV:
|
|
81
|
+
value: staging
|
|
82
|
+
description: Never read
|
|
83
|
+
|
|
84
|
+
deploy:
|
|
85
|
+
script:
|
|
86
|
+
- ./deploy.sh
|
|
87
|
+
`;
|
|
88
|
+
const diags = checkUnusedVariables(yaml);
|
|
89
|
+
expect(diags.map((d) => d.entity)).toEqual(["UNUSED_ENV"]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
-
import { getPrimaryOutput, extractGlobalVariables } from "./yaml-helpers";
|
|
9
|
+
import { getPrimaryOutput, extractGlobalVariables, topLevelSections } from "./yaml-helpers";
|
|
10
10
|
|
|
11
11
|
export function checkUnusedVariables(yaml: string): PostSynthDiagnostic[] {
|
|
12
12
|
const diagnostics: PostSynthDiagnostic[] = [];
|
|
@@ -20,14 +20,13 @@ export function checkUnusedVariables(yaml: string): PostSynthDiagnostic[] {
|
|
|
20
20
|
// Check if $VARNAME or ${VARNAME} appears anywhere in the YAML (outside the variables block)
|
|
21
21
|
const refPattern = new RegExp(`\\$\\{?${varName}\\}?`);
|
|
22
22
|
// Also check for uses in extends, needs, etc. — search all sections
|
|
23
|
-
const sections = yaml.split("\n\n");
|
|
24
23
|
let found = false;
|
|
25
24
|
|
|
26
|
-
for (const
|
|
25
|
+
for (const { key, text } of topLevelSections(yaml)) {
|
|
27
26
|
// Skip the global variables section itself
|
|
28
|
-
if (
|
|
27
|
+
if (key === "variables") continue;
|
|
29
28
|
|
|
30
|
-
if (refPattern.test(
|
|
29
|
+
if (refPattern.test(text)) {
|
|
31
30
|
found = true;
|
|
32
31
|
break;
|
|
33
32
|
}
|
|
@@ -93,3 +93,30 @@ test-app:
|
|
|
93
93
|
expect(diags).toHaveLength(0);
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
describe("WGL028: needs the old line parser dropped (#3256)", () => {
|
|
98
|
+
test("reads a need listed after an artifacts: line", () => {
|
|
99
|
+
const yaml = `stages:
|
|
100
|
+
- build
|
|
101
|
+
- deploy
|
|
102
|
+
|
|
103
|
+
lint:
|
|
104
|
+
stage: build
|
|
105
|
+
script: make lint
|
|
106
|
+
|
|
107
|
+
build:
|
|
108
|
+
stage: build
|
|
109
|
+
script: make
|
|
110
|
+
|
|
111
|
+
deploy:
|
|
112
|
+
stage: deploy
|
|
113
|
+
needs:
|
|
114
|
+
- job: lint
|
|
115
|
+
artifacts: false
|
|
116
|
+
- job: build
|
|
117
|
+
script: ./deploy.sh
|
|
118
|
+
`;
|
|
119
|
+
const diags = checkRedundantNeeds(yaml);
|
|
120
|
+
expect(diags.map((d) => d.message.match(/lists "([^"]+)"/)?.[1])).toEqual(["lint", "build"]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -43,3 +43,17 @@ describe("WGL042: unreachable rules after unconditional match", () => {
|
|
|
43
43
|
expect(diags).toHaveLength(0);
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
describe("WGL042: job ids the old line parser skipped (#3256)", () => {
|
|
48
|
+
test("checks a job whose id starts with a capital", () => {
|
|
49
|
+
const yaml = `Build_Docs:
|
|
50
|
+
rules:
|
|
51
|
+
- when: always
|
|
52
|
+
- if: $CI_COMMIT_BRANCH == "main"
|
|
53
|
+
script: make docs
|
|
54
|
+
`;
|
|
55
|
+
const diags = wgl042.check(makeCtx(yaml));
|
|
56
|
+
expect(diags).toHaveLength(1);
|
|
57
|
+
expect(diags[0].entity).toBe("Build_Docs");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -54,3 +54,17 @@ build:
|
|
|
54
54
|
expect(diags).toHaveLength(0);
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
describe("WGL048: section boundaries (#3256)", () => {
|
|
59
|
+
test("a name: in the next job is not read as workflow:name", () => {
|
|
60
|
+
const yaml = `workflow:
|
|
61
|
+
rules:
|
|
62
|
+
- when: always
|
|
63
|
+
build:
|
|
64
|
+
image:
|
|
65
|
+
name: node:20
|
|
66
|
+
script: make
|
|
67
|
+
`;
|
|
68
|
+
expect(wgl048.check(makeCtx(yaml))).toHaveLength(1);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
-
import { getPrimaryOutput } from "./yaml-helpers";
|
|
11
|
+
import { getPrimaryOutput, topLevelSections } from "./yaml-helpers";
|
|
12
12
|
|
|
13
13
|
export const wgl048: PostSynthCheck = {
|
|
14
14
|
id: "WGL048",
|
|
@@ -19,7 +19,7 @@ export const wgl048: PostSynthCheck = {
|
|
|
19
19
|
|
|
20
20
|
for (const [, output] of ctx.outputs) {
|
|
21
21
|
const yaml = getPrimaryOutput(output);
|
|
22
|
-
const workflowSection = yaml
|
|
22
|
+
const workflowSection = topLevelSections(yaml).find((s) => s.key === "workflow")?.text;
|
|
23
23
|
if (!workflowSection) continue;
|
|
24
24
|
if (!/^\s+name:/m.test(workflowSection)) {
|
|
25
25
|
diagnostics.push({
|