@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.
Files changed (59) hide show
  1. package/dist/codegen/naming.d.ts.map +1 -1
  2. package/dist/components/generate-pipeline.d.ts.map +1 -1
  3. package/dist/composites/node-pipeline.d.ts +16 -16
  4. package/dist/composites/node-pipeline.d.ts.map +1 -1
  5. package/dist/generated/index.d.ts +19 -19
  6. package/dist/generated/index.d.ts.map +1 -1
  7. package/dist/import/generator.d.ts.map +1 -1
  8. package/dist/import/parser.d.ts.map +1 -1
  9. package/dist/integrity.json +10 -10
  10. package/dist/intrinsics.d.ts.map +1 -1
  11. package/dist/lint/post-synth/wgl013.d.ts +3 -1
  12. package/dist/lint/post-synth/wgl013.d.ts.map +1 -1
  13. package/dist/lint/post-synth/wgl017.d.ts.map +1 -1
  14. package/dist/lint/post-synth/wgl020.d.ts.map +1 -1
  15. package/dist/lint/post-synth/wgl021.d.ts.map +1 -1
  16. package/dist/lint/post-synth/wgl050.d.ts.map +1 -1
  17. package/dist/lint/post-synth/yaml-helpers.d.ts +57 -8
  18. package/dist/lint/post-synth/yaml-helpers.d.ts.map +1 -1
  19. package/dist/manifest.json +1 -1
  20. package/dist/migrate/from-github/provenance.d.ts.map +1 -1
  21. package/dist/op/builders.d.ts.map +1 -1
  22. package/dist/rules/wgl013.ts +4 -2
  23. package/dist/rules/wgl017.ts +3 -11
  24. package/dist/rules/wgl020.ts +4 -5
  25. package/dist/rules/wgl021.ts +4 -5
  26. package/dist/rules/wgl048.ts +2 -2
  27. package/dist/rules/wgl049.ts +4 -4
  28. package/dist/rules/wgl050.ts +5 -8
  29. package/dist/rules/yaml-helpers.ts +176 -113
  30. package/dist/variables.d.ts.map +1 -1
  31. package/package.json +4 -4
  32. package/src/components/__fixtures__/pr-loop.gitlab.golden.yml +43 -0
  33. package/src/components/generate-op-pipeline.ts +4 -4
  34. package/src/components/generate-pipeline.test.ts +33 -0
  35. package/src/components/generate-pipeline.ts +117 -11
  36. package/src/components/generate-pr-pipeline.test.ts +70 -0
  37. package/src/lint/post-synth/post-synth.test.ts +29 -0
  38. package/src/lint/post-synth/wgl013.test.ts +53 -0
  39. package/src/lint/post-synth/wgl013.ts +4 -2
  40. package/src/lint/post-synth/wgl014.test.ts +23 -0
  41. package/src/lint/post-synth/wgl015.test.ts +35 -0
  42. package/src/lint/post-synth/wgl017.test.ts +14 -0
  43. package/src/lint/post-synth/wgl017.ts +3 -11
  44. package/src/lint/post-synth/wgl020.test.ts +26 -0
  45. package/src/lint/post-synth/wgl020.ts +4 -5
  46. package/src/lint/post-synth/wgl021.test.ts +29 -0
  47. package/src/lint/post-synth/wgl021.ts +4 -5
  48. package/src/lint/post-synth/wgl028.test.ts +27 -0
  49. package/src/lint/post-synth/wgl042.test.ts +14 -0
  50. package/src/lint/post-synth/wgl048.test.ts +14 -0
  51. package/src/lint/post-synth/wgl048.ts +2 -2
  52. package/src/lint/post-synth/wgl049.test.ts +25 -0
  53. package/src/lint/post-synth/wgl049.ts +4 -4
  54. package/src/lint/post-synth/wgl050.test.ts +16 -0
  55. package/src/lint/post-synth/wgl050.ts +5 -8
  56. package/src/lint/post-synth/yaml-helpers.test.ts +249 -0
  57. package/src/lint/post-synth/yaml-helpers.ts +176 -113
  58. package/src/migrate/from-github/fixtures/syntax-mapping/03-matrix/expected.gitlab-ci.yml +3 -1
  59. package/src/serializer.ts +5 -5
@@ -32,9 +32,11 @@
32
32
  * touching the component declarations.
33
33
  */
34
34
 
35
- import { emitYAML } from "@intentius/chant/yaml";
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
- // `emitYAML` returns a `\n`-led block for a non-empty sequence and an inline
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(yamlStages.length > 0 ? "stages:" + emitYAML(yamlStages, 1) : "stages: []");
200
- if (doc.variables) sections.push("variables:" + emitYAML(doc.variables, 1));
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(`${job.jobName}:` + emitYAML(props, 1));
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 (promoteJob) sections.push(`${promoteJob}:` + emitYAML(doc[promoteJob], 1));
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, extractJobs } from "./yaml-helpers";
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 sections = yaml.split("\n\n");
17
- for (const section of sections) {
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, extractJobs } from "./yaml-helpers";
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 topMatch = line.match(/^(\.?[a-z][a-z0-9_.-]*):/);
22
- if (topMatch) {
23
- const name = topMatch[1];
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 section of sections) {
25
+ for (const { key, text } of topLevelSections(yaml)) {
27
26
  // Skip the global variables section itself
28
- if (section.trimStart().startsWith("variables:")) continue;
27
+ if (key === "variables") continue;
29
28
 
30
- if (refPattern.test(section)) {
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.split("\n\n").find((s) => /^workflow:/.test(s));
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({