@intentius/chant-lexicon-gitlab 0.0.15 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/integrity.json +18 -4
- package/dist/manifest.json +1 -1
- package/dist/rules/wgl016.ts +82 -0
- package/dist/rules/wgl017.ts +54 -0
- package/dist/rules/wgl018.ts +39 -0
- package/dist/rules/wgl019.ts +44 -0
- package/dist/rules/wgl020.ts +56 -0
- package/dist/rules/wgl021.ts +62 -0
- package/dist/rules/wgl022.ts +44 -0
- package/dist/rules/wgl023.ts +51 -0
- package/dist/rules/wgl024.ts +46 -0
- package/dist/rules/wgl025.ts +49 -0
- package/dist/rules/wgl026.ts +67 -0
- package/dist/rules/wgl027.ts +54 -0
- package/dist/rules/wgl028.ts +67 -0
- package/dist/rules/yaml-helpers.ts +82 -0
- package/dist/skills/chant-gitlab.md +1 -1
- package/dist/skills/gitlab-ci-patterns.md +309 -0
- package/package.json +3 -3
- package/src/codegen/fetch.test.ts +30 -0
- package/src/codegen/generate.test.ts +65 -0
- package/src/codegen/idempotency.test.ts +28 -0
- package/src/codegen/naming.test.ts +93 -0
- package/src/codegen/snapshot.test.ts +28 -19
- package/src/composites/composites.test.ts +160 -0
- package/src/coverage.test.ts +15 -7
- package/src/import/roundtrip.test.ts +132 -0
- package/src/lint/post-synth/wgl016.test.ts +72 -0
- package/src/lint/post-synth/wgl016.ts +82 -0
- package/src/lint/post-synth/wgl017.test.ts +53 -0
- package/src/lint/post-synth/wgl017.ts +54 -0
- package/src/lint/post-synth/wgl018.test.ts +69 -0
- package/src/lint/post-synth/wgl018.ts +39 -0
- package/src/lint/post-synth/wgl019.test.ts +76 -0
- package/src/lint/post-synth/wgl019.ts +44 -0
- package/src/lint/post-synth/wgl020.test.ts +54 -0
- package/src/lint/post-synth/wgl020.ts +56 -0
- package/src/lint/post-synth/wgl021.test.ts +62 -0
- package/src/lint/post-synth/wgl021.ts +62 -0
- package/src/lint/post-synth/wgl022.test.ts +86 -0
- package/src/lint/post-synth/wgl022.ts +44 -0
- package/src/lint/post-synth/wgl023.test.ts +88 -0
- package/src/lint/post-synth/wgl023.ts +51 -0
- package/src/lint/post-synth/wgl024.test.ts +77 -0
- package/src/lint/post-synth/wgl024.ts +46 -0
- package/src/lint/post-synth/wgl025.test.ts +85 -0
- package/src/lint/post-synth/wgl025.ts +49 -0
- package/src/lint/post-synth/wgl026.test.ts +87 -0
- package/src/lint/post-synth/wgl026.ts +67 -0
- package/src/lint/post-synth/wgl027.test.ts +84 -0
- package/src/lint/post-synth/wgl027.ts +54 -0
- package/src/lint/post-synth/wgl028.test.ts +95 -0
- package/src/lint/post-synth/wgl028.ts +67 -0
- package/src/lint/post-synth/yaml-helpers.ts +82 -0
- package/src/lsp/completions.test.ts +16 -6
- package/src/lsp/hover.test.ts +18 -7
- package/src/plugin.test.ts +15 -2
- package/src/plugin.ts +66 -3
- package/src/skills/gitlab-ci-patterns.md +309 -0
- package/src/testdata/pipelines/deploy-envs.gitlab-ci.yml +60 -0
- package/src/testdata/pipelines/docker-build.gitlab-ci.yml +41 -0
- package/src/testdata/pipelines/includes-templates.gitlab-ci.yml +52 -0
- package/src/testdata/pipelines/monorepo.gitlab-ci.yml +51 -0
- package/src/testdata/pipelines/multi-stage.gitlab-ci.yml +56 -0
- package/src/testdata/pipelines/simple.gitlab-ci.yml +9 -0
- package/src/validate.test.ts +12 -6
- package/src/variables.test.ts +58 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL027: Empty Script
|
|
3
|
+
*
|
|
4
|
+
* Detects jobs with `script: []` or scripts containing only empty strings.
|
|
5
|
+
* GitLab rejects jobs with empty scripts at pipeline validation time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
9
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
10
|
+
|
|
11
|
+
export const wgl027: PostSynthCheck = {
|
|
12
|
+
id: "WGL027",
|
|
13
|
+
description: "Empty script — jobs with empty or blank script entries",
|
|
14
|
+
|
|
15
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
16
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
17
|
+
|
|
18
|
+
for (const [entityName, entity] of ctx.entities) {
|
|
19
|
+
if (isPropertyDeclarable(entity)) continue;
|
|
20
|
+
const entityType = (entity as Record<string, unknown>).entityType as string;
|
|
21
|
+
if (entityType !== "GitLab::CI::Job") continue;
|
|
22
|
+
|
|
23
|
+
const props = (entity as Record<string, unknown>).props as Record<string, unknown> | undefined;
|
|
24
|
+
if (!props) continue;
|
|
25
|
+
|
|
26
|
+
const script = props.script;
|
|
27
|
+
if (script === undefined || script === null) continue;
|
|
28
|
+
|
|
29
|
+
let isEmpty = false;
|
|
30
|
+
|
|
31
|
+
if (Array.isArray(script)) {
|
|
32
|
+
if (script.length === 0) {
|
|
33
|
+
isEmpty = true;
|
|
34
|
+
} else if (script.every((s) => typeof s === "string" && s.trim() === "")) {
|
|
35
|
+
isEmpty = true;
|
|
36
|
+
}
|
|
37
|
+
} else if (typeof script === "string" && script.trim() === "") {
|
|
38
|
+
isEmpty = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (isEmpty) {
|
|
42
|
+
diagnostics.push({
|
|
43
|
+
checkId: "WGL027",
|
|
44
|
+
severity: "error",
|
|
45
|
+
message: `Job "${entityName}" has an empty script — GitLab will reject this pipeline`,
|
|
46
|
+
entity: entityName,
|
|
47
|
+
lexicon: "gitlab",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return diagnostics;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WGL028: Redundant Needs
|
|
3
|
+
*
|
|
4
|
+
* Detects `needs:` entries that list jobs already implied by stage ordering.
|
|
5
|
+
* While not incorrect, redundant needs add noise and make the pipeline
|
|
6
|
+
* harder to maintain. This is informational only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, extractStages, extractJobs } from "./yaml-helpers";
|
|
11
|
+
|
|
12
|
+
export function checkRedundantNeeds(yaml: string): PostSynthDiagnostic[] {
|
|
13
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
14
|
+
|
|
15
|
+
const stages = extractStages(yaml);
|
|
16
|
+
if (stages.length === 0) return diagnostics;
|
|
17
|
+
|
|
18
|
+
const stageIndex = new Map<string, number>();
|
|
19
|
+
for (let i = 0; i < stages.length; i++) {
|
|
20
|
+
stageIndex.set(stages[i], i);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const jobs = extractJobs(yaml);
|
|
24
|
+
|
|
25
|
+
for (const [jobName, job] of jobs) {
|
|
26
|
+
if (!job.needs || !job.stage) continue;
|
|
27
|
+
|
|
28
|
+
const jobStageIdx = stageIndex.get(job.stage);
|
|
29
|
+
if (jobStageIdx === undefined) continue;
|
|
30
|
+
|
|
31
|
+
for (const need of job.needs) {
|
|
32
|
+
const neededJob = jobs.get(need);
|
|
33
|
+
if (!neededJob?.stage) continue;
|
|
34
|
+
|
|
35
|
+
const needStageIdx = stageIndex.get(neededJob.stage);
|
|
36
|
+
if (needStageIdx === undefined) continue;
|
|
37
|
+
|
|
38
|
+
// If the needed job is in an earlier stage, it's already implied
|
|
39
|
+
// by GitLab's default stage-based ordering
|
|
40
|
+
if (needStageIdx < jobStageIdx) {
|
|
41
|
+
diagnostics.push({
|
|
42
|
+
checkId: "WGL028",
|
|
43
|
+
severity: "info",
|
|
44
|
+
message: `Job "${jobName}" lists "${need}" in needs: but it's already in an earlier stage (${neededJob.stage} → ${job.stage})`,
|
|
45
|
+
entity: jobName,
|
|
46
|
+
lexicon: "gitlab",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return diagnostics;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const wgl028: PostSynthCheck = {
|
|
56
|
+
id: "WGL028",
|
|
57
|
+
description: "Redundant needs — needs listing jobs already implied by stage ordering",
|
|
58
|
+
|
|
59
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
60
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
61
|
+
for (const [, output] of ctx.outputs) {
|
|
62
|
+
const yaml = getPrimaryOutput(output);
|
|
63
|
+
diagnostics.push(...checkRedundantNeeds(yaml));
|
|
64
|
+
}
|
|
65
|
+
return diagnostics;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
@@ -141,3 +141,85 @@ export function extractJobs(yaml: string): Map<string, ParsedJob> {
|
|
|
141
141
|
export function hasInclude(yaml: string): boolean {
|
|
142
142
|
return /^include:/m.test(yaml);
|
|
143
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Extract global variables from serialized YAML.
|
|
147
|
+
*/
|
|
148
|
+
export function extractGlobalVariables(yaml: string): Map<string, string> {
|
|
149
|
+
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
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return vars;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Extract the full section text for a given job name.
|
|
164
|
+
*/
|
|
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;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Extract rules from a job section.
|
|
178
|
+
*/
|
|
179
|
+
export function extractJobRules(section: string): ParsedRule[] {
|
|
180
|
+
const rules: ParsedRule[] = [];
|
|
181
|
+
const lines = section.split("\n");
|
|
182
|
+
|
|
183
|
+
let inRules = false;
|
|
184
|
+
let currentRule: ParsedRule = {};
|
|
185
|
+
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
if (line.match(/^\s+rules:$/)) {
|
|
188
|
+
inRules = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (inRules) {
|
|
193
|
+
const ruleStart = line.match(/^\s+- (if|when|changes):\s*(.*)$/);
|
|
194
|
+
if (ruleStart) {
|
|
195
|
+
if (Object.keys(currentRule).length > 0) {
|
|
196
|
+
rules.push(currentRule);
|
|
197
|
+
}
|
|
198
|
+
currentRule = {};
|
|
199
|
+
if (ruleStart[1] === "if") currentRule.if = ruleStart[2].trim();
|
|
200
|
+
if (ruleStart[1] === "when") currentRule.when = ruleStart[2].trim();
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const whenMatch = line.match(/^\s+when:\s+(.+)$/);
|
|
205
|
+
if (whenMatch) {
|
|
206
|
+
currentRule.when = whenMatch[1].trim();
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// End of rules block
|
|
211
|
+
if (!line.match(/^\s+\s/) || line.match(/^\s+[a-z_]+:/) && !line.match(/^\s+when:/)) {
|
|
212
|
+
if (Object.keys(currentRule).length > 0) {
|
|
213
|
+
rules.push(currentRule);
|
|
214
|
+
}
|
|
215
|
+
inRules = false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (inRules && Object.keys(currentRule).length > 0) {
|
|
221
|
+
rules.push(currentRule);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return rules;
|
|
225
|
+
}
|
|
@@ -147,7 +147,7 @@ Add `"dry_run": true, "include_merged_yaml": true` for full expansion with inclu
|
|
|
147
147
|
| Step | Catches | When to run |
|
|
148
148
|
|------|---------|-------------|
|
|
149
149
|
| `chant lint` | Deprecated only/except (WGL001), missing script (WGL002), missing stage (WGL003), artifacts without expiry (WGL004) | Every edit |
|
|
150
|
-
| `chant build` | Post-synth checks: undefined stages (WGL010), unreachable jobs (WGL011), deprecated properties (WGL012), invalid needs targets (WGL013),
|
|
150
|
+
| `chant build` | Post-synth checks: undefined stages (WGL010), unreachable jobs (WGL011), deprecated properties (WGL012), invalid needs/extends targets (WGL013-014), circular needs (WGL015), secrets in variables (WGL016), insecure registry (WGL017), missing timeout/retry (WGL018-019), duplicate jobs (WGL020), unused variables (WGL021), missing artifacts expiry (WGL022), overly broad rules (WGL023), manual without allow_failure (WGL024), missing cache key (WGL025), DinD without TLS (WGL026), empty script (WGL027), redundant needs (WGL028) | Before push |
|
|
151
151
|
| CI Lint API | GitLab-specific validation: include resolution, variable expansion, YAML schema errors | Before merge to default branch |
|
|
152
152
|
|
|
153
153
|
Always run all three before deploying. Lint catches things the API cannot (and vice versa).
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
---
|
|
2
|
+
skill: gitlab-ci-patterns
|
|
3
|
+
description: GitLab CI/CD pipeline stages, caching, artifacts, includes, and advanced patterns
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# GitLab CI/CD Pipeline Patterns
|
|
8
|
+
|
|
9
|
+
## Pipeline Stage Design
|
|
10
|
+
|
|
11
|
+
### Standard Stage Ordering
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { Job, Image, Cache, Artifacts } from "@intentius/chant-lexicon-gitlab";
|
|
15
|
+
|
|
16
|
+
// Stages execute in order. Jobs within a stage run in parallel.
|
|
17
|
+
// Default stages: .pre, build, test, deploy, .post
|
|
18
|
+
|
|
19
|
+
export const lint = new Job({
|
|
20
|
+
stage: "build",
|
|
21
|
+
image: new Image({ name: "node:22-alpine" }),
|
|
22
|
+
script: ["npm ci", "npm run lint"],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const test = new Job({
|
|
26
|
+
stage: "test",
|
|
27
|
+
image: new Image({ name: "node:22-alpine" }),
|
|
28
|
+
script: ["npm ci", "npm test"],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const deploy = new Job({
|
|
32
|
+
stage: "deploy",
|
|
33
|
+
script: ["./deploy.sh"],
|
|
34
|
+
rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }],
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Parallel Jobs with needs
|
|
39
|
+
|
|
40
|
+
Use `needs` to create a DAG and skip waiting for the full stage:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
export const unitTests = new Job({
|
|
44
|
+
stage: "test",
|
|
45
|
+
script: ["npm run test:unit"],
|
|
46
|
+
needs: ["build"],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const integrationTests = new Job({
|
|
50
|
+
stage: "test",
|
|
51
|
+
script: ["npm run test:integration"],
|
|
52
|
+
needs: ["build"],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const deploy = new Job({
|
|
56
|
+
stage: "deploy",
|
|
57
|
+
script: ["./deploy.sh"],
|
|
58
|
+
needs: ["unitTests", "integrationTests"],
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Caching Strategies
|
|
63
|
+
|
|
64
|
+
### Language-Specific Cache Keys
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { Cache } from "@intentius/chant-lexicon-gitlab";
|
|
68
|
+
|
|
69
|
+
// Node.js: cache node_modules by lockfile hash
|
|
70
|
+
export const nodeCache = new Cache({
|
|
71
|
+
key: { files: ["package-lock.json"] },
|
|
72
|
+
paths: ["node_modules/"],
|
|
73
|
+
policy: "pull-push",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Python: cache pip downloads
|
|
77
|
+
export const pipCache = new Cache({
|
|
78
|
+
key: { files: ["requirements.txt"] },
|
|
79
|
+
paths: [".pip-cache/"],
|
|
80
|
+
policy: "pull-push",
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Cache Policies
|
|
85
|
+
|
|
86
|
+
| Policy | Behavior | Use for |
|
|
87
|
+
|--------|----------|---------|
|
|
88
|
+
| `pull-push` | Download and upload cache | Build jobs that install dependencies |
|
|
89
|
+
| `pull` | Download only, never upload | Test/deploy jobs (read from build cache) |
|
|
90
|
+
| `push` | Upload only, never download | Initial cache population |
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
export const build = new Job({
|
|
94
|
+
stage: "build",
|
|
95
|
+
cache: new Cache({
|
|
96
|
+
key: "$CI_COMMIT_REF_SLUG",
|
|
97
|
+
paths: ["node_modules/", "dist/"],
|
|
98
|
+
policy: "pull-push", // build populates cache
|
|
99
|
+
}),
|
|
100
|
+
script: ["npm ci", "npm run build"],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export const test = new Job({
|
|
104
|
+
stage: "test",
|
|
105
|
+
cache: new Cache({
|
|
106
|
+
key: "$CI_COMMIT_REF_SLUG",
|
|
107
|
+
paths: ["node_modules/"],
|
|
108
|
+
policy: "pull", // test only reads cache
|
|
109
|
+
}),
|
|
110
|
+
script: ["npm test"],
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Artifacts
|
|
115
|
+
|
|
116
|
+
### Pass Build Output Between Jobs
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { Artifacts } from "@intentius/chant-lexicon-gitlab";
|
|
120
|
+
|
|
121
|
+
export const buildArtifacts = new Artifacts({
|
|
122
|
+
paths: ["dist/"],
|
|
123
|
+
expire_in: "1 day",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export const testReports = new Artifacts({
|
|
127
|
+
reports: { junit: "coverage/junit.xml", coverage_report: { coverage_format: "cobertura", path: "coverage/cobertura.xml" } },
|
|
128
|
+
paths: ["coverage/"],
|
|
129
|
+
expire_in: "1 week",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
export const build = new Job({
|
|
133
|
+
stage: "build",
|
|
134
|
+
script: ["npm ci", "npm run build"],
|
|
135
|
+
artifacts: buildArtifacts,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export const test = new Job({
|
|
139
|
+
stage: "test",
|
|
140
|
+
script: ["npm ci", "npm test -- --coverage"],
|
|
141
|
+
artifacts: testReports,
|
|
142
|
+
needs: ["build"],
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Artifact Types
|
|
147
|
+
|
|
148
|
+
| Type | Purpose | GitLab feature |
|
|
149
|
+
|------|---------|----------------|
|
|
150
|
+
| `junit` | Test results | MR test report widget |
|
|
151
|
+
| `coverage_report` | Code coverage | MR coverage visualization |
|
|
152
|
+
| `dotenv` | Export variables | Pass variables to downstream jobs |
|
|
153
|
+
| `terraform` | Terraform plans | MR Terraform widget |
|
|
154
|
+
|
|
155
|
+
## Include Patterns
|
|
156
|
+
|
|
157
|
+
### Reusable Pipeline Components
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { Include } from "@intentius/chant-lexicon-gitlab";
|
|
161
|
+
|
|
162
|
+
// Include from same project
|
|
163
|
+
export const localInclude = new Include({
|
|
164
|
+
local: ".gitlab/ci/deploy.yml",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Include from another project
|
|
168
|
+
export const projectInclude = new Include({
|
|
169
|
+
project: "devops/pipeline-templates",
|
|
170
|
+
ref: "main",
|
|
171
|
+
file: "/templates/docker-build.yml",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Include from remote URL
|
|
175
|
+
export const remoteInclude = new Include({
|
|
176
|
+
remote: "https://example.com/ci-templates/security-scan.yml",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Include a GitLab CI template
|
|
180
|
+
export const templateInclude = new Include({
|
|
181
|
+
template: "Security/SAST.gitlab-ci.yml",
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Composites for Common Pipelines
|
|
186
|
+
|
|
187
|
+
Use composites instead of raw includes for type-safe pipeline generation:
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
import { NodePipeline } from "@intentius/chant-lexicon-gitlab";
|
|
191
|
+
|
|
192
|
+
export const app = NodePipeline({
|
|
193
|
+
nodeVersion: "22",
|
|
194
|
+
installCommand: "npm ci",
|
|
195
|
+
buildScript: "build",
|
|
196
|
+
testScript: "test",
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Rules and Conditional Execution
|
|
201
|
+
|
|
202
|
+
### Branch-Based Rules
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
export const deployStaging = new Job({
|
|
206
|
+
stage: "deploy",
|
|
207
|
+
script: ["./deploy.sh staging"],
|
|
208
|
+
rules: [
|
|
209
|
+
{ if: '$CI_COMMIT_BRANCH == "develop"', when: "on_success" },
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
export const deployProd = new Job({
|
|
214
|
+
stage: "deploy",
|
|
215
|
+
script: ["./deploy.sh production"],
|
|
216
|
+
rules: [
|
|
217
|
+
{ if: '$CI_COMMIT_BRANCH == "main"', when: "manual" },
|
|
218
|
+
],
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### MR vs Branch Pipelines
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// Run on merge requests only
|
|
226
|
+
export const mrTest = new Job({
|
|
227
|
+
stage: "test",
|
|
228
|
+
script: ["npm test"],
|
|
229
|
+
rules: [{ if: "$CI_MERGE_REQUEST_IID" }],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Run on default branch only
|
|
233
|
+
export const release = new Job({
|
|
234
|
+
stage: "deploy",
|
|
235
|
+
script: ["npm publish"],
|
|
236
|
+
rules: [{ if: '$CI_COMMIT_BRANCH == "main"' }],
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Review Apps
|
|
241
|
+
|
|
242
|
+
### Deploy Per-MR Environments
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import { ReviewApp } from "@intentius/chant-lexicon-gitlab";
|
|
246
|
+
|
|
247
|
+
export const review = ReviewApp({
|
|
248
|
+
name: "review",
|
|
249
|
+
deployScript: "kubectl apply -f manifests.yaml",
|
|
250
|
+
stopScript: "kubectl delete -f manifests.yaml",
|
|
251
|
+
autoStopIn: "1 week",
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
This generates a deploy job with `environment` and a stop job with `action: stop` that triggers when the MR is merged or closed.
|
|
256
|
+
|
|
257
|
+
## Docker Build Pattern
|
|
258
|
+
|
|
259
|
+
### Multi-Stage Build and Push
|
|
260
|
+
|
|
261
|
+
```typescript
|
|
262
|
+
import { DockerBuild } from "@intentius/chant-lexicon-gitlab";
|
|
263
|
+
|
|
264
|
+
export const docker = DockerBuild({
|
|
265
|
+
dockerfile: "Dockerfile",
|
|
266
|
+
context: ".",
|
|
267
|
+
tagLatest: true,
|
|
268
|
+
registry: "$CI_REGISTRY",
|
|
269
|
+
imageName: "$CI_REGISTRY_IMAGE",
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
This generates a job using Docker-in-Docker (`dind`) service with proper `DOCKER_TLS_CERTDIR` configuration.
|
|
274
|
+
|
|
275
|
+
## Matrix Builds
|
|
276
|
+
|
|
277
|
+
### Test Across Multiple Versions
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
export const test = new Job({
|
|
281
|
+
stage: "test",
|
|
282
|
+
parallel: {
|
|
283
|
+
matrix: [
|
|
284
|
+
{ NODE_VERSION: ["18", "20", "22"] },
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
image: new Image({ name: "node:${NODE_VERSION}-alpine" }),
|
|
288
|
+
script: ["npm ci", "npm test"],
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Pipeline Security
|
|
293
|
+
|
|
294
|
+
### Protected Variables
|
|
295
|
+
|
|
296
|
+
Use protected variables for production secrets. They are only available on protected branches/tags:
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
export const deploy = new Job({
|
|
300
|
+
stage: "deploy",
|
|
301
|
+
script: ["./deploy.sh"],
|
|
302
|
+
variables: { DEPLOY_ENV: "production" },
|
|
303
|
+
rules: [
|
|
304
|
+
{ if: '$CI_COMMIT_BRANCH == "main"', when: "manual" },
|
|
305
|
+
],
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Set `DEPLOY_TOKEN` as a protected, masked variable in project settings.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-gitlab",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
"bundle": "bun run src/package-cli.ts",
|
|
23
23
|
"validate": "bun run src/validate-cli.ts",
|
|
24
24
|
"docs": "bun run src/codegen/docs-cli.ts",
|
|
25
|
-
"prepack": "bun run bundle && bun run validate"
|
|
25
|
+
"prepack": "bun run generate && bun run bundle && bun run validate"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@intentius/chant": "0.0.
|
|
28
|
+
"@intentius/chant": "0.0.18"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"typescript": "^5.9.3"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { fetchCISchema, fetchSchemas, getCachePath, GITLAB_SCHEMA_VERSION } from "./fetch";
|
|
3
|
+
|
|
4
|
+
describe("fetch", () => {
|
|
5
|
+
test("fetchCISchema function exists", () => {
|
|
6
|
+
expect(typeof fetchCISchema).toBe("function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("fetchSchemas function exists", () => {
|
|
10
|
+
expect(typeof fetchSchemas).toBe("function");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("GITLAB_SCHEMA_VERSION is defined", () => {
|
|
14
|
+
expect(typeof GITLAB_SCHEMA_VERSION).toBe("string");
|
|
15
|
+
expect(GITLAB_SCHEMA_VERSION).toMatch(/^v\d+\.\d+\.\d+-ee$/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("getCachePath returns a path", () => {
|
|
19
|
+
const cachePath = getCachePath();
|
|
20
|
+
expect(typeof cachePath).toBe("string");
|
|
21
|
+
expect(cachePath).toContain("gitlab-ci-schema.json");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test.skip("integration: fetchCISchema returns Buffer (requires network)", async () => {
|
|
25
|
+
const data = await fetchCISchema();
|
|
26
|
+
expect(data).toBeInstanceOf(Buffer);
|
|
27
|
+
const parsed = JSON.parse(data.toString("utf-8"));
|
|
28
|
+
expect(parsed.properties).toBeDefined();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { generate, writeGeneratedFiles } from "./generate";
|
|
3
|
+
import { loadSchemaFixtureMap } from "../testdata/load-fixtures";
|
|
4
|
+
|
|
5
|
+
describe("generate pipeline components", () => {
|
|
6
|
+
test("exports generate function", () => {
|
|
7
|
+
expect(typeof generate).toBe("function");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("exports writeGeneratedFiles function", () => {
|
|
11
|
+
expect(typeof writeGeneratedFiles).toBe("function");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Integration test — requires network, skip by default
|
|
15
|
+
test.skip("generates full pipeline (integration)", async () => {
|
|
16
|
+
const result = await generate({ verbose: false });
|
|
17
|
+
|
|
18
|
+
expect(result.resources).toBeGreaterThan(0);
|
|
19
|
+
expect(result.properties).toBeGreaterThan(0);
|
|
20
|
+
expect(result.lexiconJSON).toBeTruthy();
|
|
21
|
+
expect(result.typesDTS).toBeTruthy();
|
|
22
|
+
expect(result.indexTS).toBeTruthy();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("offline fixture pipeline", () => {
|
|
27
|
+
test("generates from fixtures", async () => {
|
|
28
|
+
const fixtures = loadSchemaFixtureMap();
|
|
29
|
+
const result = await generate({ schemaSource: fixtures });
|
|
30
|
+
|
|
31
|
+
expect(result.resources).toBeGreaterThanOrEqual(1);
|
|
32
|
+
expect(result.properties).toBeGreaterThanOrEqual(0);
|
|
33
|
+
expect(result.lexiconJSON).toBeTruthy();
|
|
34
|
+
expect(result.typesDTS).toBeTruthy();
|
|
35
|
+
expect(result.indexTS).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("output contains expected entities", async () => {
|
|
39
|
+
const fixtures = loadSchemaFixtureMap();
|
|
40
|
+
const result = await generate({ schemaSource: fixtures });
|
|
41
|
+
|
|
42
|
+
const lexicon = JSON.parse(result.lexiconJSON);
|
|
43
|
+
// Core CI entities should be present
|
|
44
|
+
expect(lexicon["Job"]).toBeDefined();
|
|
45
|
+
expect(lexicon["Default"]).toBeDefined();
|
|
46
|
+
expect(lexicon["Workflow"]).toBeDefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("resource and property counts are non-zero", async () => {
|
|
50
|
+
const fixtures = loadSchemaFixtureMap();
|
|
51
|
+
const result = await generate({ schemaSource: fixtures });
|
|
52
|
+
|
|
53
|
+
expect(result.resources).toBeGreaterThan(0);
|
|
54
|
+
// Properties are optional in a minimal fixture but count should be defined
|
|
55
|
+
expect(typeof result.properties).toBe("number");
|
|
56
|
+
expect(typeof result.enums).toBe("number");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("produces no warnings from fixtures", async () => {
|
|
60
|
+
const fixtures = loadSchemaFixtureMap();
|
|
61
|
+
const result = await generate({ schemaSource: fixtures });
|
|
62
|
+
|
|
63
|
+
expect(result.warnings).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { generate } from "./generate";
|
|
3
|
+
import { loadSchemaFixtureMap } from "../testdata/load-fixtures";
|
|
4
|
+
|
|
5
|
+
describe("generation idempotency", () => {
|
|
6
|
+
test("two generations from same fixtures produce identical content", async () => {
|
|
7
|
+
const fixtures = loadSchemaFixtureMap();
|
|
8
|
+
|
|
9
|
+
const result1 = await generate({ schemaSource: fixtures });
|
|
10
|
+
const result2 = await generate({ schemaSource: fixtures });
|
|
11
|
+
|
|
12
|
+
// Content should be byte-identical
|
|
13
|
+
expect(result1.lexiconJSON).toBe(result2.lexiconJSON);
|
|
14
|
+
expect(result1.typesDTS).toBe(result2.typesDTS);
|
|
15
|
+
expect(result1.indexTS).toBe(result2.indexTS);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("resource counts match between runs", async () => {
|
|
19
|
+
const fixtures = loadSchemaFixtureMap();
|
|
20
|
+
|
|
21
|
+
const result1 = await generate({ schemaSource: fixtures });
|
|
22
|
+
const result2 = await generate({ schemaSource: fixtures });
|
|
23
|
+
|
|
24
|
+
expect(result1.resources).toBe(result2.resources);
|
|
25
|
+
expect(result1.properties).toBe(result2.properties);
|
|
26
|
+
expect(result1.enums).toBe(result2.enums);
|
|
27
|
+
});
|
|
28
|
+
});
|