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 GOARCH: amd64
CGO_ENABLED: "0" CGO_ENABLED: "0"
run: | 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 \ -o glint-${{ github.ref_name }}-linux-amd64 \
./cmd/glint/... ./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) - name: Build Windows (amd64)
env: env:
GOOS: windows GOOS: windows
GOARCH: amd64 GOARCH: amd64
CGO_ENABLED: "0" CGO_ENABLED: "0"
run: | run: |
go build -trimpath -ldflags="-s -w" \ go build -trimpath \
-o glint-${{ github.ref_name }}.exe \ -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/... ./cmd/glint/...
- name: Create release and upload assets - name: Create release and upload assets
@@ -60,7 +106,13 @@ jobs:
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \ -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
| jq -r .id) | 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 \ curl -sf -X POST \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
+3
View File
@@ -16,6 +16,9 @@ coverage.txt
# Task runner cache # Task runner cache
.task/ .task/
# Tmp
.tmp/
# Claude Code project memory # Claude Code project memory
.claude/ .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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project uses [Semantic Versioning](https://semver.org). 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 ## [0.4.0] - 2026-06-26
### Added ### 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 Intel | `glint-vX.Y.Z-darwin-amd64` |
| macOS Apple Silicon | `glint-vX.Y.Z-darwin-arm64` | | macOS Apple Silicon | `glint-vX.Y.Z-darwin-arm64` |
| Windows x86-64 | `glint-vX.Y.Z-windows-amd64.exe` | | Windows x86-64 | `glint-vX.Y.Z-windows-amd64.exe` |
| Windows ARM64 | `glint-vX.Y.Z-windows-arm64.exe` |
### Linux (amd64) ### Linux (amd64)
+3 -2
View File
@@ -6,7 +6,7 @@
<p align="center"> <p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License"></a> <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> </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. > **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 ## 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`) - **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 - **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 - **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-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-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 # 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 build-release # build all platform binaries at once (requires a tagged commit)
task clean # remove build artifacts 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. 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] **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] **`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 - [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. 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] **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] **`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 - [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 ignore_error: false
- cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml - cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml
ignore_error: true ignore_error: true
- cmd: ./{{.BINARY}} check testdata/nested_rules.yml
ignore_error: true
- cmd: ./{{.BINARY}} explain GL007 - cmd: ./{{.BINARY}} explain GL007
ignore_error: false ignore_error: false
- cmd: ./{{.BINARY}} explain gl042 - cmd: ./{{.BINARY}} explain gl042
@@ -226,6 +228,22 @@ tasks:
generates: generates:
- "{{.BINARY}}-{{.TAG}}-windows-amd64.exe" - "{{.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: build-release:
desc: Build release binaries for all supported platforms (requires a tagged commit) desc: Build release binaries for all supported platforms (requires a tagged commit)
cmds: cmds:
@@ -234,6 +252,7 @@ tasks:
- task: build-darwin-amd64 - task: build-darwin-amd64
- task: build-darwin-arm64 - task: build-darwin-arm64
- task: build-windows - task: build-windows
- task: build-windows-arm64
fuzz: fuzz:
desc: "Run all fuzz targets (set FUZZ_TIME=60s to control per-target duration, default 30s)" desc: "Run all fuzz targets (set FUZZ_TIME=60s to control per-target duration, default 30s)"
@@ -270,4 +289,4 @@ tasks:
clean: clean:
desc: Remove build artifacts 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 return true, nil
} }
vars := ctx.Get vars := ctx.Get
for _, rule := range p.Workflow.Rules { // Workflow rules use strict evaluation: an unparseable condition is treated
// Workflow rules use strict evaluation: an unparseable condition is // as no-match so later rules are reached. Permissive-true would cause an
// treated as no-match so later rules (with valid conditions or a // early rule with a complex condition to block all subsequent rules.
// bare when:) are reached. Permissive-true would cause an early rule for _, group := range p.Workflow.Rules.Groups() {
// with a complex/invalid condition to block all subsequent rules. matched, when, groupVars := evalRuleGroup(group, vars, ctx, true)
if !ruleIfMatchesStrict(rule.If, vars) { if !matched {
continue continue
} }
if !changesMatch(rule.Changes, ctx) {
continue
}
when := rule.When
if when == "" { if when == "" {
when = "always" when = "always"
} }
return when != "never", ExtractStringVars(rule.Variables) return when != "never", groupVars
} }
return false, nil // no rule matched → pipeline does not run 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. // rules: takes priority over only/except.
if len(job.Rules) > 0 { if len(job.Rules) > 0 {
for _, rule := range job.Rules { for _, group := range job.Rules.Groups() {
if !ruleIfMatches(rule.If, vars) { matched, when, _ := evalRuleGroup(group, vars, ctx, false)
if !matched {
continue continue
} }
if !changesMatch(rule.Changes, ctx) { return whenToState(when)
continue
}
return whenToState(rule.When)
} }
return JobSkipped // no rule matched → job is excluded return JobSkipped // no rule matched → job is excluded
} }
@@ -98,6 +92,35 @@ func EvalJob(job model.Job, ctx *Context) JobState {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── 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 { func ruleIfMatches(ifExpr string, vars func(string) string) bool {
if ifExpr == "" { if ifExpr == "" {
return true // no if: condition → rule always matches return true // no if: condition → rule always matches
+82
View File
@@ -867,4 +867,86 @@ test:
Fix: `include: Fix: `include:
- remote: https://ci-templates.example.com/build.yml`, - 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, checkSecrets(name, job)...)
findings = append(findings, checkPagesKeyword(name, job)...) findings = append(findings, checkPagesKeyword(name, job)...)
findings = append(findings, checkCacheKeyFiles(name, job)...) findings = append(findings, checkCacheKeyFiles(name, job)...)
findings = append(findings, checkPullPolicy(name, job)...)
findings = append(findings, checkRulesAllowFailure(name, job)...)
return findings return findings
} }
@@ -313,6 +315,20 @@ func checkTrigger(name string, job model.Job) []Finding {
Message: "'trigger' map must specify 'project' or 'include'", 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 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. // GL041: cache.key.files must be a list of exact file paths, not glob patterns.
func checkCacheKeyFiles(name string, job model.Job) []Finding { func checkCacheKeyFiles(name string, job model.Job) []Finding {
if job.Cache == nil { 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, checkDuplicateStages(p)...)
findings = append(findings, checkDefault(p)...) findings = append(findings, checkDefault(p)...)
findings = append(findings, checkWorkflow(p)...) findings = append(findings, checkWorkflow(p)...)
findings = append(findings, checkPipelineVariableOptions(p)...)
findings = append(findings, checkJobs(p)...) findings = append(findings, checkJobs(p)...)
findings = append(findings, checkNeeds(p, skipped)...) findings = append(findings, checkNeeds(p, skipped)...)
findings = append(findings, checkRulesNeeds(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, checkJobKeywords(name, job)...)
findings = append(findings, checkVariableOptionsForJob(name, job)...)
// Attach source location to every job-scoped finding collected above. // Attach source location to every job-scoped finding collected above.
for i := range findings { for i := range findings {
@@ -235,6 +237,55 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding {
return findings 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 // scriptNonEmpty reports whether a script/before_script/after_script field
// (which may be a []any list or a plain string) is non-empty. // (which may be a []any list or a plain string) is non-empty.
func scriptNonEmpty(v any) bool { 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 // CI templates fetched over HTTP are transmitted in cleartext and can be
// intercepted or modified in transit. // intercepted or modified in transit.
RuleInsecureRemoteInclude = "GL045" 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 continue
} }
for _, varName := range extractIfVars(rule.If) { for _, varName := range extractIfVars(rule.If) {
if isPredefinedVar(varName) || pipelineVars[varName] || seen[varName] { if isPredefinedVar(varName) || pipelineVars[varName] || workflowRuleVars[varName] || seen[varName] {
continue continue
} }
seen[varName] = true seen[varName] = true
+65 -2
View File
@@ -1,5 +1,7 @@
package model package model
import "gopkg.in/yaml.v3"
// Pipeline represents the top-level structure of a .gitlab-ci.yml file. // Pipeline represents the top-level structure of a .gitlab-ci.yml file.
// Unknown top-level keys are collected into Jobs. // Unknown top-level keys are collected into Jobs.
type Pipeline struct { type Pipeline struct {
@@ -41,7 +43,9 @@ type DefaultConfig struct {
} }
type Workflow struct { type Workflow struct {
Rules []Rule `yaml:"rules"` Name string `yaml:"name"`
AutoCancel any `yaml:"auto_cancel"`
Rules Rules `yaml:"rules"`
} }
type Job struct { type Job struct {
@@ -57,7 +61,7 @@ type Job struct {
Image any `yaml:"image"` Image any `yaml:"image"`
Services []any `yaml:"services"` Services []any `yaml:"services"`
Variables map[string]any `yaml:"variables"` // string or {value,description,options} map Variables map[string]any `yaml:"variables"` // string or {value,description,options} map
Rules []Rule `yaml:"rules"` Rules Rules `yaml:"rules"`
Only any `yaml:"only"` Only any `yaml:"only"`
Except any `yaml:"except"` Except any `yaml:"except"`
Needs []any `yaml:"needs"` Needs []any `yaml:"needs"`
@@ -91,6 +95,65 @@ type Rule struct {
Exists any `yaml:"exists"` // []string or map form Exists any `yaml:"exists"` // []string or map form
Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+) 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+) 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. // ReservedKeys are top-level GitLab CI keys that are NOT job definitions.
+54
View File
@@ -2,6 +2,13 @@ stages:
- build - build
- test - test
variables:
DEPLOY_ENV:
value: canary # ERROR: not listed in options
options:
- staging
- production
workflow: workflow:
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push"' - if: '$CI_PIPELINE_SOURCE == "push"'
@@ -126,3 +133,50 @@ bad-rule-when-job:
rules: rules:
- if: '$CI_MERGE_REQUEST_ID' - if: '$CI_MERGE_REQUEST_ID'
when: sometimes # ERROR: invalid rules[0].when 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: variables:
GO_VERSION: "1.26" GO_VERSION: "1.26"
DEPLOY_ENV:
value: staging
options:
- staging
- production
- review
build-job: build-job:
stage: build stage: build
image: image:
name: golang:1.26 name: golang:1.26
entrypoint: [""] entrypoint: [""]
pull_policy: if-not-present
script: script:
- go build ./... - go build ./...
artifacts: artifacts:
@@ -72,6 +79,24 @@ release-job:
action: start action: start
when: on_success 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: .template:
before_script: before_script:
- echo "before" - 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"' - if: '$CI_COMMIT_BRANCH == "main"'
variables: variables:
DEPLOY_TARGET: "production" 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 - when: always
build: build: