Compare commits

...
4 Commits
Author SHA1 Message Date
k3nnyandClaude Sonnet 4.6 986162d270 feat(linter): add GL046-GL049 rules, AND-group support, and GL032 fix
ci / vet, staticcheck, test, build (push) Successful in 8m44s
release / Build and publish release (push) Successful in 9m52s
- GL046: validate image/service pull_policy values (always, if-not-present, never)
- GL047: error when a variables.options default value is not in the options list
- GL048: error on unrecognised trigger.forward keys
- GL049: validate rules[n].allow_failure (bool or {exit_codes:} map)
- Parse and evaluate workflow.rules/job.rules nested-array AND-groups; crash
  on !!seq nodes is fixed; all members of a group must match for it to fire
- Add workflow.name and workflow.auto_cancel fields to Workflow struct
- Fix GL032 false positive: variables declared in any workflow rule's variables:
  block no longer trigger an undeclared-variable warning in sibling workflow
  rule if: expressions
- Add Windows ARM64 release build target (task build-windows-arm64)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 22:44:22 +02:00
k3nny 3eea496b9f feat(release): 🚀 ajout arm64 2026-07-30 22:03:56 +02:00
k3nnyandClaude Sonnet 4.6 ef0d7b118a docs(docs): update CHANGELOG and README badge for v0.4.1
ci / vet, staticcheck, test, build (push) Successful in 3m4s
release / Build and publish release (push) Successful in 2m52s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:58:14 +02:00
k3nnyandClaude Sonnet 4.6 b222105e1f build(build): add Linux arm64, macOS amd64/arm64 to release CI
ci / vet, staticcheck, test, build (push) Successful in 2m10s
- Add build steps for linux/arm64, darwin/amd64, darwin/arm64
- Rename Windows output to glint-<tag>-windows-amd64.exe (consistent
  with Taskfile and INSTALL.md)
- Add -X main.version=<tag> to all ldflags (was missing, causing
  release binaries to report "dev" as version)
