@intentius/chant-lexicon-gitlab 0.118.1 → 0.120.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.
@@ -1 +1 @@
1
- {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAiFH;;GAEG;AACH,wBAAsB,YAAY,CAAC,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsC9E"}
1
+ {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAyHH;;GAEG;AACH,wBAAsB,YAAY,CAAC,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsC9E"}
@@ -1 +1 @@
1
- {"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../../src/codegen/naming.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,cAAc,IAAI,kBAAkB,EAGrC,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAsC,KAAK,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAErF,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AA8BnF;;;GAGG;AACH,qBAAa,cAAe,SAAQ,kBAAkB;IACpD,YAAY,OAAO,EAAE,iBAAiB,EAAE,EAMvC;CACF"}
1
+ {"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../../src/codegen/naming.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,cAAc,IAAI,kBAAkB,EAGrC,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAsC,KAAK,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAErF,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AA+BnF;;;GAGG;AACH,qBAAa,cAAe,SAAQ,kBAAkB;IACpD,YAAY,OAAO,EAAE,iBAAiB,EAAE,EAMvC;CACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/codegen/parse.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAChF,OAAO,EAEL,kBAAkB,IAAI,sBAAsB,EAI7C,MAAM,sCAAsC,CAAC;AAI9C,YAAY,EAAE,mBAAmB,EAAE,CAAC;AACpC,OAAO,EAAE,sBAAsB,IAAI,kBAAkB,EAAE,CAAC;AAExD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,oBAAoB,EAAE,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,cAAc,CAAC;IACzB,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,yEAAyE;IACzE,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AA+FD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,iBAAiB,EAAE,CAoBxE;AAkcD;;GAEG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGxD;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAG1D"}
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/codegen/parse.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAChF,OAAO,EAEL,kBAAkB,IAAI,sBAAsB,EAI7C,MAAM,sCAAsC,CAAC;AAI9C,YAAY,EAAE,mBAAmB,EAAE,CAAC;AACpC,OAAO,EAAE,sBAAsB,IAAI,kBAAkB,EAAE,CAAC;AAExD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,oBAAoB,EAAE,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,cAAc,CAAC;IACzB,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,yEAAyE;IACzE,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AA2GD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,iBAAiB,EAAE,CAoBxE;AA6dD;;GAEG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGxD;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAG1D"}
@@ -1,5 +1,6 @@
1
1
  export declare const Default: new (props: Record<string, unknown>, attributes?: Record<string, unknown>) => import("@intentius/chant").Declarable & Record<string, string>;
2
2
  export declare const Job: new (props: Record<string, unknown>, attributes?: Record<string, unknown>) => import("@intentius/chant").Declarable & Record<string, string>;
3
+ export declare const Pipeline: new (props: Record<string, unknown>, attributes?: Record<string, unknown>) => import("@intentius/chant").Declarable & Record<string, string>;
3
4
  export declare const Workflow: new (props: Record<string, unknown>, attributes?: Record<string, unknown>) => import("@intentius/chant").Declarable & Record<string, string>;
4
5
  export declare const AllowFailure: new (props: Record<string, unknown>) => import("@intentius/chant").Declarable & Record<string, unknown>;
5
6
  export declare const Artifacts: new (props: Record<string, unknown>) => import("@intentius/chant").Declarable & Record<string, unknown>;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/generated/index.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,OAAO,8IAAsE,CAAC;AAC3F,eAAO,MAAM,GAAG,8IAAkE,CAAC;AACnF,eAAO,MAAM,QAAQ,8IAAuE,CAAC;AAE7F,eAAO,MAAM,YAAY,yGAAuE,CAAC;AACjG,eAAO,MAAM,SAAS,yGAAoE,CAAC;AAC3F,eAAO,MAAM,UAAU,yGAAqE,CAAC;AAC7F,eAAO,MAAM,KAAK,yGAAgE,CAAC;AACnF,eAAO,MAAM,WAAW,yGAAsE,CAAC;AAC/F,eAAO,MAAM,KAAK,yGAAgE,CAAC;AACnF,eAAO,MAAM,OAAO,yGAAkE,CAAC;AACvF,eAAO,MAAM,OAAO,yGAAkE,CAAC;AACvF,eAAO,MAAM,IAAI,yGAA+D,CAAC;AACjF,eAAO,MAAM,QAAQ,yGAAmE,CAAC;AACzF,eAAO,MAAM,OAAO,yGAAkE,CAAC;AACvF,eAAO,MAAM,KAAK,yGAAgE,CAAC;AACnF,eAAO,MAAM,IAAI,yGAA+D,CAAC;AACjF,eAAO,MAAM,OAAO,yGAAkE,CAAC;AACvF,eAAO,MAAM,OAAO,yGAAkE,CAAC;AACvF,eAAO,MAAM,YAAY,yGAAuE,CAAC;AAGjG,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC1C,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/generated/index.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,OAAO,8IAAsE,CAAC;AAC3F,eAAO,MAAM,GAAG,8IAAkE,CAAC;AACnF,eAAO,MAAM,QAAQ,8IAAuE,CAAC;AAC7F,eAAO,MAAM,QAAQ,8IAAuE,CAAC;AAE7F,eAAO,MAAM,YAAY,yGAAuE,CAAC;AACjG,eAAO,MAAM,SAAS,yGAAoE,CAAC;AAC3F,eAAO,MAAM,UAAU,yGAAqE,CAAC;AAC7F,eAAO,MAAM,KAAK,yGAAgE,CAAC;AACnF,eAAO,MAAM,WAAW,yGAAsE,CAAC;AAC/F,eAAO,MAAM,KAAK,yGAAgE,CAAC;AACnF,eAAO,MAAM,OAAO,yGAAkE,CAAC;AACvF,eAAO,MAAM,OAAO,yGAAkE,CAAC;AACvF,eAAO,MAAM,IAAI,yGAA+D,CAAC;AACjF,eAAO,MAAM,QAAQ,yGAAmE,CAAC;AACzF,eAAO,MAAM,OAAO,yGAAkE,CAAC;AACvF,eAAO,MAAM,KAAK,yGAAgE,CAAC;AACnF,eAAO,MAAM,IAAI,yGAA+D,CAAC;AACjF,eAAO,MAAM,OAAO,yGAAkE,CAAC;AACvF,eAAO,MAAM,OAAO,yGAAkE,CAAC;AACvF,eAAO,MAAM,YAAY,yGAAuE,CAAC;AAGjG,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAC1C,OAAO,EAAE,EAAE,EAAE,MAAM,cAAc,CAAC"}
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "c1fbb032e4112bba50f38ddd107b2ca78b519d501211549172134e14c6ff84c3",
5
- "meta.json": "931fc3246a55645b1493080bbeb160e5d205e42349e8bdca96ca243adb5f0da3",
6
- "types/index.d.ts": "5cd2e99f135a929b72bdd822d00d780d39b1cd407ba0cfb3d511c7ac9d667b58",
4
+ "manifest.json": "b2e850bbb36b4d9288070eaa6c6f240149f2487a1534b498184d9282a1f0982e",
5
+ "meta.json": "db082fbf90c5941c498b2bee0f106103449c1a4c257716ca2220f0dd490450b2",
6
+ "types/index.d.ts": "a048a66ebe20da0cb5fc889054cac69044d9bf882e2c56b5c5bf113d1f3ba57c",
7
7
  "rules/artifact-no-expiry.ts": "3f3cabf9792cbf8207e53a25f506715466b19ec25e9c3b4d0d77fed6b2eb4542",
8
8
  "rules/deprecated-only-except.ts": "2341b95c0aaf3013c375fb404969858e23451e3430f3d1a63b128fcb21b90156",
9
9
  "rules/import-source.ts": "b5b2e79ef4c18a21711a380dce60e8bbab840223eda568b18039879d3a0a549f",
@@ -55,5 +55,5 @@
55
55
  "skills/chant-gitlab-migrate.md": "4853d04980560b379e5b0f7267dcb0a1e5d5879cb9e9931681ce76b2a5fe69d8",
56
56
  "skills/chant-gitlab-patterns.md": "6d9a44e9e8de4c3820be9d65381b0d2ede4cb3626aaaf0011e21838a9fbf556e"
57
57
  },
58
- "composite": "d8b0f5de08f945bf3f79d1a76f351def325dae22ddf841d36dd3efbebdafa08a"
58
+ "composite": "c0d291cbf1bda2ce61c417054a931919434b86868d2022cb9d6efc9d5f0175b2"
59
59
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitlab",
3
- "version": "0.118.1",
3
+ "version": "0.120.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GitLab",
6
6
  "intrinsics": [
package/dist/meta.json CHANGED
@@ -181,6 +181,20 @@
181
181
  "kind": "property",
182
182
  "lexicon": "gitlab"
183
183
  },
184
+ "Pipeline": {
185
+ "resourceType": "GitLab::CI::Pipeline",
186
+ "kind": "resource",
187
+ "lexicon": "gitlab",
188
+ "constraints": {
189
+ "stages": {
190
+ "default": [
191
+ "build",
192
+ "test",
193
+ "deploy"
194
+ ]
195
+ }
196
+ }
197
+ },
184
198
  "Release": {
185
199
  "resourceType": "GitLab::CI::Release",
186
200
  "kind": "property",
package/dist/okf/index.md CHANGED
@@ -6,6 +6,7 @@ okf_version: '0.2'
6
6
 
7
7
  * [Default](/types/Default.md) - GitLab::CI::Default
8
8
  * [Job](/types/Job.md) - GitLab::CI::Job
9
+ * [Pipeline](/types/Pipeline.md) - GitLab::CI::Pipeline
9
10
  * [Workflow](/types/Workflow.md) - GitLab::CI::Workflow
10
11
 
11
12
  # Rules
@@ -13,3 +13,4 @@ OIDC id_token mintable from a merge-request pipeline
13
13
  ## Applies to
14
14
 
15
15
  - [Job](/types/Job.md)
16
+ - [Pipeline](/types/Pipeline.md)
@@ -13,3 +13,4 @@ Secret-like variable reachable from a merge-request pipeline
13
13
  ## Applies to
14
14
 
15
15
  - [Job](/types/Job.md)
16
+ - [Pipeline](/types/Pipeline.md)
@@ -13,3 +13,4 @@ Cache populated in a merge-request pipeline (poisoning risk)
13
13
  ## Applies to
14
14
 
15
15
  - [Job](/types/Job.md)
16
+ - [Pipeline](/types/Pipeline.md)
@@ -9,3 +9,7 @@ lexicon: gitlab
9
9
  docs: https://intentius.io/chant/lexicons/gitlab/rules/
10
10
  ---
11
11
  Pipeline defines workflow: but no workflow:name
12
+
13
+ ## Applies to
14
+
15
+ - [Pipeline](/types/Pipeline.md)
@@ -17,7 +17,7 @@ resource_type: GitLab::CI::Default
17
17
  - `hooks` (`Record<string, unknown>`, optional)
18
18
  - `id_tokens` (`Record<string, { aud: string | string[] }>`, optional)
19
19
  - `identity` (`"google_cloud"`, optional)
20
- - `image` (`Image`, optional)
20
+ - `image` (`Image | string`, optional)
21
21
  - `interruptible` (`boolean`, optional)
22
22
  - `retry` (`Retry | number`, optional)
23
23
  - `services` (`Service[]`, optional)
@@ -15,6 +15,7 @@ resource_type: GitLab::CI::Job
15
15
  - `artifacts` (`Artifacts`, optional)
16
16
  - `before_script` (`string | string[]`, optional)
17
17
  - `cache` (`Cache | Cache[]`, optional)
18
+ - `comment` (`string`, optional): YAML comment lines written above the job's key. Not a GitLab key; each line of the string becomes a `#` line
18
19
  - `coverage` (`string`, optional): Must be a regular expression, optionally but recommended to be quoted, and must be surrounded with '/'. Example: '/Code coverage: \d+\.\d+/'
19
20
  - `dependencies` (`string[]`, optional): Specify a list of job names from earlier stages from which artifacts should be loaded. By default, all previous artifacts are passed. Use an empty array to skip downloading artifacts.
20
21
  - `environment` (`Environment | string`, optional): Used to associate environment metadata with a deploy. Environment can have a name and URL attached to it, and will be displayed under /environments under the project.
@@ -23,11 +24,11 @@ resource_type: GitLab::CI::Job
23
24
  - `hooks` (`Record<string, unknown>`, optional)
24
25
  - `id_tokens` (`Record<string, { aud: string | string[] }>`, optional)
25
26
  - `identity` (`"google_cloud"`, optional)
26
- - `image` (`Image`, optional)
27
+ - `image` (`Image | string`, optional)
27
28
  - `inherit` (`Inherit`, optional)
28
29
  - `interruptible` (`boolean`, optional)
29
30
  - `manual_confirmation` (`string`, optional)
30
- - `needs` (`Need[]`, optional): The list of jobs in previous stages whose sole completion is needed to start the current job.
31
+ - `needs` (`(Need | string)[]`, optional): The list of jobs in previous stages whose sole completion is needed to start the current job.
31
32
  - `only` (`any`, optional): Job will run *only* when these filtering options match.
32
33
  - `pages` (`Record<string, any> | boolean`, optional)
33
34
  - `parallel` (`Parallel | number`, optional)
@@ -0,0 +1,23 @@
1
+ ---
2
+ type: resource-type
3
+ title: Pipeline
4
+ description: gitlab resource type GitLab::CI::Pipeline
5
+ name: Pipeline
6
+ lexicon: gitlab
7
+ resource_type: GitLab::CI::Pipeline
8
+ ---
9
+ `GitLab::CI::Pipeline`, a resource type of the gitlab lexicon.
10
+
11
+ ## Properties
12
+
13
+ - `comment` (`string`, optional): YAML comment lines written at the top of the file. Not a GitLab key; each line of the string becomes a `#` line
14
+ - `include` (`Include | string | (Include | string)[]`, optional)
15
+ - `stages` (`string[]`, optional)
16
+ - `variables` (`Record<string, unknown>`, optional)
17
+
18
+ ## Governed by
19
+
20
+ - [WGL034](/rules/WGL034.md): OIDC id_token mintable from a merge-request pipeline
21
+ - [WGL038](/rules/WGL038.md): Secret-like variable reachable from a merge-request pipeline
22
+ - [WGL046](/rules/WGL046.md): Cache populated in a merge-request pipeline (poisoning risk)
23
+ - [WGL048](/rules/WGL048.md): Pipeline defines workflow: but no workflow:name
@@ -1 +1 @@
1
- {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AA0E9D;;GAEG;AACH,eAAO,MAAM,gBAAgB,EAAE,UAmF9B,CAAC"}
1
+ {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AA0F9D;;GAEG;AACH,eAAO,MAAM,gBAAgB,EAAE,UA+G9B,CAAC"}
@@ -50,7 +50,7 @@ export declare class Default {
50
50
  hooks?: Record<string, unknown>;
51
51
  id_tokens?: Record<string, { aud: string | string[] }>;
52
52
  identity?: "google_cloud";
53
- image?: Image;
53
+ image?: Image | string;
54
54
  interruptible?: boolean;
55
55
  retry?: Retry | number;
56
56
  services?: Service[];
@@ -123,6 +123,8 @@ export declare class Job {
123
123
  artifacts?: Artifacts;
124
124
  before_script?: string | string[];
125
125
  cache?: Cache | Cache[];
126
+ /** YAML comment lines written above the job's key. Not a GitLab key; each line of the string becomes a `#` line */
127
+ comment?: string;
126
128
  /** Must be a regular expression, optionally but recommended to be quoted, and must be surrounded with '/'. Example: '/Code coverage: \d+\.\d+/' */
127
129
  coverage?: string;
128
130
  /** Specify a list of job names from earlier stages from which artifacts should be loaded. By default, all previous artifacts are passed. Use an empty array to skip downloading artifacts. */
@@ -136,12 +138,12 @@ export declare class Job {
136
138
  hooks?: Record<string, unknown>;
137
139
  id_tokens?: Record<string, { aud: string | string[] }>;
138
140
  identity?: "google_cloud";
139
- image?: Image;
141
+ image?: Image | string;
140
142
  inherit?: Inherit;
141
143
  interruptible?: boolean;
142
144
  manual_confirmation?: string;
143
145
  /** The list of jobs in previous stages whose sole completion is needed to start the current job. */
144
- needs?: Need[];
146
+ needs?: (Need | string)[];
145
147
  /** Job will run *only* when these filtering options match. */
146
148
  only?: any;
147
149
  pages?: Record<string, any> | boolean;
@@ -188,6 +190,16 @@ export declare class Parallel {
188
190
  });
189
191
  }
190
192
 
193
+ export declare class Pipeline {
194
+ constructor(props: {
195
+ /** YAML comment lines written at the top of the file. Not a GitLab key; each line of the string becomes a `#` line */
196
+ comment?: string;
197
+ include?: Include | string | (Include | string)[];
198
+ stages?: string[];
199
+ variables?: Record<string, unknown>;
200
+ });
201
+ }
202
+
191
203
  export declare class Release {
192
204
  constructor(props: {
193
205
  /** Specifies the longer description of the Release. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gitlab",
3
- "version": "0.118.1",
3
+ "version": "0.120.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.118.1",
71
- "@intentius/chant-lexicon-github": "^0.118.1",
70
+ "@intentius/chant": "^0.120.0",
71
+ "@intentius/chant-lexicon-github": "^0.120.0",
72
72
  "typescript": "^5.9.3"
73
73
  },
74
74
  "peerDependenciesMeta": {
@@ -30,12 +30,12 @@ npm install --save-dev @intentius/chant-lexicon-gitlab
30
30
 
31
31
  {{file:docs-snippets/src/quickstart.ts}}
32
32
 
33
- The lexicon provides **3 resources** (Job, Workflow, Default), **16 property types** (Image, Cache, Artifacts, Rule, Environment, Trigger, Need, Service, and more), the \`CI\` pseudo-parameter object for predefined variables, and the \`reference()\` intrinsic for YAML \`!reference\` tags. It also ships **4 lint rules** + **39 post-synth checks** (including a CI/CD supply-chain security pass, WGL029–048) and a [\`chant migrate\`](./migration) source for translating GitHub Actions workflows.
33
+ The lexicon provides **4 resources** (Job, Workflow, Default, Pipeline), **16 property types** (Image, Cache, Artifacts, Rule, Environment, Trigger, Need, Service, and more), the \`CI\` pseudo-parameter object for predefined variables, and the \`reference()\` intrinsic for YAML \`!reference\` tags. It also ships **4 lint rules** + **39 post-synth checks** (including a CI/CD supply-chain security pass, WGL029–048) and a [\`chant migrate\`](./migration) source for translating GitHub Actions workflows.
34
34
  `;
35
35
 
36
36
  const outputFormat = `The GitLab lexicon serializes resources into **\`.gitlab-ci.yml\` YAML**. Keys are
37
- converted to \`snake_case\` and jobs use kebab-case names. Stages are automatically
38
- collected from all job definitions.
37
+ converted to \`snake_case\` and jobs use kebab-case names. Stages are collected
38
+ from the job definitions, after any a \`Pipeline\` lists.
39
39
 
40
40
  ## Building
41
41
 
@@ -48,7 +48,8 @@ chant build
48
48
 
49
49
  The generated file includes:
50
50
 
51
- - \`stages:\` list — automatically collected from all job \`stage\` properties
51
+ - a header comment, \`include:\` and \`variables:\`, if a \`Pipeline\` resource is exported
52
+ - \`stages:\` list: a \`Pipeline\`'s \`stages\` in its order, then every other stage a job's \`stage\` names, in first use
52
53
  - \`default:\` section — if a \`Default\` resource is exported
53
54
  - \`workflow:\` section — if a \`Workflow\` resource is exported
54
55
  - Job definitions with \`snake_case\` keys and \`kebab-case\` job names
@@ -61,6 +62,45 @@ The generated file includes:
61
62
  | \`expire_in: "1 week"\` | \`expire_in: 1 week\` | Property names use spec-native snake_case |
62
63
  | \`new Image({ name: "node:20" })\` | \`image: node:20\` | Single-property objects are collapsed |
63
64
 
65
+ ## Top-level keys and comments
66
+
67
+ A \`Pipeline\` resource holds the top-level keys that are not a job, \`default\` or \`workflow\`: \`include\`, \`stages\` and \`variables\`. Its \`stages\` can name a stage no declared job uses, such as one an included file's jobs run in, and set the order; a stage a job names that the list leaves out is still added after it, so the file stays valid. \`variables\` holds pipeline-wide variables, which \`Default\` cannot, since GitLab's \`default:\` has none.
68
+
69
+ \`Pipeline\` and \`Job\` also take a \`comment\`, written as \`#\` lines: the pipeline's at the top of the file, a job's above its key.
70
+
71
+ \`\`\`typescript
72
+ export const pipeline = new Pipeline({
73
+ comment: "Protect the writer's variables and scope them to their environment.",
74
+ include: [{ local: ".gitlab/watch.yml" }],
75
+ stages: ["review", "apply", "ops"],
76
+ variables: { GIT_DEPTH: "0" },
77
+ });
78
+
79
+ export const lint = new Job({ comment: "Blocks the merge.", stage: "review", image: "node:22", script: ["npm run lint"] });
80
+ \`\`\`
81
+
82
+ \`\`\`yaml
83
+ # Protect the writer's variables and scope them to their environment.
84
+
85
+ include:
86
+ - local: .gitlab/watch.yml
87
+
88
+ stages:
89
+ - review
90
+ - apply
91
+ - ops
92
+
93
+ variables:
94
+ GIT_DEPTH: '0'
95
+
96
+ # Blocks the merge.
97
+ lint:
98
+ stage: review
99
+ image: node:22
100
+ script:
101
+ - npm run lint
102
+ \`\`\`
103
+
64
104
  ## Validating locally
65
105
 
66
106
  The output is standard GitLab CI YAML. Validate with the GitLab CI Lint API or locally:
@@ -17,6 +17,7 @@ const gitlabNamingConfig: NamingConfig = {
17
17
  "GitLab::CI::Job": "Job",
18
18
  "GitLab::CI::Default": "Default",
19
19
  "GitLab::CI::Workflow": "Workflow",
20
+ "GitLab::CI::Pipeline": "Pipeline",
20
21
  "GitLab::CI::Artifacts": "Artifacts",
21
22
  "GitLab::CI::Cache": "Cache",
22
23
  "GitLab::CI::Image": "Image",
@@ -5,19 +5,20 @@ import { loadSchemaFixture } from "../testdata/load-fixtures";
5
5
  const fixture = loadSchemaFixture();
6
6
 
7
7
  describe("parseCISchema", () => {
8
- test("returns 19 entities", () => {
8
+ test("returns 20 entities", () => {
9
9
  const results = parseCISchema(fixture);
10
- expect(results).toHaveLength(19);
10
+ expect(results).toHaveLength(20);
11
11
  });
12
12
 
13
- test("returns 3 resource entities", () => {
13
+ test("returns 4 resource entities", () => {
14
14
  const results = parseCISchema(fixture);
15
15
  const resources = results.filter((r) => !r.isProperty);
16
- expect(resources).toHaveLength(3);
16
+ expect(resources).toHaveLength(4);
17
17
  const names = resources.map((r) => r.resource.typeName);
18
18
  expect(names).toContain("GitLab::CI::Job");
19
19
  expect(names).toContain("GitLab::CI::Default");
20
20
  expect(names).toContain("GitLab::CI::Workflow");
21
+ expect(names).toContain("GitLab::CI::Pipeline");
21
22
  });
22
23
 
23
24
  test("returns 16 property entities", () => {
@@ -146,8 +147,17 @@ describe("spec conformance — type mappings", () => {
146
147
  expect(findProp("Job", "release")?.tsType).toBe("Release");
147
148
  });
148
149
 
149
- test("Job.needs → Need[]", () => {
150
- expect(findProp("Job", "needs")?.tsType).toBe("Need[]");
150
+ test("Job.needs → (Need | string)[] and Job.image → Image | string (#3669)", () => {
151
+ expect(findProp("Job", "needs")?.tsType).toBe("(Need | string)[]");
152
+ expect(findProp("Job", "image")?.tsType).toBe("Image | string");
153
+ });
154
+
155
+ test("Pipeline holds the top-level include, stages and variables, and a comment (#3669)", () => {
156
+ expect(findProp("Pipeline", "include")?.tsType).toBe("Include | string | (Include | string)[]");
157
+ expect(findProp("Pipeline", "stages")?.tsType).toBe("string[]");
158
+ expect(findProp("Pipeline", "variables")).toBeDefined();
159
+ expect(findProp("Pipeline", "comment")?.tsType).toBe("string");
160
+ expect(findProp("Job", "comment")?.tsType).toBe("string");
151
161
  });
152
162
 
153
163
  test("Job.inherit → Inherit", () => {
@@ -104,6 +104,8 @@ const RESOURCE_ENTITIES: Array<{
104
104
  /** Schema path — "root" for root object, or "#/definitions/<name>" */
105
105
  source: string;
106
106
  description?: string;
107
+ /** Only these properties of the source (the root object holds every top-level key). */
108
+ pick?: string[];
107
109
  }> = [
108
110
  {
109
111
  typeName: "GitLab::CI::Job",
@@ -120,6 +122,16 @@ const RESOURCE_ENTITIES: Array<{
120
122
  source: "root:workflow",
121
123
  description: "Pipeline-level workflow configuration",
122
124
  },
125
+ {
126
+ // The top-level keys that are neither a job, `default` nor `workflow`
127
+ // (#3669). The serializer writes them first, so an explicit `stages`
128
+ // list can name a stage no declared job uses (one an included file's
129
+ // jobs run in) and set the order.
130
+ typeName: "GitLab::CI::Pipeline",
131
+ source: "root",
132
+ description: "Top-level pipeline keys: include, stages and variables",
133
+ pick: ["include", "stages", "variables"],
134
+ },
123
135
  ];
124
136
 
125
137
  /**
@@ -181,14 +193,19 @@ export function parseCISchema(data: string | Buffer): GitLabParseResult[] {
181
193
  */
182
194
  function extractResourceEntity(
183
195
  schema: CISchema,
184
- entity: { typeName: string; source: string; description?: string },
196
+ entity: { typeName: string; source: string; description?: string; pick?: string[] },
185
197
  ): GitLabParseResult | null {
186
198
  const def = resolveSource(schema, entity.source);
187
199
  if (!def) return null;
188
200
 
189
201
  // Find the object variant if it's a oneOf/anyOf
190
- const objectDef = findObjectVariant(def, schema);
191
- if (!objectDef?.properties) return null;
202
+ const found = findObjectVariant(def, schema);
203
+ if (!found?.properties) return null;
204
+ const pick = entity.pick;
205
+ const objectDef: CISchemaDefinition = pick
206
+ ? { ...found, properties: Object.fromEntries(Object.entries(found.properties).filter(([k]) => pick.includes(k))), required: undefined }
207
+ : found;
208
+ if (!objectDef.properties) return null;
192
209
 
193
210
  const requiredSet = new Set<string>(objectDef.required ?? []);
194
211
  const properties = parseProperties(objectDef.properties, requiredSet, schema);
@@ -203,6 +220,8 @@ function extractResourceEntity(
203
220
  }
204
221
  }
205
222
  }
223
+ const comment = COMMENT_PROPERTIES[entity.typeName];
224
+ if (comment) properties.push({ name: "comment", tsType: "string", required: false, description: comment, constraints: {} });
206
225
 
207
226
  // Extract nested property types from definition properties
208
227
  const { propertyTypes, enums } = extractNestedTypes(objectDef, shortName, schema);
@@ -270,12 +289,23 @@ function extractPropertyEntity(
270
289
  };
271
290
  }
272
291
 
292
+ /**
293
+ * Resources that carry a `comment`: not a GitLab key, but `#` lines the
294
+ * serializer writes above the entry (#3669, as #3667 for github).
295
+ */
296
+ const COMMENT_PROPERTIES: Record<string, string> = {
297
+ "GitLab::CI::Pipeline": "YAML comment lines written at the top of the file. Not a GitLab key; each line of the string becomes a `#` line",
298
+ "GitLab::CI::Job": "YAML comment lines written above the job's key. Not a GitLab key; each line of the string becomes a `#` line",
299
+ };
300
+
273
301
  // ── Helpers ────────────────────────────────────────────────────────
274
302
 
275
303
  /**
276
304
  * Resolve a source path to a schema definition.
277
305
  */
278
306
  function resolveSource(schema: CISchema, source: string): CISchemaDefinition | null {
307
+ if (source === "root") return { properties: schema.properties } as CISchemaDefinition;
308
+
279
309
  if (source.startsWith("#/definitions/")) {
280
310
  // Array item extraction: "#/definitions/foo:item" → foo.items object variant
281
311
  if (source.includes(":item")) {
@@ -541,9 +571,18 @@ const RESOURCE_PROPERTY_OVERRIDES: Record<string, Record<string, string>> = {
541
571
  environment: "Environment | string",
542
572
  trigger: "Trigger | string",
543
573
  release: "Release",
544
- needs: "Need[]",
574
+ // GitLab takes the plain forms too: `image: node:22`, `needs: [build]`.
575
+ image: "Image | string",
576
+ needs: "(Need | string)[]",
545
577
  inherit: "Inherit",
546
578
  },
579
+ "GitLab::CI::Default": {
580
+ image: "Image | string",
581
+ },
582
+ "GitLab::CI::Pipeline": {
583
+ include: "Include | string | (Include | string)[]",
584
+ stages: "string[]",
585
+ },
547
586
  "GitLab::CI::Workflow": {
548
587
  rules: "WorkflowRule[]",
549
588
  },
@@ -10,7 +10,7 @@ describe("generated lexicon-gitlab.json", () => {
10
10
  test.skipIf(!hasGenerated)("is valid JSON with expected entries", () => {
11
11
  const content = readFileSync(join(generatedDir, "lexicon-gitlab.json"), "utf-8");
12
12
  const registry = JSON.parse(content);
13
- expect(Object.keys(registry)).toHaveLength(19);
13
+ expect(Object.keys(registry)).toHaveLength(20);
14
14
  });
15
15
 
16
16
  test.skipIf(!hasGenerated)("contains all resource entities", () => {
@@ -454,7 +454,7 @@ describe("generateGitlabOpPipeline: a deployment environment (#2257)", () => {
454
454
  */
455
455
  const YAML_BEFORE_2257 =
456
456
  [
457
- '# chant Ops (#927, #2084, #2293) — one job per Op under stage "ops", each',
457
+ '# chant Ops — one job per Op under stage "ops", each',
458
458
  "# selected by its own rules:. A merge_request_event or push job needs no",
459
459
  "# setup; its rule fires on the event itself.",
460
460
  "#",
@@ -505,7 +505,7 @@ export function generateGitlabOpPipeline(
505
505
  }
506
506
 
507
507
  const headerLines = [
508
- `# chant Ops (#927, #2084, #2293) — one job per Op under stage "${stage}", each`,
508
+ `# chant Ops — one job per Op under stage "${stage}", each`,
509
509
  "# selected by its own rules:. A merge_request_event or push job needs no",
510
510
  "# setup; its rule fires on the event itself.",
511
511
  ];
@@ -95,7 +95,7 @@ export function generateGitlabOpWavesPipeline(spec: OpWavesSpec, options: OpWave
95
95
  );
96
96
  }
97
97
  const header = [
98
- `# chant Op waves "${spec.name}" (#3679): one stage per wave, each needing the one before.`,
98
+ `# chant Op waves "${spec.name}": one stage per wave, each needing the one before.`,
99
99
  "# A wave that waits at its gate exits 3 and stops the waves after it; approve the",
100
100
  "# digest it prints, then retry the job.",
101
101
  ...(spec.resume
@@ -12,8 +12,8 @@ describe("coverage analysis", () => {
12
12
  const { computeCoverage } = await import("./coverage");
13
13
  const lexiconJSON = readFileSync(lexiconPath, "utf-8");
14
14
  const report = computeCoverage(lexiconJSON);
15
- expect(report.resourceCount).toBe(3); // Job, Default, Workflow
16
- expect(report.resources).toHaveLength(3);
15
+ expect(report.resourceCount).toBe(4); // Job, Default, Workflow, Pipeline
16
+ expect(report.resources).toHaveLength(4);
17
17
  });
18
18
 
19
19
  test.skipIf(!hasGenerated)("reports resource names correctly", async () => {
@@ -42,6 +42,6 @@ describe("coverage analysis", () => {
42
42
  const report = computeCoverage(lexiconJSON);
43
43
  const summary = formatSummary(report);
44
44
  expect(summary).toContain("Coverage Report");
45
- expect(summary).toContain("3 resources");
45
+ expect(summary).toContain("4 resources");
46
46
  });
47
47
  });
@@ -50,7 +50,7 @@ export declare class Default {
50
50
  hooks?: Record<string, unknown>;
51
51
  id_tokens?: Record<string, { aud: string | string[] }>;
52
52
  identity?: "google_cloud";
53
- image?: Image;
53
+ image?: Image | string;
54
54
  interruptible?: boolean;
55
55
  retry?: Retry | number;
56
56
  services?: Service[];
@@ -123,6 +123,8 @@ export declare class Job {
123
123
  artifacts?: Artifacts;
124
124
  before_script?: string | string[];
125
125
  cache?: Cache | Cache[];
126
+ /** YAML comment lines written above the job's key. Not a GitLab key; each line of the string becomes a `#` line */
127
+ comment?: string;
126
128
  /** Must be a regular expression, optionally but recommended to be quoted, and must be surrounded with '/'. Example: '/Code coverage: \d+\.\d+/' */
127
129
  coverage?: string;
128
130
  /** Specify a list of job names from earlier stages from which artifacts should be loaded. By default, all previous artifacts are passed. Use an empty array to skip downloading artifacts. */
@@ -136,12 +138,12 @@ export declare class Job {
136
138
  hooks?: Record<string, unknown>;
137
139
  id_tokens?: Record<string, { aud: string | string[] }>;
138
140
  identity?: "google_cloud";
139
- image?: Image;
141
+ image?: Image | string;
140
142
  inherit?: Inherit;
141
143
  interruptible?: boolean;
142
144
  manual_confirmation?: string;
143
145
  /** The list of jobs in previous stages whose sole completion is needed to start the current job. */
144
- needs?: Need[];
146
+ needs?: (Need | string)[];
145
147
  /** Job will run *only* when these filtering options match. */
146
148
  only?: any;
147
149
  pages?: Record<string, any> | boolean;
@@ -188,6 +190,16 @@ export declare class Parallel {
188
190
  });
189
191
  }
190
192
 
193
+ export declare class Pipeline {
194
+ constructor(props: {
195
+ /** YAML comment lines written at the top of the file. Not a GitLab key; each line of the string becomes a `#` line */
196
+ comment?: string;
197
+ include?: Include | string | (Include | string)[];
198
+ stages?: string[];
199
+ variables?: Record<string, unknown>;
200
+ });
201
+ }
202
+
191
203
  export declare class Release {
192
204
  constructor(props: {
193
205
  /** Specifies the longer description of the Release. */
@@ -3,6 +3,7 @@ import { createResource, createProperty } from "./runtime";
3
3
 
4
4
  export const Default = /* @__PURE__ */ createResource("GitLab::CI::Default", "gitlab", {});
5
5
  export const Job = /* @__PURE__ */ createResource("GitLab::CI::Job", "gitlab", {});
6
+ export const Pipeline = /* @__PURE__ */ createResource("GitLab::CI::Pipeline", "gitlab", {});
6
7
  export const Workflow = /* @__PURE__ */ createResource("GitLab::CI::Workflow", "gitlab", {});
7
8
 
8
9
  export const AllowFailure = /* @__PURE__ */ createProperty("GitLab::CI::AllowFailure", "gitlab");
@@ -181,6 +181,20 @@
181
181
  "kind": "property",
182
182
  "lexicon": "gitlab"
183
183
  },
184
+ "Pipeline": {
185
+ "resourceType": "GitLab::CI::Pipeline",
186
+ "kind": "resource",
187
+ "lexicon": "gitlab",
188
+ "constraints": {
189
+ "stages": {
190
+ "default": [
191
+ "build",
192
+ "test",
193
+ "deploy"
194
+ ]
195
+ }
196
+ }
197
+ },
184
198
  "Release": {
185
199
  "resourceType": "GitLab::CI::Release",
186
200
  "kind": "property",
@@ -292,7 +292,7 @@ jobs:
292
292
  const result = await catalog.handler();
293
293
  const parsed = JSON.parse(result);
294
294
  expect(Array.isArray(parsed)).toBe(true);
295
- expect(parsed.length).toBe(19);
295
+ expect(parsed.length).toBe(20);
296
296
  const job = parsed.find((e: { className: string }) => e.className === "Job");
297
297
  expect(job).toBeDefined();
298
298
  expect(job.kind).toBe("resource");
@@ -447,3 +447,73 @@ describe("nested objects and arrays", () => {
447
447
  expect(output).toContain("parallel: 5");
448
448
  });
449
449
  });
450
+
451
+ describe("Pipeline: top-level include, stages and variables, and comments (#3669)", () => {
452
+ const Pipeline = createResource("GitLab::CI::Pipeline", "gitlab", {});
453
+ const Job = createResource("GitLab::CI::Job", "gitlab", {});
454
+ const Include = createProperty("GitLab::CI::Include", "gitlab");
455
+ const Image = createProperty("GitLab::CI::Image", "gitlab");
456
+
457
+ test("renders include, an explicit stage list naming a stage no job uses, variables and comments", () => {
458
+ const entities = new Map<string, Declarable>([
459
+ ["pipeline", new Pipeline({
460
+ comment: "Protect the writer variables.\n\nScope each to its environment.",
461
+ include: [new Include({ local: ".gitlab/apply.yml" }), { local: ".gitlab/watch.yml" }],
462
+ stages: ["review", "apply", "ops"],
463
+ variables: { GIT_DEPTH: "0" },
464
+ }) as unknown as Declarable],
465
+ ["lint", new Job({ comment: "Blocks the merge.", stage: "review", image: "node:22", script: ["npm run lint"] }) as unknown as Declarable],
466
+ ["apply", new Job({ stage: "apply", image: new Image({ name: "node:22" }), needs: ["lint"], script: ["npm run apply"] }) as unknown as Declarable],
467
+ ]);
468
+
469
+ expect(gitlabSerializer.serialize(entities)).toBe(`# Protect the writer variables.
470
+ #
471
+ # Scope each to its environment.
472
+
473
+ include:
474
+ - local: .gitlab/apply.yml
475
+ - local: .gitlab/watch.yml
476
+
477
+ stages:
478
+ - review
479
+ - apply
480
+ - ops
481
+
482
+ variables:
483
+ GIT_DEPTH: '0'
484
+
485
+ # Blocks the merge.
486
+ lint:
487
+ stage: review
488
+ image: node:22
489
+ script:
490
+ - npm run lint
491
+
492
+ apply:
493
+ stage: apply
494
+ image:
495
+ name: node:22
496
+ needs:
497
+ - lint
498
+ script:
499
+ - npm run apply
500
+ `);
501
+ });
502
+
503
+ test("a stage a job names but the list leaves out is appended, so the file stays valid", () => {
504
+ const entities = new Map<string, Declarable>([
505
+ ["pipeline", new Pipeline({ stages: ["apply", "review"] }) as unknown as Declarable],
506
+ ["lint", new Job({ stage: "lint", script: ["x"] }) as unknown as Declarable],
507
+ ["plan", new Job({ stage: "review", script: ["x"] }) as unknown as Declarable],
508
+ ]);
509
+ expect(gitlabSerializer.serialize(entities)).toContain("stages:\n - apply\n - review\n - lint\n");
510
+ });
511
+
512
+ test("a single include string renders as a one-item list, and no Pipeline leaves the output as before", () => {
513
+ const one = new Map<string, Declarable>([["p", new Pipeline({ include: "templates/ci.yml" }) as unknown as Declarable]]);
514
+ expect(gitlabSerializer.serialize(one)).toBe("include:\n - templates/ci.yml\n");
515
+
516
+ const jobsOnly = new Map<string, Declarable>([["build", new Job({ stage: "build", script: ["make"] }) as unknown as Declarable]]);
517
+ expect(gitlabSerializer.serialize(jobsOnly)).toBe("stages:\n - build\n\nbuild:\n stage: build\n script:\n - make\n");
518
+ });
519
+ });
package/src/serializer.ts CHANGED
@@ -15,6 +15,22 @@ import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-w
15
15
  import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
16
16
  import { emitYAMLEntry } from "@intentius/chant/yaml";
17
17
 
18
+ /**
19
+ * `#` lines for a comment, one per line of the string (#3669). Only the file
20
+ * header and job comments exist, and both sit at column 0.
21
+ */
22
+ function commentLines(comment: string): string {
23
+ return comment.split("\n").map((l) => `#${l.trimEnd() ? ` ${l.trimEnd()}` : ""}`).join("\n");
24
+ }
25
+
26
+ /** An entity's props without `comment`, and the comment if it is a non-empty string. */
27
+ function splitComment(entity: Declarable): { props: Record<string, unknown> | undefined; comment?: string } {
28
+ const props = isResourceDeclarable(entity) ? (entity.props as Record<string, unknown> | undefined) : undefined;
29
+ if (!props || !("comment" in props)) return { props };
30
+ const { comment, ...rest } = props;
31
+ return { props: rest, comment: typeof comment === "string" && comment !== "" ? comment : undefined };
32
+ }
33
+
18
34
  /**
19
35
  * GitLab CI visitor for the generic serializer walker.
20
36
  */
@@ -101,6 +117,7 @@ export const gitlabSerializer: Serializer = {
101
117
 
102
118
  // Separate entities by type
103
119
  const jobs: Array<[string, Declarable]> = [];
120
+ const pipelines: Array<[string, Declarable]> = [];
104
121
  const defaults: Array<[string, Declarable]> = [];
105
122
  const workflows: Array<[string, Declarable]> = [];
106
123
  const others: Array<[string, Declarable]> = [];
@@ -115,13 +132,40 @@ export const gitlabSerializer: Serializer = {
115
132
  defaults.push([name, entity]);
116
133
  } else if (entityType === "GitLab::CI::Workflow") {
117
134
  workflows.push([name, entity]);
135
+ } else if (entityType === "GitLab::CI::Pipeline") {
136
+ pipelines.push([name, entity]);
118
137
  } else {
119
138
  others.push([name, entity]);
120
139
  }
121
140
  }
122
141
 
123
- // Emit stages (collect from jobs)
124
- const stages = new Set<string>();
142
+ // The Pipeline entity's top-level keys (#3669). More than one is merged
143
+ // in declaration order: comments joined, includes concatenated, stages
144
+ // in first-listed order, later variables overriding earlier ones.
145
+ const headers: string[] = [];
146
+ const includes: unknown[] = [];
147
+ const declaredStages: string[] = [];
148
+ let variables: Record<string, unknown> | undefined;
149
+ for (const [, entity] of pipelines) {
150
+ const { props, comment } = splitComment(entity);
151
+ if (comment) headers.push(comment);
152
+ const converted = toYAMLValue(props, entityNames) as Record<string, unknown> | undefined;
153
+ if (!converted) continue;
154
+ if (converted.include !== undefined) {
155
+ includes.push(...(Array.isArray(converted.include) ? converted.include : [converted.include]));
156
+ }
157
+ if (Array.isArray(converted.stages)) declaredStages.push(...converted.stages.map(String));
158
+ if (converted.variables && typeof converted.variables === "object") {
159
+ variables = { ...variables, ...(converted.variables as Record<string, unknown>) };
160
+ }
161
+ }
162
+ if (headers.length > 0) sections.push(commentLines(headers.join("\n\n")));
163
+ if (includes.length > 0) sections.push(emitYAMLEntry("include", includes));
164
+
165
+ // Stages: a Pipeline's explicit list first, in its order, then any stage
166
+ // a job names that the list leaves out, in first use. Without a list,
167
+ // that is the jobs' stages alone.
168
+ const stages = new Set<string>(declaredStages);
125
169
  for (const [, entity] of jobs) {
126
170
  const props = isResourceDeclarable(entity) ? (entity.props as Record<string, unknown> | undefined) : undefined;
127
171
  if (props?.stage && typeof props.stage === "string") {
@@ -131,6 +175,7 @@ export const gitlabSerializer: Serializer = {
131
175
  if (stages.size > 0) {
132
176
  sections.push(emitYAMLEntry("stages", [...stages]));
133
177
  }
178
+ if (variables && Object.keys(variables).length > 0) sections.push(emitYAMLEntry("variables", variables));
134
179
 
135
180
  // Emit defaults
136
181
  for (const [, entity] of defaults) {
@@ -156,14 +201,13 @@ export const gitlabSerializer: Serializer = {
156
201
 
157
202
  // Emit jobs
158
203
  for (const [name, entity] of jobs) {
159
- const converted = toYAMLValue(
160
- (isResourceDeclarable(entity) ? entity.props : undefined),
161
- entityNames,
162
- ) as Record<string, unknown> | undefined;
204
+ const { props, comment } = splitComment(entity);
205
+ const converted = toYAMLValue(props, entityNames) as Record<string, unknown> | undefined;
163
206
  if (converted) {
164
207
  // Convert job name from camelCase to kebab-case for YAML
165
208
  const yamlName = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
166
- sections.push(emitYAMLEntry(yamlName, converted));
209
+ const entry = emitYAMLEntry(yamlName, converted);
210
+ sections.push(comment ? `${commentLines(comment)}\n${entry}` : entry);
167
211
  }
168
212
  }
169
213