Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54b5850835 | |||
| 5fee51ec7d | |||
| cbed44b1e9 |
@@ -5,6 +5,42 @@ 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.2.15] - 2026-06-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Static reachability check (GL033)** — warns when every rule in a job's `rules:` block has an explicit `when: never`, making the job permanently excluded from any pipeline run. This is a purely static claim: no matter which `if:` condition evaluates to true, the outcome is always "skip"; and if no rule matches, the implicit fallback is also skip. No expression evaluation or context is required. The finding is a `WARNING` (may be intentional as a "disabled job" pattern). Only jobs where every rule has the literal `when: never` value are flagged; rules with no `when:` (default `on_success`), `when: manual`, `when: always`, or `when: on_failure` are not.
|
||||
|
||||
## [0.2.14] - 2026-06-13
|
||||
|
||||
### Added
|
||||
|
||||
- **`--version` / `-v` flag** — `glint --version`, `glint -v`, and `glint version` all print the compiled version string (e.g. `glint v0.2.14`). The version is also shown at the top of every `--help` output (global, `check --help`, `graph --help`). The version is injected at build time via `-ldflags "-X main.version=..."` using `git describe --tags --always --dirty`.
|
||||
|
||||
- **Sorted findings output** — `Lint` now returns findings sorted by `(File, Line, Rule)`. All issues from the same source file appear together in ascending line order; pipeline-level findings with no file location sort first. Previously findings were emitted in map-iteration order (non-deterministic).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Warning format consistency** — include-resolution warnings, extends-chain warnings, and the workflow non-start warning now use the same ruff-style `path: [warning] message` format as lint findings instead of the old `[WARNING] …` prefix with no file context.
|
||||
|
||||
- **Workflow rule permissive evaluation** — workflow `rules:if:` expressions are now evaluated in strict mode: an expression that cannot be fully parsed returns `false` (skip this rule, try the next) instead of `true` (match everything). Previously, a complex or partially-unsupported condition on the first workflow rule would match every context, blocking all subsequent rules and injecting the wrong variables. Job rules retain permissive evaluation (`true` on parse failure) to avoid silently dropping jobs.
|
||||
|
||||
- **Single `=` operator in `rules:if:`** — a bare `=` not followed by `=` or `~` is now accepted as an alias for `==`. This is a common mistake in GitLab CI YAML; previously it caused a parse failure and triggered the permissive fallback.
|
||||
|
||||
- **Source location lost through `extends:` resolution** — when a job was resolved via `extends:`, the merged definition was re-encoded and re-decoded as a fresh `model.Job` struct, which does not carry `File` or `Line` (they are not YAML keys). Those fields are now explicitly copied back from the original job before replacing it in `p.Jobs`, so extended jobs report the correct source file and line number in findings.
|
||||
|
||||
## [0.2.13] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Variable expansion (`$VAR` / `${VAR}`)** — variable values that reference other variables are now expanded in the effective context after all sources are merged. Transitive chains (`A=$B`, `B=$C`) are resolved over up to ten passes; circular references are left as-is. The expanded values are visible in `--list-vars` output under "Effective context variables" and are used when evaluating `rules:if:` expressions.
|
||||
|
||||
- **Non-string scalar variables (`bool`, `int`, `float64`)** — variables declared with bare `true`/`false` or integer values (e.g. `BUILD: true`, `RETRIES: 3`) are now handled correctly in all variable processing paths. Previously they were rendered as `(complex)` in `--list-vars` output and silently dropped from the effective context; they now render and inject as their string equivalents (`"true"`, `"3"`), matching GitLab CI's own behaviour where all variable values are strings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **YAML `\/` escape in double-quoted strings** — regex patterns containing `\/` (escaped forward-slash) in double-quoted `if:` expressions (e.g. `$CI_COMMIT_BRANCH =~ /^us\//`) caused a YAML parse error (`found unknown escape character`) with `gopkg.in/yaml.v3`, which does not implement this YAML 1.2 escape. The parser now preprocesses the raw bytes before unmarshalling: inside double-quoted strings, `\/` is rewritten to `\\/`, which `yaml.v3` parses as a literal backslash followed by a slash — preserving the regex intent.
|
||||
|
||||
## [0.2.11] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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.2.11-blue.svg" alt="Release"></a>
|
||||
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.15-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.
|
||||
@@ -28,6 +28,12 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G
|
||||
- **Extended variable declarations** — `variables:` entries may use the `{value, description, options}` map form (GitLab CI 13.7+); `default.image` accepts both string and map form; `rules.changes`/`rules.exists` accept both list and `{paths, compare_to}` map form
|
||||
- **Graph output** — `glint graph` prints a job tree (stages → jobs) to the terminal; `glint graph includes` emits a Mermaid include dependency diagram; `glint graph pipeline` renders a GitLab CI-style PNG/SVG
|
||||
- **Context simulation** — pass `--branch`, `--tag`, or `--source` to `glint check` or `glint graph` to see which jobs would be active, manual, or skipped for a specific pipeline event; evaluates `rules:if:` expressions and `only`/`except` filters
|
||||
- **Variable expansion** — `$VAR` and `${VAR}` references inside variable values are expanded after all sources are merged (pipeline defaults → workflow-rule overrides → CLI flags); transitive chains resolve automatically; visible via `--list-vars`
|
||||
- **Non-string variable scalars** — `BUILD: true`, `RETRIES: 3` and other bare boolean/integer variable values are handled correctly throughout: they render in `--list-vars` output and are injected into the evaluation context as their string equivalents, matching GitLab CI's behaviour
|
||||
- **Static reachability (GL033)** — warns when a job's `rules:` block can never activate: if every rule has `when: never` the job is permanently excluded from any pipeline run, provable without evaluating any `if:` expressions
|
||||
- **Sorted findings output** — findings are sorted by source file then line number, so all issues from the same file appear together in order; pipeline-level findings (no file) sort first
|
||||
- **Consistent ruff-style warnings** — all warnings (unresolvable includes, skipped extends chains, workflow non-start) use the same `path: [warning] message` format as lint findings
|
||||
- **`--version` / `-v` flag** — prints the compiled version string (e.g. `glint v0.2.14`); the version is also shown at the top of every `--help` output
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for planned improvements.
|
||||
|
||||
@@ -200,6 +206,7 @@ glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
|
||||
**Evaluated:**
|
||||
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null`
|
||||
- `only:` / `except:` — ref keywords (`branches`, `tags`, `merge_requests`, `schedules`, …), branch name globs (`feat/*`), and `/regex/` patterns
|
||||
- Variable expansion — `$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; use `--list-vars` to inspect the resolved values
|
||||
|
||||
**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
|
||||
Rules without an `if:` clause always match.
|
||||
@@ -307,6 +314,7 @@ Every finding includes a stable rule ID (e.g. `GL003`) that can be used to filte
|
||||
| ID | Severity | Rule |
|
||||
|----|----------|------|
|
||||
| GL032 | WARNING | `rules:if:` references `$VAR` not declared in `variables:` (pipeline, job, or `workflow:rules:variables:`) — may be a false positive for variables set in GitLab CI/CD project settings |
|
||||
| GL033 | WARNING | Every rule in `rules:` has `when: never` — job is permanently excluded from the pipeline (statically provable without context) |
|
||||
|
||||
### Hidden jobs (templates)
|
||||
|
||||
|
||||
+18
-2
@@ -4,7 +4,7 @@ This document tracks planned improvements to `glint`. Items are grouped by theme
|
||||
|
||||
---
|
||||
|
||||
## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11
|
||||
## Context-aware validation — ✓ single-context shipped in v0.2.0; expression evaluator hardened post-v0.2.0; implicit defaults and --list-vars shipped v0.2.11; variable expansion and scalar handling shipped v0.2.13; workflow evaluation and output fixes shipped v0.2.14
|
||||
|
||||
Single-context simulation is fully implemented. Pass `--branch`, `--tag`, `--source`, or `--var` to either `glint check` or `glint graph`; jobs are evaluated and shown as active / manual / skipped.
|
||||
|
||||
@@ -30,6 +30,21 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
|
||||
|
||||
~~**`--list-vars` debug flag**~~ — ✓ shipped v0.2.11; prints sorted `KEY=VALUE` of all collected variables (pipeline YAML + included files + workflow-rule union + effective context) to stderr.
|
||||
|
||||
**Shipped in v0.2.13**
|
||||
|
||||
- ✓ **Variable expansion** — `$VAR` / `${VAR}` references within variable values are expanded after all sources are merged; transitive chains resolve over multiple passes; visible in `--list-vars` effective-context output.
|
||||
- ✓ **Non-string scalar variables** — `BUILD: true`, `RETRIES: 3` and similar bare boolean/integer values now render correctly in `--list-vars` and are injected into the evaluation context as string equivalents; previously shown as `(complex)` and silently dropped.
|
||||
- ✓ **YAML `\/` escape in double-quoted strings** — regex patterns like `/^us\//` in double-quoted `if:` blocks no longer cause a parse error; the raw bytes are preprocessed before YAML unmarshalling.
|
||||
|
||||
**Shipped in v0.2.14**
|
||||
|
||||
- ✓ **Workflow rule strict evaluation** — workflow `rules:if:` now uses strict mode (parse failure → skip rule, not match); fixes premature matching that blocked later rules and injected wrong variables.
|
||||
- ✓ **Single `=` operator** — `=` is now accepted as an alias for `==` in `rules:if:` expressions, matching common user intent.
|
||||
- ✓ **Source location through `extends:` resolution** — `File` and `Line` are now preserved when a job is rebuilt via extends, so findings reference the correct source location.
|
||||
- ✓ **Sorted findings output** — findings are sorted by `(File, Line, Rule)`; same-file issues group together in line order.
|
||||
- ✓ **Consistent warning format** — all warnings use ruff-style `path: [warning] message` format.
|
||||
- ✓ **`--version` / `-v` flag** — prints compiled version; version also shown at the top of every `--help` output.
|
||||
|
||||
**Remaining work**
|
||||
|
||||
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
|
||||
@@ -46,13 +61,14 @@ glint graph tree --branch main .gitlab-ci.yml # tree annotated with [skipped]
|
||||
The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice.
|
||||
|
||||
- ~~**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
|
||||
- ~~**`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
|
||||
- **`services:` validation** — map form requires `name`; `alias` must be a valid DNS label
|
||||
- **`rules:changes` / `rules:exists`** — warn on glob patterns that can never match (e.g. absolute paths, double `**` on unsupported versions)
|
||||
- **`timeout` format** — must be a duration string GitLab understands (`1h 30m`, `90 minutes`, etc.)
|
||||
- **`id_tokens:` / `secrets:`** — presence and required-key checks
|
||||
- **`pages:publish`** — validate that the path is consistent with `artifacts.paths`
|
||||
- **`inherit:` completeness** — flag when a job overrides a default field that would require `inherit: default: false` to suppress
|
||||
- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only, no variable expansion)
|
||||
- **Unreachable jobs** — detect jobs that can never run because every `rules:` branch evaluates to `never` (static analysis only)
|
||||
- **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion
|
||||
- **`cache:key:files`** — must be a list of paths, not a glob
|
||||
|
||||
|
||||
+7
-3
@@ -3,6 +3,8 @@ version: "3"
|
||||
vars:
|
||||
BINARY: glint
|
||||
GO: /usr/local/go/bin/go
|
||||
VERSION:
|
||||
sh: git describe --tags --always --dirty 2>/dev/null || echo "dev"
|
||||
|
||||
tasks:
|
||||
default:
|
||||
@@ -12,7 +14,7 @@ tasks:
|
||||
build:
|
||||
desc: Build the glint binary
|
||||
cmds:
|
||||
- "{{.GO}} build -o {{.BINARY}} ./cmd/glint/..."
|
||||
- "{{.GO}} build -ldflags \"-X main.version={{.VERSION}}\" -o {{.BINARY}} ./cmd/glint/..."
|
||||
sources:
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
@@ -79,6 +81,8 @@ tasks:
|
||||
ignore_error: false
|
||||
- cmd: ./{{.BINARY}} check testdata/variable_refs_included.yml
|
||||
ignore_error: false
|
||||
- cmd: ./{{.BINARY}} check testdata/dead_rules.yml
|
||||
ignore_error: false
|
||||
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci.yml
|
||||
ignore_error: false
|
||||
- cmd: ./{{.BINARY}} check testdata/samba/.gitlab-ci-coverage.yml
|
||||
@@ -112,7 +116,7 @@ tasks:
|
||||
- sh: git describe --tags --exact-match
|
||||
msg: "Current commit is not tagged — Windows build requires a git tag"
|
||||
cmds:
|
||||
- "GOOS=windows GOARCH=amd64 {{.GO}} build -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..."
|
||||
- "GOOS=windows GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}.exe ./cmd/glint/..."
|
||||
sources:
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
@@ -128,7 +132,7 @@ tasks:
|
||||
- sh: git describe --tags --exact-match
|
||||
msg: "Current commit is not tagged — Linux build requires a git tag"
|
||||
cmds:
|
||||
- "GOOS=linux GOARCH=amd64 {{.GO}} build -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..."
|
||||
- "GOOS=linux GOARCH=amd64 {{.GO}} build -ldflags \"-X main.version={{.TAG}}\" -o {{.BINARY}}-{{.TAG}}-linux-amd64 ./cmd/glint/..."
|
||||
sources:
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
|
||||
+21
-14
@@ -16,6 +16,9 @@ import (
|
||||
"git.k3nny.fr/glint/internal/resolver"
|
||||
)
|
||||
|
||||
// version is set at build time via -ldflags "-X main.version=vX.Y.Z".
|
||||
var version = "dev"
|
||||
|
||||
const globalUsage = `glint: Lint and visualise GitLab CI pipelines locally.
|
||||
|
||||
Usage: glint [OPTIONS] <COMMAND>
|
||||
@@ -26,6 +29,7 @@ Commands:
|
||||
|
||||
Options:
|
||||
-h, --help Print help
|
||||
-v, --version Print version
|
||||
|
||||
For help with a specific command, see: ` + "`glint <command> --help`" + `.
|
||||
`
|
||||
@@ -41,7 +45,10 @@ func main() {
|
||||
case "graph":
|
||||
cmdGraph(os.Args[2:])
|
||||
case "-h", "--help", "help":
|
||||
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
|
||||
fmt.Fprint(os.Stderr, globalUsage)
|
||||
case "-v", "--version", "version":
|
||||
fmt.Printf("glint %s\n", version)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "glint: unknown command %q\n\n%s", os.Args[1], globalUsage)
|
||||
os.Exit(2)
|
||||
@@ -68,6 +75,7 @@ func cmdCheck(args []string) {
|
||||
var vars multiFlag
|
||||
fs.Var(&vars, "var", "set a CI variable as KEY=VALUE; repeatable")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
|
||||
fmt.Fprint(os.Stderr, `Lint a GitLab CI pipeline file.
|
||||
|
||||
Resolves local includes and extends chains, then runs all lint rules.
|
||||
@@ -153,7 +161,7 @@ Examples:
|
||||
rootDir := filepath.Dir(filepath.Clean(path))
|
||||
warnings, _ := resolver.ResolveIncludes(p, cfg, rootDir)
|
||||
for _, w := range warnings {
|
||||
fmt.Fprintf(os.Stderr, "[WARNING] include %s\n", w)
|
||||
fmt.Fprintf(os.Stderr, "%s: [warning] include %s\n", path, w)
|
||||
}
|
||||
|
||||
extWarnings, err := resolver.Resolve(p)
|
||||
@@ -162,12 +170,14 @@ Examples:
|
||||
os.Exit(2)
|
||||
}
|
||||
for _, w := range extWarnings {
|
||||
fmt.Fprintf(os.Stderr, "[WARNING] job %q extends unknown job %q; extends chain skipped\n", w.Job, w.Base)
|
||||
fmt.Fprintf(os.Stderr, "%s: [warning] job %q extends unknown job %q; extends chain skipped\n", path, w.Job, w.Base)
|
||||
}
|
||||
|
||||
ctx := cicontext.New(*branch, *tag, *source, vars)
|
||||
if !ctx.IsEmpty() {
|
||||
enrichContext(ctx, p)
|
||||
if !enrichContext(ctx, p) {
|
||||
fmt.Fprintf(os.Stderr, "%s: [warning] workflow:rules: pipeline would not start for this context\n", path)
|
||||
}
|
||||
}
|
||||
if *listVars {
|
||||
printVars(p, ctx)
|
||||
@@ -219,6 +229,7 @@ func cmdGraph(args []string) {
|
||||
gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)")
|
||||
out := fs.String("out", "glint-out", "output directory for Mermaid graph files (pipeline mode)")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "glint %s\n\n", version)
|
||||
fmt.Fprint(os.Stderr, `Visualise the pipeline as a job tree and/or Mermaid graph.
|
||||
|
||||
Usage: glint graph [MODE] [OPTIONS] <PIPELINE>
|
||||
@@ -404,34 +415,30 @@ func printVarMap(m map[string]any) {
|
||||
}
|
||||
|
||||
func varValueString(v any) string {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case map[string]any:
|
||||
if s, ok := val["value"].(string); ok {
|
||||
if s, ok := cicontext.ScalarString(v); ok {
|
||||
return s
|
||||
}
|
||||
return "(complex)"
|
||||
}
|
||||
return "(complex)"
|
||||
}
|
||||
|
||||
// enrichContext injects pipeline-level variable defaults and then
|
||||
// workflow-rule-generated variables into ctx before job evaluation.
|
||||
// Injection respects pinned variables (--branch/--tag/--source/--var always win).
|
||||
func enrichContext(ctx *cicontext.Context, p *model.Pipeline) {
|
||||
// Returns false when workflow:rules: would prevent the pipeline from starting.
|
||||
func enrichContext(ctx *cicontext.Context, p *model.Pipeline) bool {
|
||||
// Pipeline variables: injected as defaults (lowest priority).
|
||||
for k, v := range cicontext.ExtractStringVars(p.Variables) {
|
||||
ctx.Inject(k, v)
|
||||
}
|
||||
// Workflow rules: evaluate to find which rule matches, then inject its variables.
|
||||
runs, ruleVars := cicontext.EvalWorkflow(p, ctx)
|
||||
if !runs {
|
||||
fmt.Fprintln(os.Stderr, "[WARNING] workflow:rules: pipeline would not start for this context")
|
||||
}
|
||||
for k, v := range ruleVars {
|
||||
ctx.Inject(k, v)
|
||||
}
|
||||
// Expand $VAR / ${VAR} references within variable values now that all
|
||||
// sources (pipeline, workflow rules, CLI) have been merged.
|
||||
ctx.ExpandVars()
|
||||
return runs
|
||||
}
|
||||
|
||||
func printContext(p *model.Pipeline, ctx *cicontext.Context) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package cicontext
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Context holds the simulated CI execution environment used for context-aware
|
||||
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
|
||||
@@ -117,26 +120,131 @@ func (c *Context) Summary() string {
|
||||
|
||||
// ExtractStringVars converts a map[string]any variable block (as used by
|
||||
// Pipeline.Variables and Rule.Variables) to a flat map[string]string.
|
||||
// Plain string values are used directly. Extended {value: "..."} map form
|
||||
// uses the "value" key. Other forms are skipped.
|
||||
// Plain string values are used directly. Extended {value: ...} map form uses
|
||||
// the "value" key. Scalar non-string values (bool, int, float64) are
|
||||
// converted to their string representation, matching GitLab's own behaviour
|
||||
// where all CI variable values are strings.
|
||||
func ExtractStringVars(m map[string]any) map[string]string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
out[k] = val
|
||||
case map[string]any:
|
||||
if s, ok := val["value"].(string); ok {
|
||||
if s, ok := ScalarString(v); ok {
|
||||
out[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ScalarString converts a YAML-decoded CI variable value to its string
|
||||
// representation. Handles plain scalars and the extended {value: ...} map
|
||||
// form. Returns (s, true) on success, ("", false) for unrecognised forms.
|
||||
func ScalarString(v any) (string, bool) {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val, true
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", val), true
|
||||
case int:
|
||||
return fmt.Sprintf("%d", val), true
|
||||
case float64:
|
||||
return fmt.Sprintf("%g", val), true
|
||||
case map[string]any:
|
||||
inner, ok := val["value"]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return ScalarString(inner)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ExpandVars expands $VAR and ${VAR} references within every value in
|
||||
// ctx.Vars, using the same map as the expansion source. Iteration repeats
|
||||
// (up to 10 passes) so transitive chains like A=$B, B=$C resolve fully.
|
||||
// Variables that form circular references are left as-is after the limit.
|
||||
func (c *Context) ExpandVars() {
|
||||
if c == nil || len(c.Vars) == 0 {
|
||||
return
|
||||
}
|
||||
for range 10 {
|
||||
changed := false
|
||||
for k, v := range c.Vars {
|
||||
expanded := expandVarRefs(v, c.Vars)
|
||||
if expanded != v {
|
||||
c.Vars[k] = expanded
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expandVarRefs replaces $VAR and ${VAR} occurrences in s with their values
|
||||
// from vars. Unknown variables are left unchanged.
|
||||
func expandVarRefs(s string, vars map[string]string) string {
|
||||
if !strings.Contains(s, "$") {
|
||||
return s
|
||||
}
|
||||
var sb strings.Builder
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] != '$' {
|
||||
sb.WriteByte(s[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
i++ // consume '$'
|
||||
if i >= len(s) {
|
||||
sb.WriteByte('$')
|
||||
break
|
||||
}
|
||||
if s[i] == '{' {
|
||||
i++ // consume '{'
|
||||
j := i
|
||||
for j < len(s) && isIdentByte(s[j]) {
|
||||
j++
|
||||
}
|
||||
if j < len(s) && s[j] == '}' {
|
||||
name := s[i:j]
|
||||
if val, ok := vars[name]; ok {
|
||||
sb.WriteString(val)
|
||||
} else {
|
||||
sb.WriteString("${")
|
||||
sb.WriteString(name)
|
||||
sb.WriteByte('}')
|
||||
}
|
||||
i = j + 1
|
||||
} else {
|
||||
// Malformed ${…} — emit literally
|
||||
sb.WriteString("${")
|
||||
i = j
|
||||
}
|
||||
} else {
|
||||
j := i
|
||||
for j < len(s) && isIdentByte(s[j]) {
|
||||
j++
|
||||
}
|
||||
if j > i {
|
||||
name := s[i:j]
|
||||
if val, ok := vars[name]; ok {
|
||||
sb.WriteString(val)
|
||||
} else {
|
||||
sb.WriteByte('$')
|
||||
sb.WriteString(name)
|
||||
}
|
||||
i = j
|
||||
} else {
|
||||
sb.WriteByte('$')
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// slugify converts a ref name to its GitLab slug form:
|
||||
// lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed.
|
||||
func slugify(s string) string {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// - Variable references: $VAR_NAME or ${VAR_NAME}
|
||||
// - String literals: "value" or 'value'
|
||||
// - Null keyword: null
|
||||
// - Comparison: == != =~ !~
|
||||
// - Comparison: == != =~ !~ (single = is accepted as == for user convenience)
|
||||
// - Boolean: && || !
|
||||
// - Grouping: ( )
|
||||
// - Regex flags: /pattern/i (case-insensitive), /pattern/m, /pattern/s
|
||||
@@ -23,10 +23,21 @@ import (
|
||||
// used by GitLab CI. Unsupported or unparseable expressions fall back to true
|
||||
// (permissive) so the linter never silently drops jobs it cannot evaluate.
|
||||
func EvalIf(expr string, vars func(string) string) bool {
|
||||
return evalIf(expr, vars, true)
|
||||
}
|
||||
|
||||
// EvalIfStrict is like EvalIf but returns false (instead of true) when the
|
||||
// expression cannot be fully parsed. Use for workflow:rules: evaluation where
|
||||
// a failed parse should skip to the next rule rather than matching everything.
|
||||
func EvalIfStrict(expr string, vars func(string) string) bool {
|
||||
return evalIf(expr, vars, false)
|
||||
}
|
||||
|
||||
func evalIf(expr string, vars func(string) string, permissive bool) bool {
|
||||
p := &exprParser{s: strings.TrimSpace(expr), vars: vars}
|
||||
result, ok := p.parseOr()
|
||||
if !ok || p.pos < len(p.s) {
|
||||
return true // unparseable → permissive
|
||||
return permissive
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -200,6 +211,17 @@ func (p *exprParser) parseComparison() (bool, bool) {
|
||||
return true, true // bad pattern → permissive
|
||||
}
|
||||
return !re.MatchString(leftStr), true
|
||||
|
||||
// Single = not followed by = or ~ — accepted as == (common user mistake;
|
||||
// GitLab CI only supports == but = is frequently written by accident).
|
||||
case p.peek() == '=' && !p.startsWith("==") && !p.startsWith("=~"):
|
||||
p.pos++ // consume '='
|
||||
p.skipWS()
|
||||
rightStr, ok := p.parseValue()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return leftStr == rightStr, true
|
||||
}
|
||||
|
||||
// No operator: variable is truthy when non-empty (defined and non-null).
|
||||
|
||||
@@ -120,6 +120,12 @@ func TestEvalIf(t *testing.T) {
|
||||
// ── Permissive fallback ───────────────────────────────────────────────
|
||||
{"unparseable returns true", `this is not valid syntax %%%`, true},
|
||||
{"empty expr returns true", ``, true},
|
||||
|
||||
// ── Single = as alias for == ──────────────────────────────────────────
|
||||
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
|
||||
{"single eq no match", `$CI_COMMIT_BRANCH = "main"`, false},
|
||||
{"single eq in compound", `$CI_COMMIT_BRANCH = "develop" && $CI_PIPELINE_SOURCE = "push"`, true},
|
||||
{"single eq compound false", `$CI_COMMIT_BRANCH = "main" && $CI_PIPELINE_SOURCE = "push"`, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -131,3 +137,48 @@ func TestEvalIf(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalIfStrict(t *testing.T) {
|
||||
vars := func(key string) string {
|
||||
m := map[string]string{
|
||||
"CI_COMMIT_BRANCH": "develop",
|
||||
"CI_PIPELINE_SOURCE": "push",
|
||||
"WORKFLOW": "",
|
||||
}
|
||||
return m[key]
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
want bool
|
||||
}{
|
||||
// Parseable expressions behave identically to EvalIf.
|
||||
{"parseable match", `$CI_COMMIT_BRANCH == "develop"`, true},
|
||||
{"parseable no match", `$CI_COMMIT_BRANCH == "main"`, false},
|
||||
{"single eq match", `$CI_COMMIT_BRANCH = "develop"`, true},
|
||||
// Empty expression: ruleIfMatchesStrict handles the empty→true case
|
||||
// before calling EvalIfStrict, so empty falls through to false here.
|
||||
{"empty expr", ``, false},
|
||||
|
||||
// Unparseable expressions return false (strict) instead of true (permissive).
|
||||
{"unparseable returns false", `this is not valid syntax %%%`, false},
|
||||
|
||||
// The key workflow-rule scenario: a complex condition with an
|
||||
// unevaluable sub-expression should not match (strict=false) so that
|
||||
// later workflow rules can be evaluated.
|
||||
{"workflow rule complex no match", `$WORKFLOW = "gitflow" && $CI_PIPELINE_SOURCE == /(push|web)/`, false},
|
||||
|
||||
// Compound with a bad second operand: strict returns false.
|
||||
{"and with bad rhs strict false", `$CI_COMMIT_BRANCH == "develop" && !(((`, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := EvalIfStrict(tc.expr, vars)
|
||||
if got != tc.want {
|
||||
t.Errorf("EvalIfStrict(%q) = %v, want %v", tc.expr, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
|
||||
}
|
||||
vars := ctx.Get
|
||||
for _, rule := range p.Workflow.Rules {
|
||||
if !ruleIfMatches(rule.If, vars) {
|
||||
// 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) {
|
||||
continue
|
||||
}
|
||||
when := rule.When
|
||||
@@ -94,6 +98,13 @@ func ruleIfMatches(ifExpr string, vars func(string) string) bool {
|
||||
return EvalIf(ifExpr, vars)
|
||||
}
|
||||
|
||||
func ruleIfMatchesStrict(ifExpr string, vars func(string) string) bool {
|
||||
if ifExpr == "" {
|
||||
return true // no if: condition → rule always matches
|
||||
}
|
||||
return EvalIfStrict(ifExpr, vars)
|
||||
}
|
||||
|
||||
func whenToState(when string) JobState {
|
||||
switch when {
|
||||
case "never":
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
func TestCheckDeadRules(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rules []model.Rule
|
||||
wantHit bool // whether GL033 should fire
|
||||
}{
|
||||
{
|
||||
name: "no rules — not dead",
|
||||
rules: nil,
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "single bare when:never — dead",
|
||||
rules: []model.Rule{{When: "never"}},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "all rules when:never with if — dead",
|
||||
rules: []model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
|
||||
{If: `$CI_COMMIT_BRANCH == "develop"`, When: "never"},
|
||||
{When: "never"},
|
||||
},
|
||||
wantHit: true,
|
||||
},
|
||||
{
|
||||
name: "first rule on_success — not dead",
|
||||
rules: []model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "main"`, When: "on_success"},
|
||||
{When: "never"},
|
||||
},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "rule with empty when (defaults to on_success) — not dead",
|
||||
rules: []model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "main"`},
|
||||
{When: "never"},
|
||||
},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "when:manual — not dead",
|
||||
rules: []model.Rule{{When: "manual"}},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "when:always — not dead",
|
||||
rules: []model.Rule{{When: "always"}},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "when:on_failure — not dead",
|
||||
rules: []model.Rule{{When: "on_failure"}},
|
||||
wantHit: false,
|
||||
},
|
||||
{
|
||||
name: "mixed never and manual — not dead",
|
||||
rules: []model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
|
||||
{When: "manual"},
|
||||
},
|
||||
wantHit: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
job := model.Job{Rules: tc.rules}
|
||||
findings := checkDeadRules("test-job", job)
|
||||
hit := false
|
||||
for _, f := range findings {
|
||||
if f.Rule == RuleDeadRules {
|
||||
hit = true
|
||||
}
|
||||
}
|
||||
if hit != tc.wantHit {
|
||||
t.Errorf("checkDeadRules: got hit=%v, want hit=%v; findings=%v", hit, tc.wantHit, findings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@ func checkJobKeywords(name string, job model.Job) []Finding {
|
||||
findings = append(findings, checkArtifacts(name, job)...)
|
||||
findings = append(findings, checkCache(name, job)...)
|
||||
findings = append(findings, checkRules(name, job)...)
|
||||
findings = append(findings, checkDeadRules(name, job)...)
|
||||
findings = append(findings, checkImage(name, job)...)
|
||||
findings = append(findings, checkInherit(name, job)...)
|
||||
return findings
|
||||
@@ -476,6 +477,28 @@ func checkRules(name string, job model.Job) []Finding {
|
||||
return findings
|
||||
}
|
||||
|
||||
// checkDeadRules reports when every rule in a job's rules: block has an
|
||||
// explicit when: never, making the job permanently unreachable. This is a
|
||||
// provably-correct static claim: no matter which if: condition matches, the
|
||||
// outcome is always "never"; and if no rule matches, the implicit fallback is
|
||||
// also skip. No if: evaluation is required.
|
||||
func checkDeadRules(name string, job model.Job) []Finding {
|
||||
if len(job.Rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, r := range job.Rules {
|
||||
if r.When != "never" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return []Finding{{
|
||||
Severity: Warning,
|
||||
Rule: RuleDeadRules,
|
||||
Job: name,
|
||||
Message: "rules: block can never activate; every rule has 'when: never' — job is permanently excluded from the pipeline",
|
||||
}}
|
||||
}
|
||||
|
||||
func checkImage(name string, job model.Job) []Finding {
|
||||
if job.Image == nil {
|
||||
return nil
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
@@ -48,7 +50,8 @@ func (f Finding) String() string {
|
||||
return fmt.Sprintf("%s%s%s %s", loc, rule, sev, msg)
|
||||
}
|
||||
|
||||
// Lint runs all rules against p and returns findings sorted by job name.
|
||||
// Lint runs all rules against p and returns findings sorted by (File, Line, Rule).
|
||||
// Findings with no File (pipeline-level) sort before file-scoped ones.
|
||||
func Lint(p *model.Pipeline) []Finding {
|
||||
var findings []Finding
|
||||
findings = append(findings, checkStages(p)...)
|
||||
@@ -57,6 +60,15 @@ func Lint(p *model.Pipeline) []Finding {
|
||||
findings = append(findings, checkNeeds(p)...)
|
||||
findings = append(findings, checkDependencies(p)...)
|
||||
findings = append(findings, checkVariableRefs(p)...)
|
||||
slices.SortStableFunc(findings, func(a, b Finding) int {
|
||||
if c := cmp.Compare(a.File, b.File); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Line, b.Line); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(a.Rule, b.Rule)
|
||||
})
|
||||
return findings
|
||||
}
|
||||
|
||||
|
||||
@@ -114,4 +114,8 @@ const (
|
||||
// the job's own variables:, or any workflow:rules:variables: block.
|
||||
// May be a false positive for variables set in GitLab CI/CD project settings.
|
||||
RuleUndeclaredVariable = "GL032"
|
||||
|
||||
// GL033: every rule in a job's rules: block has when: never, so the job
|
||||
// can never be included in any pipeline run.
|
||||
RuleDeadRules = "GL033"
|
||||
)
|
||||
|
||||
@@ -80,6 +80,11 @@ func Resolve(p *model.Pipeline) ([]ExtendWarning, error) {
|
||||
return extWarnings, fmt.Errorf("job %q: re-decoding merged definition: %w", name, err)
|
||||
}
|
||||
j.Name = name
|
||||
// Preserve source location — File/Line are not part of the YAML map
|
||||
// and are lost during the encode/decode round-trip.
|
||||
orig := p.Jobs[name]
|
||||
j.File = orig.File
|
||||
j.Line = orig.Line
|
||||
p.Jobs[name] = j
|
||||
}
|
||||
|
||||
|
||||
Vendored
+59
@@ -0,0 +1,59 @@
|
||||
---
|
||||
# dead_rules.yml
|
||||
# Exercises GL033: rules: block where every rule has when: never.
|
||||
# All flagged jobs produce a WARNING (exit 0). Valid jobs must not be flagged.
|
||||
|
||||
stages:
|
||||
- build
|
||||
- test
|
||||
- deploy
|
||||
|
||||
# ── Jobs that should trigger GL033 ─────────────────────────────────────────
|
||||
|
||||
# Single bare catch-all never — job is always excluded.
|
||||
disabled-job:
|
||||
stage: build
|
||||
script: echo disabled
|
||||
rules:
|
||||
- when: never
|
||||
|
||||
# Multiple rules, all when: never — no branch can activate this job.
|
||||
dead-multi:
|
||||
stage: test
|
||||
script: echo dead
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
when: never
|
||||
- if: '$CI_COMMIT_BRANCH == "develop"'
|
||||
when: never
|
||||
- when: never
|
||||
|
||||
# ── Jobs that must NOT trigger GL033 ───────────────────────────────────────
|
||||
|
||||
# First rule can activate.
|
||||
main-only:
|
||||
stage: build
|
||||
script: echo main
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
when: on_success
|
||||
- when: never
|
||||
|
||||
# Rule with no when: — defaults to on_success, so job is reachable.
|
||||
implicit-on-success:
|
||||
stage: test
|
||||
script: echo implicit
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "develop"'
|
||||
|
||||
# Manual is not never — job is reachable (just gated).
|
||||
manual-gate:
|
||||
stage: deploy
|
||||
script: echo deploy
|
||||
rules:
|
||||
- when: manual
|
||||
|
||||
# No rules at all — always active.
|
||||
always-active:
|
||||
stage: build
|
||||
script: echo always
|
||||
Vendored
+13
-7
@@ -4,10 +4,10 @@
|
||||
# The matching workflow rule sets DEPLOY_TARGET; job rules use it.
|
||||
#
|
||||
# Expected behaviour per context:
|
||||
# (no context) → all jobs active (no context evaluation)
|
||||
# --branch main → deploy-prod active, deploy-staging skipped
|
||||
# --branch develop → deploy-prod skipped, deploy-staging active
|
||||
# --branch feat/x → deploy-prod skipped, deploy-staging skipped (manual)
|
||||
# --branch main → only build active (no workflow rule matches; WORKFLOW var not set)
|
||||
# --branch develop → deploy-staging active, deploy-prod skipped
|
||||
# --branch feat/x → only build active (when: always fallback, no deploy vars)
|
||||
# --var WORKFLOW=gitflow --branch us/feature → deploy-prod + build active
|
||||
|
||||
stages:
|
||||
- build
|
||||
@@ -20,9 +20,15 @@ variables:
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
- if: "
|
||||
$WORKFLOW = 'gitflow' &&
|
||||
$CI_PIPELINE_SOURCE == /(push|web)/ &&
|
||||
$CI_COMMIT_BRANCH =~ /^us\//
|
||||
"
|
||||
variables:
|
||||
DEPLOY_TARGET: production
|
||||
DEPLOY: true
|
||||
BUILD: true
|
||||
- if: '$CI_COMMIT_BRANCH == "develop"'
|
||||
variables:
|
||||
DEPLOY_TARGET: staging
|
||||
@@ -38,7 +44,7 @@ deploy-prod:
|
||||
stage: deploy
|
||||
script: make deploy ENV=production
|
||||
rules:
|
||||
- if: '$DEPLOY_TARGET == "production"'
|
||||
- if: '$DEPLOY_TARGET == "production" && $DEPLOY == "true"'
|
||||
when: on_success
|
||||
- when: never
|
||||
|
||||
@@ -46,6 +52,6 @@ deploy-staging:
|
||||
stage: deploy
|
||||
script: make deploy ENV=staging
|
||||
rules:
|
||||
- if: '$DEPLOY_TARGET == "staging"'
|
||||
- if: '$DEPLOY_TARGET == "staging" '
|
||||
when: on_success
|
||||
- when: never
|
||||
|
||||
Reference in New Issue
Block a user