- Use $TAG variable in upload loop instead of repeating the expression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 23:56:42 +02:00
18 changed files with 579 additions and 33 deletions
+56 -4
View File
@@ -32,18 +32,64 @@ jobs:
GOARCH: amd64
CGO_ENABLED: "0"
run: |
go build -trimpath -ldflags="-s -w" \
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-linux-amd64 \
./cmd/glint/...
- name: Build Linux (arm64)
env:
GOOS: linux
GOARCH: arm64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-linux-arm64 \
./cmd/glint/...
- name: Build macOS (amd64)
env:
GOOS: darwin
GOARCH: amd64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-darwin-amd64 \
./cmd/glint/...
- name: Build macOS (arm64)
env:
GOOS: darwin
GOARCH: arm64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-darwin-arm64 \
./cmd/glint/...
- name: Build Windows (amd64)
env:
GOOS: windows
GOARCH: amd64
CGO_ENABLED: "0"
run: |
go build -trimpath -ldflags="-s -w" \
-o glint-${{ github.ref_name }}.exe \
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-windows-amd64.exe \
./cmd/glint/...
- name: Build Windows (arm64)
env:
GOOS: windows
GOARCH: arm64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o glint-${{ github.ref_name }}-windows-arm64.exe \
./cmd/glint/...
- name: Create release and upload assets
@@ -60,7 +106,13 @@ jobs:
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
| jq -r .id)
for file in glint-${{ github.ref_name }}-linux-amd64 glint-${{ github.ref_name }}.exe; do
for file in \
glint-${TAG}-linux-amd64 \
glint-${TAG}-linux-arm64 \
glint-${TAG}-darwin-amd64 \
glint-${TAG}-darwin-arm64 \
glint-${TAG}-windows-amd64.exe \
glint-${TAG}-windows-arm64.exe; do
curl -sf -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
+3
View File
@@ -16,6 +16,9 @@ coverage.txt
# Task runner cache
.task/
# Tmp
.tmp/
# Claude Code project memory
.claude/
+22
View File
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org).
## [0.5.0] - 2026-07-30
### Added
- **Windows ARM64 build target** — `task build-windows-arm64` cross-compiles `glint-<tag>-windows-arm64.exe`; the Gitea release CI uploads it alongside the other five platform binaries.
- **`workflow.rules` AND-group support** — the `- -` nested-array rule form (AND-groups) is now parsed and evaluated with correct AND logic: all conditions in a group must match for the group to fire; `when:` and `variables:` from all matching members are merged. Previously this form caused a YAML unmarshal crash.
- **`workflow.name` and `workflow.auto_cancel` fields** — no longer cause a parse error when present; both are decoded into the `Workflow` struct.
- **GL046 — `pull_policy` validation** — errors when `image.pull_policy` or `services[n].pull_policy` uses a value other than `always`, `if-not-present`, or `never`; both the string scalar and list-of-strings forms are checked.
- **GL047 — `variables.options` default validation** — errors when a pipeline-level or job-level variable declares an `options:` list and its `value:` (default) is absent from that list; GitLab rejects such pipelines at creation time.
- **GL048 — `trigger.forward` key validation** — errors on unrecognised keys inside `trigger.forward:`; only `pipeline_variables` and `yaml_variables` are valid.
- **GL049 — `rules[n].allow_failure` validation** — validates rule-level `allow_failure:` (GitLab CI 15.0+): must be a boolean or a map with an `exit_codes:` key; applies the same checks as the job-level GL014.
### Fixed
- **GL032 false positive in workflow rules** — variables set by any workflow rule's `variables:` block are now excluded from GL032 undeclared-variable warnings when referenced in sibling workflow rule `if:` expressions; previously only pipeline-level `variables:` entries were exempt.
## [0.4.1] - 2026-06-26
### Fixed
- **Release CI** — added Linux arm64, macOS Intel, and macOS Apple Silicon build steps; renamed Windows output to `glint-<tag>-windows-amd64.exe` for consistency; added missing `-X main.version=<tag>` ldflags so release binaries report the correct version instead of `"dev"`.
## [0.4.0] - 2026-06-26
### Added
+1
View File
@@ -36,6 +36,7 @@ Pre-built binaries are attached to each [release](https://git.k3nny.fr/k3nny/gli
| macOS Intel | `glint-vX.Y.Z-darwin-amd64` |
| macOS Apple Silicon | `glint-vX.Y.Z-darwin-arm64` |
| Windows x86-64 | `glint-vX.Y.Z-windows-amd64.exe` |
| Windows ARM64 | `glint-vX.Y.Z-windows-arm64.exe` |
### Linux (amd64)
+3 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.4.0-blue.svg" alt="Release"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.5.0-blue.svg" alt="Release"></a>
</p>
> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome.
@@ -15,7 +15,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
## What it does
- **Lints** — 45 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL045); run `glint explain <ID>` for any rule
- **Lints** — 49 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001GL049); run `glint explain <ID>` for any rule
- **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support and HTTP proxy support (`--proxy` flag or `proxy:` in `.glint.yml`)
- **Renders merged pipeline** — `glint render` resolves all includes and `extends:` chains into a single flat YAML file, matching what GitLab CI actually processes
- **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run
@@ -158,6 +158,7 @@ task build-linux-arm64 # cross-compile for Linux ARM64 (requires a tagged comm
task build-darwin-amd64 # cross-compile for macOS Intel (requires a tagged commit)
task build-darwin-arm64 # cross-compile for macOS Apple Silicon (requires a tagged commit)
task build-windows # cross-compile for Windows x86-64 (requires a tagged commit)
task build-windows-arm64 # cross-compile for Windows ARM64 (requires a tagged commit)
task build-release # build all platform binaries at once (requires a tagged commit)
task clean # remove build artifacts
```
+5
View File
@@ -8,6 +8,7 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint graph` to evaluate `rules:if:` expressions and `only`/`except` filters against a specific pipeline event.
- [x] **`workflow.rules` AND-group semantics** — shipped v0.5.0; the `- -` nested-array rule form (AND-groups) is parsed and evaluated with correct AND logic: all conditions in a group must match for the group to fire; variables and `when:` from all matching group members are merged
- [x] **Single-context simulation** — shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped
- [x] **`workflow:rules:variables:` propagation** — shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated
- [x] **Expression evaluator: multi-line `if:` values** — shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace
@@ -32,6 +33,10 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
- [x] **`image.pull_policy` / `services[n].pull_policy` validation (GL046)** — shipped v0.5.0; validates that `pull_policy` values are one of `always`, `if-not-present`, `never`; applies to both `image:` map form and service entries; list form also checked element-by-element
- [x] **`variables.options` default value validation (GL047)** — shipped v0.5.0; errors when a variable declares an `options:` list and its `value:` (default) is not listed; checked at pipeline level and per-job; GitLab rejects such pipelines at creation time
- [x] **`trigger.forward` key validation (GL048)** — shipped v0.5.0; errors on unrecognised keys inside `trigger.forward:`; only `pipeline_variables` and `yaml_variables` are valid
- [x] **`rules[n].allow_failure` validation (GL049)** — shipped v0.5.0; validates rule-level `allow_failure:` (GitLab CI 15.0+) using the same rules as job-level GL014: must be a boolean or a `{exit_codes:}` map
- [x] **Variable reference validation (GL032)** — shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered
- [x] **`rules:if:` static reachability (GL033)** — shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required
- [x] **`services:` validation (GL034)** — shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label
+20 -1
View File
@@ -117,6 +117,8 @@ tasks:
ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml
ignore_error: true
- cmd: ./{{.BINARY}} check testdata/nested_rules.yml
ignore_error: true
- cmd: ./{{.BINARY}} explain GL007
ignore_error: false
- cmd: ./{{.BINARY}} explain gl042
@@ -226,6 +228,22 @@ tasks:
generates:
- "{{.BINARY}}-{{.TAG}}-windows-amd64.exe"
build-windows-arm64:
desc: Build the glint binary for Windows ARM64 (requires a tagged commit)
vars:
TAG:
sh: git describe --tags --exact-match
preconditions:
- sh: git describe --tags --exact-match
msg: "Current commit is not tagged — release build requires a git tag"
cmds:
- "GOOS=windows GOARCH=arm64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-windows-arm64.exe ./cmd/glint/..."
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BINARY}}-{{.TAG}}-windows-arm64.exe"
build-release:
desc: Build release binaries for all supported platforms (requires a tagged commit)
cmds:
@@ -234,6 +252,7 @@ tasks:
- task: build-darwin-amd64
- task: build-darwin-arm64
- task: build-windows
- task: build-windows-arm64
fuzz:
desc: "Run all fuzz targets (set FUZZ_TIME=60s to control per-target duration, default 30s)"
@@ -270,4 +289,4 @@ tasks:
clean:
desc: Remove build artifacts
cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64
cmd: rm -f {{.BINARY}} {{.BINARY}}-*.exe {{.BINARY}}-*-linux-amd64 {{.BINARY}}-*-linux-arm64 {{.BINARY}}-*-darwin-amd64 {{.BINARY}}-*-darwin-arm64
+40 -17
View File
@@ -42,22 +42,18 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
return true, nil
}
vars := ctx.Get
for _, rule := range p.Workflow.Rules {
// Workflow rules use strict evaluation: an unparseable condition is
// treated as no-match so later rules (with valid conditions or a
// bare when:) are reached. Permissive-true would cause an early rule
// with a complex/invalid condition to block all subsequent rules.
if !ruleIfMatchesStrict(rule.If, vars) {
// Workflow rules use strict evaluation: an unparseable condition is treated
// as no-match so later rules are reached. Permissive-true would cause an
// early rule with a complex condition to block all subsequent rules.
for _, group := range p.Workflow.Rules.Groups() {
matched, when, groupVars := evalRuleGroup(group, vars, ctx, true)
if !matched {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
when := rule.When
if when == "" {
when = "always"
}
return when != "never", ExtractStringVars(rule.Variables)
return when != "never", groupVars
}
return false, nil // no rule matched → pipeline does not run
}
@@ -74,14 +70,12 @@ func EvalJob(job model.Job, ctx *Context) JobState {
// rules: takes priority over only/except.
if len(job.Rules) > 0 {
for _, rule := range job.Rules {
if !ruleIfMatches(rule.If, vars) {
for _, group := range job.Rules.Groups() {
matched, when, _ := evalRuleGroup(group, vars, ctx, false)
if !matched {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
return whenToState(rule.When)
return whenToState(when)
}
return JobSkipped // no rule matched → job is excluded
}
@@ -98,6 +92,35 @@ func EvalJob(job model.Job, ctx *Context) JobState {
// ── Helpers ───────────────────────────────────────────────────────────────────
// evalRuleGroup evaluates one rule group (one or more rules forming an AND-group).
// All conditions in the group must match for the group to fire.
// Returns (matched, effectiveWhen, mergedVars). When strict=true, unparseable
// if: expressions count as no-match; when false, they count as match (permissive).
func evalRuleGroup(group []model.Rule, vars func(string) string, ctx *Context, strict bool) (bool, string, map[string]string) {
for _, rule := range group {
var ifOk bool
if strict {
ifOk = ruleIfMatchesStrict(rule.If, vars)
} else {
ifOk = ruleIfMatches(rule.If, vars)
}
if !ifOk || !changesMatch(rule.Changes, ctx) {
return false, "", nil
}
}
merged := make(map[string]string)
effectiveWhen := ""
for _, rule := range group {
for k, v := range ExtractStringVars(rule.Variables) {
merged[k] = v
}
if rule.When != "" {
effectiveWhen = rule.When
}
}
return true, effectiveWhen, merged
}
func ruleIfMatches(ifExpr string, vars func(string) string) bool {
if ifExpr == "" {
return true // no if: condition → rule always matches
+82
View File
@@ -867,4 +867,86 @@ test:
Fix: `include:
- remote: https://ci-templates.example.com/build.yml`,
},
RuleInvalidPullPolicy: {
Title: "'pull_policy:' has invalid value",
Severity: Error,
Description: "'image.pull_policy' and 'services[n].pull_policy' must be one of " +
"'always', 'if-not-present', or 'never', or a list of those values. " +
"Any other string is rejected by GitLab at pipeline creation time.",
Example: `my-job:
image:
name: alpine
pull_policy: on-demand # not a valid value
script: echo hi`,
Fix: `my-job:
image:
name: alpine
pull_policy: if-not-present
script: echo hi`,
},
RuleVariableValueNotInOptions: {
Title: "variable default value not listed in 'options'",
Severity: Error,
Description: "A pipeline variable declares an 'options' list that constrains what " +
"values can be chosen when triggering the pipeline manually. If the 'value' " +
"(the default) is not in that list, GitLab rejects the pipeline at creation " +
"time with a validation error.",
Example: `variables:
DEPLOY_ENV:
value: staging
options:
- production
- review # 'staging' is missing from the list`,
Fix: `variables:
DEPLOY_ENV:
value: staging
options:
- staging
- production
- review`,
},
RuleInvalidTriggerForward: {
Title: "'trigger.forward:' has unrecognised key",
Severity: Error,
Description: "'trigger.forward' controls which variables are forwarded to the " +
"downstream pipeline. Only 'pipeline_variables' and 'yaml_variables' are " +
"valid keys. Any other key is silently ignored by some GitLab versions and " +
"rejected by others.",
Example: `deploy:
trigger:
include:
- artifact: pipeline.yml
job: build
forward:
all_variables: true # not a valid key`,
Fix: `deploy:
trigger:
include:
- artifact: pipeline.yml
job: build
forward:
pipeline_variables: true
yaml_variables: true`,
},
RuleInvalidRulesAllowFailure: {
Title: "'rules[n].allow_failure:' invalid value",
Severity: Error,
Description: "'allow_failure' inside a 'rules:' entry (GitLab CI 15.0+) must be " +
"a boolean (true/false) or a map with an 'exit_codes' key. This overrides " +
"the job-level 'allow_failure' when the rule matches.",
Example: `my-job:
script: ./test.sh
rules:
- if: $CI_COMMIT_BRANCH
allow_failure: maybe # must be true/false or a map`,
Fix: `my-job:
script: ./test.sh
rules:
- if: $CI_COMMIT_BRANCH
allow_failure: true`,
},
}
+95
View File
@@ -114,6 +114,8 @@ func checkJobKeywords(name string, job model.Job) []Finding {
findings = append(findings, checkSecrets(name, job)...)
findings = append(findings, checkPagesKeyword(name, job)...)
findings = append(findings, checkCacheKeyFiles(name, job)...)
findings = append(findings, checkPullPolicy(name, job)...)
findings = append(findings, checkRulesAllowFailure(name, job)...)
return findings
}
@@ -313,6 +315,20 @@ func checkTrigger(name string, job model.Job) []Finding {
Message: "'trigger' map must specify 'project' or 'include'",
})
}
// GL048: validate trigger.forward keys.
if fwd, ok := m["forward"].(map[string]any); ok {
validForwardKeys := map[string]bool{"pipeline_variables": true, "yaml_variables": true}
for k := range fwd {
if !validForwardKeys[k] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidTriggerForward,
Job: name,
Message: fmt.Sprintf("'trigger.forward' has unrecognised key %q; valid keys: pipeline_variables, yaml_variables", k),
})
}
}
}
}
return findings
}
@@ -793,6 +809,85 @@ func checkPagesKeyword(name string, job model.Job) []Finding {
}}
}
// GL046: image.pull_policy and services[n].pull_policy must use recognised values.
var validPullPolicy = map[string]bool{
"always": true, "if-not-present": true, "never": true,
}
func checkPullPolicy(name string, job model.Job) []Finding {
var findings []Finding
findings = append(findings, checkPullPolicyValue(name, "image", job.Image)...)
for i, svc := range job.Services {
findings = append(findings, checkPullPolicyValue(name, fmt.Sprintf("services[%d]", i), svc)...)
}
return findings
}
func checkPullPolicyValue(jobName, field string, v any) []Finding {
m, ok := v.(map[string]any)
if !ok {
return nil
}
pp, exists := m["pull_policy"]
if !exists || pp == nil {
return nil
}
var policies []string
switch x := pp.(type) {
case string:
policies = []string{x}
case []any:
for _, item := range x {
if s, ok := item.(string); ok {
policies = append(policies, s)
}
}
}
var findings []Finding
for _, p := range policies {
if !validPullPolicy[p] {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidPullPolicy,
Job: jobName,
Message: fmt.Sprintf("%s.pull_policy has unrecognised value %q; valid: always, if-not-present, never", field, p),
})
}
}
return findings
}
// GL049: rules[n].allow_failure must be a boolean or a map with exit_codes:.
func checkRulesAllowFailure(name string, job model.Job) []Finding {
var findings []Finding
for i, rule := range job.Rules {
if rule.AllowFailure == nil {
continue
}
switch v := rule.AllowFailure.(type) {
case bool:
// valid
case map[string]any:
if _, ok := v["exit_codes"]; !ok {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRulesAllowFailure,
Job: name,
Message: fmt.Sprintf("rules[%d].allow_failure map form must contain 'exit_codes'", i),
})
}
default:
findings = append(findings, Finding{
Severity: Error,
Rule: RuleInvalidRulesAllowFailure,
Job: name,
Message: fmt.Sprintf("rules[%d].allow_failure must be a boolean or a map with 'exit_codes'", i),
})
}
}
return findings
}
// GL041: cache.key.files must be a list of exact file paths, not glob patterns.
func checkCacheKeyFiles(name string, job model.Job) []Finding {
if job.Cache == nil {
+51
View File
@@ -64,6 +64,7 @@ func Lint(p *model.Pipeline, skipped map[string]bool) []Finding {
findings = append(findings, checkDuplicateStages(p)...)
findings = append(findings, checkDefault(p)...)
findings = append(findings, checkWorkflow(p)...)
findings = append(findings, checkPipelineVariableOptions(p)...)
findings = append(findings, checkJobs(p)...)
findings = append(findings, checkNeeds(p, skipped)...)
findings = append(findings, checkRulesNeeds(p, skipped)...)
@@ -223,6 +224,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
}
findings = append(findings, checkJobKeywords(name, job)...)
findings = append(findings, checkVariableOptionsForJob(name, job)...)
// Attach source location to every job-scoped finding collected above.
for i := range findings {
@@ -235,6 +237,55 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
return findings
}
// GL047: variable declared with options: must have its default value in the options list.
func checkPipelineVariableOptions(p *model.Pipeline) []Finding {
return checkVariableOptionsMap(p.Variables, "", p.SourceFile, 0, 0)
}
func checkVariableOptionsForJob(name string, job model.Job) []Finding {
return checkVariableOptionsMap(job.Variables, name, job.File, job.Line, job.Column)
}
func checkVariableOptionsMap(vars map[string]any, jobName, file string, line, col int) []Finding {
var findings []Finding
for varName, v := range vars {
m, ok := v.(map[string]any)
if !ok {
continue
}
rawOpts, hasOpts := m["options"]
rawVal, hasVal := m["value"]
if !hasOpts || !hasVal || rawVal == nil {
continue
}
opts, ok := rawOpts.([]any)
if !ok || len(opts) == 0 {
continue
}
val := fmt.Sprint(rawVal)
inOptions := false
for _, opt := range opts {
if fmt.Sprint(opt) == val {
inOptions = true
break
}
}
if !inOptions {
findings = append(findings, Finding{
Severity: Error,
Rule: RuleVariableValueNotInOptions,
Job: jobName,
File: file,
Line: line,
Column: col,
Message: fmt.Sprintf("variable %q: default value %q is not listed in 'options'", varName, val),
})
}
}
return findings
}
// scriptNonEmpty reports whether a script/before_script/after_script field
// (which may be a []any list or a plain string) is non-empty.
func scriptNonEmpty(v any) bool {
+15
View File
@@ -163,4 +163,19 @@ const (
// CI templates fetched over HTTP are transmitted in cleartext and can be
// intercepted or modified in transit.
RuleInsecureRemoteInclude = "GL045"
// GL046: image: or services[n]: pull_policy: contains an unrecognised value.
// Valid values: always, if-not-present, never (or a list of those values).
RuleInvalidPullPolicy = "GL046"
// GL047: a variable declared with options: has a default value: that is not
// listed in the options list. GitLab rejects the pipeline at creation time.
RuleVariableValueNotInOptions = "GL047"
// GL048: trigger.forward: contains an unrecognised key. Only
// pipeline_variables and yaml_variables are valid.
RuleInvalidTriggerForward = "GL048"
// GL049: rules[n].allow_failure: is not a boolean or a map with exit_codes:.
RuleInvalidRulesAllowFailure = "GL049"
)
+1 -1
View File
@@ -110,7 +110,7 @@ func checkVariableRefs(p *model.Pipeline) []Finding {
continue
}
for _, varName := range extractIfVars(rule.If) {
if isPredefinedVar(varName) || pipelineVars[varName] || seen[varName] {
if isPredefinedVar(varName) || pipelineVars[varName] || workflowRuleVars[varName] || seen[varName] {
continue
}
seen[varName] = true
+65 -2
View File
@@ -1,5 +1,7 @@
package model
import "gopkg.in/yaml.v3"
// Pipeline represents the top-level structure of a .gitlab-ci.yml file.
// Unknown top-level keys are collected into Jobs.
type Pipeline struct {
@@ -41,7 +43,9 @@ type DefaultConfig struct {
}
type Workflow struct {
Rules []Rule `yaml:"rules"`
Name string `yaml:"name"`
AutoCancel any `yaml:"auto_cancel"`
Rules Rules `yaml:"rules"`
}
type Job struct {
@@ -57,7 +61,7 @@ type Job struct {
Image any `yaml:"image"`
Services []any `yaml:"services"`
Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
Rules []Rule `yaml:"rules"`
Rules Rules `yaml:"rules"`
Only any `yaml:"only"`
Except any `yaml:"except"`
Needs []any `yaml:"needs"`
@@ -91,6 +95,65 @@ type Rule struct {
Exists any `yaml:"exists"` // []string or map form
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+)
Needs []any `yaml:"needs"` // override needs: when this rule matches (GitLab CI 16.4+)
AllowFailure any `yaml:"allow_failure"` // bool or {exit_codes:} map (GitLab CI 15.0+)
GroupID int `yaml:"-"` // >0 means AND-group; set by Rules.UnmarshalYAML
}
// Rules is a list of Rule entries that also supports GitLab CI's nested-array
// form where each outer entry can itself be a sequence of rules (AND-group).
// Rules with the same non-zero GroupID form an AND-group: all conditions must
// match for the group to fire. GroupID == 0 means standalone rule.
type Rules []Rule
func (r *Rules) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.SequenceNode {
return nil
}
nextGroup := 1
for _, item := range value.Content {
switch item.Kind {
case yaml.MappingNode:
var rule Rule // GroupID stays 0 (standalone)
if err := item.Decode(&rule); err != nil {
return err
}
*r = append(*r, rule)
case yaml.SequenceNode:
// AND-group: assign a shared GroupID so the evaluator can apply AND logic.
var group []Rule
if err := item.Decode(&group); err != nil {
return err
}
for i := range group {
group[i].GroupID = nextGroup
}
*r = append(*r, group...)
nextGroup++
}
}
return nil
}
// Groups returns the rules partitioned into AND-groups. Standalone rules
// (GroupID == 0) form single-element groups; rules with a shared GroupID form
// one group where all conditions must match.
func (r Rules) Groups() [][]Rule {
var groups [][]Rule
seen := map[int]int{} // groupID → index in groups
for _, rule := range r {
if rule.GroupID == 0 {
groups = append(groups, []Rule{rule})
} else {
idx, ok := seen[rule.GroupID]
if !ok {
idx = len(groups)
seen[rule.GroupID] = idx
groups = append(groups, nil)
}
groups[idx] = append(groups[idx], rule)
}
}
return groups
}
// ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
+54
View File
@@ -2,6 +2,13 @@ stages:
- build
- test
variables:
DEPLOY_ENV:
value: canary # ERROR: not listed in options
options:
- staging
- production
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push"'
@@ -126,3 +133,50 @@ bad-rule-when-job:
rules:
- if: '$CI_MERGE_REQUEST_ID'
when: sometimes # ERROR: invalid rules[0].when
bad-pull-policy-job:
stage: build
script:
- echo hello
image:
name: alpine
pull_policy: on-demand # ERROR: invalid pull_policy value
bad-pull-policy-service-job:
stage: build
script:
- echo hello
services:
- name: postgres:15
pull_policy: lazy # ERROR: invalid service pull_policy value
bad-trigger-forward-job:
trigger:
project: mygroup/myproject
forward:
all_variables: true # ERROR: unrecognised trigger.forward key
bad-trigger-forward-key2-job:
trigger:
include:
- artifact: child.yml
job: build
forward:
inherit: false # ERROR: unrecognised trigger.forward key
bad-rules-allow-failure-job:
stage: build
script:
- echo hello
rules:
- if: $CI_COMMIT_BRANCH
allow_failure: maybe # ERROR: invalid rules[0].allow_failure value
bad-rules-allow-failure-map-job:
stage: build
script:
- echo hello
rules:
- if: $CI_COMMIT_BRANCH
allow_failure:
codes: [1] # ERROR: map missing exit_codes key
+25
View File
@@ -15,12 +15,19 @@ default:
variables:
GO_VERSION: "1.26"
DEPLOY_ENV:
value: staging
options:
- staging
- production
- review
build-job:
stage: build
image:
name: golang:1.26
entrypoint: [""]
pull_policy: if-not-present
script:
- go build ./...
artifacts:
@@ -72,6 +79,24 @@ release-job:
action: start
when: on_success
.trigger-valid:
trigger:
include:
- artifact: child.yml
job: build
forward:
pipeline_variables: true
yaml_variables: false
.rules-allow-failure-valid:
script: echo ok
rules:
- if: $CI_COMMIT_BRANCH
allow_failure: true
- if: $CI_COMMIT_TAG
allow_failure:
exit_codes: [1, 2]
.template:
before_script:
- echo "before"
+31
View File
@@ -0,0 +1,31 @@
---
# Tests that workflow.rules and job.rules accept the nested-array (AND-group)
# form where each outer entry can itself be a sequence of rule conditions.
workflow:
name: "${PIPELINE_NAME}"
auto_cancel:
on_new_commit: interruptible
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
variables:
PIPELINE_NAME: "main"
- - if: "$CI_PIPELINE_SOURCE == 'push' && $CI_COMMIT_BRANCH =~ /^feat\\///"
variables:
PIPELINE_NAME: "feature"
- - if: "$CI_COMMIT_TAG"
- if: "$DEPLOY"
variables:
PIPELINE_NAME: "deploy"
variables:
DEPLOY: "false"
build:
stage: build
script: echo building
rules:
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
- - if: "$CI_PIPELINE_SOURCE == 'push'"
allow_failure: true
- if: "$DEPLOY"
when: manual
+4
View File
@@ -17,6 +17,10 @@ workflow:
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
DEPLOY_TARGET: "production"
WORKFLOW_FLAG: "true"
# workflow rule referencing a variable set by a sibling rule's variables: — GL032 must not fire
- if: '$WORKFLOW_FLAG == "true"'
when: always
- when: always
build: