@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
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Helpers for parsing serialized GitLab CI YAML in post-synth checks.
3
3
  *
4
- * Since GitLab CI output is YAML (not JSON like CloudFormation), we parse
5
- * the YAML sections structurally using simple regex/string parsing. The
6
- * serializer emits a predictable format so we can extract what we need
7
- * without a full YAML parser dependency.
4
+ * Jobs, stages and global variables are read with the structural parser
5
+ * (`parseYAML`, js-yaml with chant's schema). Checks that pattern-match a
6
+ * job's text get it from {@link extractJobSection}, which cuts the document
7
+ * at top-level keys rather than at blank lines, so it does not depend on the
8
+ * serializer's spacing.
8
9
  */
9
10
 
11
+ import { parseYAML } from "@intentius/chant/yaml";
12
+
10
13
  export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
11
14
 
12
15
  /**
@@ -22,7 +25,15 @@ export interface ParsedJob {
22
25
  name: string;
23
26
  stage?: string;
24
27
  rules?: ParsedRule[];
28
+ /**
29
+ * Same-pipeline jobs this job needs, in every form GitLab accepts: a plain
30
+ * name, `- job: name` (with `artifacts:`, `optional:`, `parallel:`), and
31
+ * `needs: []`. Cross-pipeline needs (`pipeline:` or `project:`) name jobs
32
+ * in another pipeline and are left out.
33
+ */
25
34
  needs?: string[];
35
+ /** The subset of {@link needs} declared `optional: true`. */
36
+ optionalNeeds?: string[];
26
37
  extends?: string[];
27
38
  }
28
39
 
@@ -32,104 +43,115 @@ export interface ParsedRule {
32
43
  }
33
44
 
34
45
  /**
35
- * Extract stages list from serialized YAML.
46
+ * Top-level keys that are global keywords, not jobs. `image`, `services`,
47
+ * `cache`, `before_script` and `after_script` are the deprecated global
48
+ * forms of the `default:` keywords; `spec` is a CI component's header.
36
49
  */
37
- export function extractStages(yaml: string): string[] {
38
- const stages: string[] = [];
39
- const stagesMatch = yaml.match(/^stages:\n((?:\s+- .+\n?)+)/m);
40
- if (stagesMatch) {
41
- for (const line of stagesMatch[1].split("\n")) {
42
- const item = line.match(/^\s+- (.+)$/);
43
- if (item) stages.push(item[1].trim().replace(/^'|'$/g, ""));
44
- }
50
+ export const RESERVED_TOP_LEVEL_KEYS: ReadonlySet<string> = new Set([
51
+ "stages",
52
+ "variables",
53
+ "default",
54
+ "include",
55
+ "workflow",
56
+ "image",
57
+ "services",
58
+ "cache",
59
+ "before_script",
60
+ "after_script",
61
+ "spec",
62
+ ]);
63
+
64
+ /** Parse the pipeline document; `undefined` when the YAML cannot be read. */
65
+ export function parsePipeline(yaml: string): Record<string, unknown> | undefined {
66
+ try {
67
+ const doc = parseYAML(yaml);
68
+ return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : undefined;
69
+ } catch {
70
+ return undefined;
45
71
  }
46
- return stages;
47
72
  }
48
73
 
49
- /**
50
- * Extract job names and their stage values from serialized YAML.
51
- */
52
- export function extractJobs(yaml: string): Map<string, ParsedJob> {
53
- const jobs = new Map<string, ParsedJob>();
74
+ function isMapping(v: unknown): v is Record<string, unknown> {
75
+ return !!v && typeof v === "object" && !Array.isArray(v);
76
+ }
54
77
 
55
- // Split into sections by double newlines
56
- const sections = yaml.split("\n\n");
57
- for (const section of sections) {
58
- const lines = section.split("\n");
59
- if (lines.length === 0) continue;
78
+ function scalarString(v: unknown): string | undefined {
79
+ if (typeof v === "string") return v;
80
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
81
+ return undefined;
82
+ }
60
83
 
61
- // Top-level key (including dot-prefixed hidden jobs like .deploy-template)
62
- const topMatch = lines[0].match(/^(\.?[a-z][a-z0-9_.-]*):/);
63
- if (!topMatch) continue;
84
+ /**
85
+ * Every job in the pipeline as `[id, rawJobObject]`: each top-level key whose
86
+ * value is a mapping, minus the global keywords. Hidden jobs (ids starting
87
+ * with `.`, used as `extends:` templates) are included.
88
+ */
89
+ export function jobEntries(yaml: string): Array<[string, Record<string, unknown>]> {
90
+ const doc = parsePipeline(yaml);
91
+ if (!doc) return [];
92
+ const out: Array<[string, Record<string, unknown>]> = [];
93
+ for (const [name, val] of Object.entries(doc)) {
94
+ if (RESERVED_TOP_LEVEL_KEYS.has(name)) continue;
95
+ if (isMapping(val)) out.push([name, val]);
96
+ }
97
+ return out;
98
+ }
64
99
 
65
- const name = topMatch[1];
66
- // Skip reserved keys
67
- if (["stages", "default", "workflow", "variables", "include"].includes(name)) continue;
100
+ /**
101
+ * Extract stages list from serialized YAML (block or flow list).
102
+ */
103
+ export function extractStages(yaml: string): string[] {
104
+ const stages = parsePipeline(yaml)?.stages;
105
+ if (!Array.isArray(stages)) return [];
106
+ return stages.map(scalarString).filter((s): s is string => s !== undefined);
107
+ }
68
108
 
109
+ /**
110
+ * Extract jobs with their stage, `needs:` and `extends:` from serialized YAML.
111
+ *
112
+ * Built on the structural parser, so every job id is read (capitals, spaces,
113
+ * colons, quoted ids) and every `needs:` entry is read whatever keys follow
114
+ * `job:` in its map. The line-based version this replaced skipped ids that
115
+ * start with a capital and stopped reading a `needs:` list at an
116
+ * `artifacts: true` line (#3256). YAML the parser cannot read gives no jobs.
117
+ */
118
+ export function extractJobs(yaml: string): Map<string, ParsedJob> {
119
+ const jobs = new Map<string, ParsedJob>();
120
+ for (const [name, obj] of jobEntries(yaml)) {
69
121
  const job: ParsedJob = { name };
70
122
 
71
- // Find stage, needs, extends within the section
72
- let inNeeds = false;
73
- for (const line of lines) {
74
- const stageMatch = line.match(/^\s+stage:\s+(.+)$/);
75
- if (stageMatch) {
76
- job.stage = stageMatch[1].trim().replace(/^'|'$/g, "");
77
- }
78
-
79
- // extends: .template or extends: [.a, .b]
80
- const extendsMatch = line.match(/^\s+extends:\s+(.+)$/);
81
- if (extendsMatch) {
82
- const val = extendsMatch[1].trim();
83
- if (val.startsWith("[")) {
84
- // Inline array: [.a, .b]
85
- job.extends = val.slice(1, -1).split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
86
- } else {
87
- job.extends = [val.replace(/^'|'$/g, "")];
88
- }
89
- }
90
-
91
- // needs: block (list form)
92
- if (line.match(/^\s+needs:$/)) {
93
- inNeeds = true;
94
- job.needs = [];
95
- continue;
96
- }
97
-
98
- if (inNeeds) {
99
- // - job-name (simple string form)
100
- const simpleNeed = line.match(/^\s+- ([a-z][a-z0-9_.-]*)$/);
101
- if (simpleNeed) {
102
- job.needs!.push(simpleNeed[1]);
103
- continue;
104
- }
105
- // - 'job-name' (quoted string form)
106
- const quotedNeed = line.match(/^\s+- '([^']+)'$/);
107
- if (quotedNeed) {
108
- job.needs!.push(quotedNeed[1]);
109
- continue;
110
- }
111
- // - job: job-name (object form)
112
- const objectNeed = line.match(/^\s+- job:\s+(.+)$/);
113
- if (objectNeed) {
114
- job.needs!.push(objectNeed[1].trim().replace(/^'|'$/g, ""));
123
+ const stage = scalarString(obj.stage);
124
+ if (stage !== undefined) job.stage = stage;
125
+
126
+ if (obj.needs !== undefined && obj.needs !== null) {
127
+ const entries = Array.isArray(obj.needs) ? obj.needs : [obj.needs];
128
+ const needs: string[] = [];
129
+ const optional: string[] = [];
130
+ for (const entry of entries) {
131
+ const plain = scalarString(entry);
132
+ if (plain !== undefined) {
133
+ needs.push(plain);
115
134
  continue;
116
135
  }
117
- // End of needs block when we hit a non-indented-list line
118
- if (!line.match(/^\s+\s/) || line.match(/^\s+[a-z_]+:/)) {
119
- inNeeds = false;
120
- }
136
+ if (!isMapping(entry)) continue;
137
+ // Cross-pipeline needs name a job in another pipeline.
138
+ if ("pipeline" in entry || "project" in entry) continue;
139
+ const target = scalarString(entry.job);
140
+ if (target === undefined) continue;
141
+ needs.push(target);
142
+ if (entry.optional === true) optional.push(target);
121
143
  }
144
+ job.needs = needs;
145
+ if (optional.length > 0) job.optionalNeeds = optional;
146
+ }
122
147
 
123
- // needs: [a, b] (inline array form)
124
- const inlineNeeds = line.match(/^\s+needs:\s+\[(.+)\]$/);
125
- if (inlineNeeds) {
126
- job.needs = inlineNeeds[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
127
- }
148
+ if (obj.extends !== undefined && obj.extends !== null) {
149
+ const ext = Array.isArray(obj.extends) ? obj.extends : [obj.extends];
150
+ job.extends = ext.map(scalarString).filter((s): s is string => s !== undefined);
128
151
  }
129
152
 
130
153
  jobs.set(name, job);
131
154
  }
132
-
133
155
  return jobs;
134
156
  }
135
157
 
@@ -143,34 +165,83 @@ export function hasInclude(yaml: string): boolean {
143
165
  }
144
166
 
145
167
  /**
146
- * Extract global variables from serialized YAML.
168
+ * Extract global variables from serialized YAML. A variable in the expanded
169
+ * form (`value:` / `description:` / `options:`) maps to its `value`.
147
170
  */
148
171
  export function extractGlobalVariables(yaml: string): Map<string, string> {
149
172
  const vars = new Map<string, string>();
150
- const match = yaml.match(/^variables:\n((?:\s+.+\n?)+)/m);
151
- if (!match) return vars;
152
-
153
- for (const line of match[1].split("\n")) {
154
- const kv = line.match(/^\s+(\w+):\s+(.+)$/);
155
- if (kv) {
156
- vars.set(kv[1], kv[2].trim().replace(/^['"]|['"]$/g, ""));
157
- }
173
+ const variables = parsePipeline(yaml)?.variables;
174
+ if (!isMapping(variables)) return vars;
175
+ for (const [key, val] of Object.entries(variables)) {
176
+ const v = isMapping(val) ? scalarString(val.value) : scalarString(val);
177
+ vars.set(key, v ?? "");
158
178
  }
159
179
  return vars;
160
180
  }
161
181
 
162
182
  /**
163
- * Extract the full section text for a given job name.
183
+ * The key of a top-level mapping line (`build:`, `Build_Docs:`,
184
+ * `build:linux:`, `"deploy prod":`), unquoted, or undefined when the line
185
+ * does not start a top-level key.
164
186
  */
165
- export function extractJobSection(yaml: string, jobName: string): string | null {
166
- const sections = yaml.split("\n\n");
167
- for (const section of sections) {
168
- const lines = section.split("\n");
169
- if (lines.length > 0 && lines[0].startsWith(`${jobName}:`)) {
170
- return section;
187
+ export function topLevelKey(line: string): string | undefined {
188
+ if (line === "" || /^[\s#]/.test(line) || /^(---|\.\.\.)(\s|$)/.test(line) || line.startsWith("- ")) return undefined;
189
+ const dq = line.match(/^"((?:[^"\\]|\\.)*)"\s*:(?:\s|$)/);
190
+ if (dq) {
191
+ try {
192
+ return JSON.parse(`"${dq[1]}"`) as string;
193
+ } catch {
194
+ return dq[1];
171
195
  }
172
196
  }
173
- return null;
197
+ const sq = line.match(/^'((?:[^']|'')*)'\s*:(?:\s|$)/);
198
+ if (sq) return sq[1].replace(/''/g, "'");
199
+ const plain = line.match(/^(.+?)\s*:(?:\s|$)/);
200
+ return plain ? plain[1] : undefined;
201
+ }
202
+
203
+ /** A top-level section of the document: its key and its raw text. */
204
+ export interface TopLevelSection {
205
+ key: string;
206
+ text: string;
207
+ }
208
+
209
+ /**
210
+ * Split the document into top-level sections. A section starts at a line
211
+ * holding a top-level key and runs to the next one; blank lines and
212
+ * column-0 comments at its end are dropped. Unlike splitting on blank
213
+ * lines, this does not depend on the serializer's spacing.
214
+ */
215
+ export function topLevelSections(yaml: string): TopLevelSection[] {
216
+ const lines = yaml.replace(/\r\n?/g, "\n").split("\n");
217
+ const out: TopLevelSection[] = [];
218
+ let key: string | undefined;
219
+ let start = 0;
220
+ const flush = (end: number) => {
221
+ if (key === undefined) return;
222
+ let last = end;
223
+ while (last > start + 1 && (lines[last - 1].trim() === "" || lines[last - 1].startsWith("#"))) last--;
224
+ out.push({ key, text: lines.slice(start, last).join("\n") });
225
+ };
226
+ for (let i = 0; i < lines.length; i++) {
227
+ const line = lines[i];
228
+ const k = topLevelKey(line);
229
+ const boundary = k !== undefined || /^(---|\.\.\.)(\s|$)/.test(line);
230
+ if (!boundary) continue;
231
+ flush(i);
232
+ key = k;
233
+ start = i;
234
+ }
235
+ flush(lines.length);
236
+ return out;
237
+ }
238
+
239
+ /**
240
+ * Extract the full section text for a given job name (exact key match, so
241
+ * `build` never returns the `build:linux` section).
242
+ */
243
+ export function extractJobSection(yaml: string, jobName: string): string | null {
244
+ return topLevelSections(yaml).find((s) => s.key === jobName)?.text ?? null;
174
245
  }
175
246
 
176
247
  /**
@@ -241,10 +312,8 @@ export interface ImageRef {
241
312
  */
242
313
  export function extractImageRefs(yaml: string): ImageRef[] {
243
314
  const refs: ImageRef[] = [];
244
- for (const section of yaml.split("\n\n")) {
315
+ for (const { key: job, text: section } of topLevelSections(yaml)) {
245
316
  const lines = section.split("\n");
246
- const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
247
- const job = top ? top[1] : "default";
248
317
 
249
318
  for (let i = 0; i < lines.length; i++) {
250
319
  // image: as object (next indented `name:`) or inline string
@@ -285,7 +354,7 @@ export interface IncludeEntry {
285
354
  */
286
355
  export function extractIncludes(yaml: string): IncludeEntry[] {
287
356
  const entries: IncludeEntry[] = [];
288
- const section = yaml.split("\n\n").find((s) => /^include:/.test(s));
357
+ const section = topLevelSections(yaml).find((s) => s.key === "include")?.text;
289
358
  if (!section) return entries;
290
359
 
291
360
  // Inline string form: `include: <value>` (value on the same line, not a list)
@@ -342,11 +411,8 @@ export interface IdTokenDecl {
342
411
  */
343
412
  export function extractIdTokens(yaml: string): IdTokenDecl[] {
344
413
  const out: IdTokenDecl[] = [];
345
- for (const section of yaml.split("\n\n")) {
414
+ for (const { key: job, text: section } of topLevelSections(yaml)) {
346
415
  const lines = section.split("\n");
347
- const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
348
- if (!top) continue;
349
- const job = top[1];
350
416
 
351
417
  let inIdTokens = false;
352
418
  let idTokensIndent = -1;
@@ -408,11 +474,8 @@ export interface ScriptCommand {
408
474
  */
409
475
  export function extractScriptCommands(yaml: string): ScriptCommand[] {
410
476
  const out: ScriptCommand[] = [];
411
- for (const section of yaml.split("\n\n")) {
477
+ for (const { key: job, text: section } of topLevelSections(yaml)) {
412
478
  const lines = section.split("\n");
413
- const top = lines[0]?.match(/^(\.?[a-z][a-z0-9_.-]*):/i);
414
- if (!top) continue;
415
- const job = top[1];
416
479
 
417
480
  let inScript = false;
418
481
  let scriptIndent = -1;
@@ -1 +1 @@
1
- {"version":3,"file":"variables.d.ts","sourceRoot":"","sources":["../src/variables.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;CAuBL,CAAC"}
1
+ {"version":3,"file":"variables.d.ts","sourceRoot":"","sources":["../src/variables.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAO,MAAM,EAAE;aACb,YAAY,EAAE,mBAAmB;aACjC,SAAS,EAAE,qBAAqB;aAChC,aAAa,EAAE,qBAAqB;aACpC,SAAS,EAAE,gBAAgB;aAC3B,SAAS,EAAE,gBAAgB;aAC3B,aAAa,EAAE,oBAAoB;aACnC,WAAW,EAAE,sBAAsB;aACnC,eAAe,EAAE,sBAAsB;aACvC,KAAK,EAAE,YAAY;aACnB,OAAO,EAAE,cAAc;aACvB,QAAQ,EAAE,eAAe;aACzB,eAAe,EAAE,uBAAuB;aACxC,UAAU,EAAE,iBAAiB;aAC7B,cAAc,EAAE,qBAAqB;aACrC,UAAU,EAAE,iBAAiB;aAC7B,SAAS,EAAE,gBAAgB;aAC3B,WAAW,EAAE,kBAAkB;aAC/B,WAAW,EAAE,kBAAkB;aAC/B,QAAQ,EAAE,cAAc;aACxB,aAAa,EAAE,oBAAoB;aACnC,YAAY,EAAE,mBAAmB;aACjC,gBAAgB,EAAE,uBAAuB;CACjC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.101.0",
3
+ "version": "0.104.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",
@@ -59,7 +59,7 @@
59
59
  "validate": "tsx src/validate-cli.ts",
60
60
  "docs": "tsx src/codegen/docs-cli.ts",
61
61
  "prepack": "npm run generate && npm run bundle && npm run validate && npm run build",
62
- "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && find dist -type f \\( -name \"*.js\" -o -name \"*.js.map\" \\) -delete"
62
+ "build": "../../scripts/tsc7.sh -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && find dist -type f \\( -name \"*.js\" -o -name \"*.js.map\" \\) -delete"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@intentius/chant": "*",
@@ -67,8 +67,8 @@
67
67
  "typescript": "^5.9.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@intentius/chant": "^0.101.0",
71
- "@intentius/chant-lexicon-github": "^0.101.0",
70
+ "@intentius/chant": "^0.104.0",
71
+ "@intentius/chant-lexicon-github": "^0.104.0",
72
72
  "typescript": "^5.9.3"
73
73
  },
74
74
  "peerDependenciesMeta": {
@@ -0,0 +1,43 @@
1
+ workflow:
2
+ name: chant-pr-prod
3
+ rules:
4
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
5
+ - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
6
+
7
+ stages:
8
+ - plan
9
+ - apply
10
+
11
+ variables:
12
+ CHANT_ENV: prod
13
+ GIT_DEPTH: '0'
14
+
15
+ plan:
16
+ stage: plan
17
+ image: node:22-slim
18
+ rules:
19
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
20
+ variables:
21
+ BASE_SHA: '$CI_MERGE_REQUEST_DIFF_BASE_SHA'
22
+ PR_NUMBER: '$CI_MERGE_REQUEST_IID'
23
+ script:
24
+ - chant components pr-plan --base "$BASE_SHA" --pr "$PR_NUMBER" --env prod --gate pr-apply --output .chant/pr --forge gitlab
25
+ artifacts:
26
+ when: always
27
+ paths:
28
+ - .chant/pr
29
+
30
+ apply:
31
+ stage: apply
32
+ image: node:22-slim
33
+ resource_group: chant-apply-prod
34
+ rules:
35
+ - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
36
+ variables:
37
+ BASE_SHA: '$CI_COMMIT_BEFORE_SHA'
38
+ script:
39
+ - chant components pr-apply --base "$BASE_SHA" --env prod --gate pr-apply --output .chant/pr --forge gitlab --require-review
40
+ artifacts:
41
+ when: always
42
+ paths:
43
+ - .chant/pr
@@ -105,7 +105,7 @@
105
105
  * beside it does not also point.
106
106
  */
107
107
 
108
- import { emitYAML } from "@intentius/chant/yaml";
108
+ import { emitYAMLEntry } from "@intentius/chant/yaml";
109
109
  import { memberGitlabChanges, memberRepoPath, memberShellDir, resolveOpTrigger } from "@intentius/chant/lexicon";
110
110
  import type {
111
111
  ComponentPipelineOptions as GenerateGitlabOpOptions,
@@ -521,10 +521,10 @@ export function generateGitlabOpPipeline(
521
521
  headerLines.push("#", ...opLines);
522
522
 
523
523
  const sections: string[] = [];
524
- sections.push("stages:" + emitYAML(doc.stages, 1));
525
- if (doc.variables) sections.push("variables:" + emitYAML(doc.variables, 1));
524
+ sections.push(emitYAMLEntry("stages", doc.stages));
525
+ if (doc.variables) sections.push(emitYAMLEntry("variables", doc.variables));
526
526
  for (const { jobName } of jobs) {
527
- sections.push(`${jobName}:` + emitYAML(doc[jobName], 1));
527
+ sections.push(emitYAMLEntry(jobName, doc[jobName]));
528
528
  }
529
529
 
530
530
  const yaml = headerLines.join("\n") + "\n\n" + sections.join("\n\n") + "\n";
@@ -327,3 +327,36 @@ describe("generateGitlabPipeline: a promote job (#2575)", () => {
327
327
  expect(() => generateGitlabPipeline(infraOnly)).not.toThrow();
328
328
  });
329
329
  });
330
+
331
+ describe("generateGitlabPipeline: gated waves (#3049)", () => {
332
+ /** net -> a, b -> app: three waves, four with a canary in front. */
333
+ const roots: DriverComponent[] = [
334
+ { name: "net", deploy: [] },
335
+ { name: "a", dependsOn: ["net"], deploy: [] },
336
+ { name: "b", dependsOn: ["net"], deploy: [] },
337
+ { name: "app", dependsOn: ["a"], deploy: [] },
338
+ ];
339
+
340
+ test("one job per wave, in order, each running that wave of the gated fan-out", () => {
341
+ const result = generateGitlabPipeline(roots, { env: "prod", gatedWaves: { gate: "release" } });
342
+ const parsed = parseYAML(result.yaml) as Record<string, Record<string, unknown>>;
343
+ expect(result.stages).toEqual(["wave-1", "wave-2", "wave-3"]);
344
+ expect(parsed["wave-2"].needs).toEqual(["wave-1"]);
345
+ expect(parsed["wave-1"].needs).toBeUndefined();
346
+ expect(parsed["wave-3"].script).toEqual([
347
+ "chant components fan-out --base HEAD~1 --env prod --wave-gate release --wave 3 --resume .chant/fan-out.json",
348
+ ]);
349
+ // The record goes to the next job even when this one stops at a gate.
350
+ expect(parsed["wave-1"].artifacts).toEqual({ when: "always", paths: [".chant/fan-out.json"] });
351
+ expect(parsed["wave-1"].variables).toEqual({ GIT_DEPTH: "0" });
352
+ // No per-component jobs.
353
+ expect(parsed.net).toBeUndefined();
354
+ });
355
+
356
+ test("a canary list adds a wave in front and is passed to every job", () => {
357
+ const result = generateGitlabPipeline(roots, { env: "prod", gatedWaves: { gate: "release", canary: ["b"], base: "$CI_COMMIT_BEFORE_SHA" } });
358
+ expect(result.stages).toEqual(["wave-1", "wave-2", "wave-3", "wave-4"]);
359
+ const parsed = parseYAML(result.yaml) as Record<string, Record<string, unknown>>;
360
+ expect((parsed["wave-1"].script as string[])[0]).toContain("--base $CI_COMMIT_BEFORE_SHA --env prod --wave-gate release --canary b --wave 1");
361
+ });
362
+ });