diff --git a/CHANGELOG.md b/CHANGELOG.md index 1017253..a0fa733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.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 diff --git a/README.md b/README.md index b915f56..c33da7a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
> **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,8 @@ 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 See [ROADMAP.md](ROADMAP.md) for planned improvements. @@ -200,6 +202,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. diff --git a/ROADMAP.md b/ROADMAP.md index bd845e3..684f540 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 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,12 @@ 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. + **Remaining work** - **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table: @@ -52,7 +58,7 @@ The current rule set covers the most common sources of broken pipelines. These a - **`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 diff --git a/cmd/glint/main.go b/cmd/glint/main.go index 80c042b..9f1538f 100644 --- a/cmd/glint/main.go +++ b/cmd/glint/main.go @@ -404,14 +404,8 @@ 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 { - return s - } - return "(complex)" + if s, ok := cicontext.ScalarString(v); ok { + return s } return "(complex)" } @@ -432,6 +426,9 @@ func enrichContext(ctx *cicontext.Context, p *model.Pipeline) { 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() } func printContext(p *model.Pipeline, ctx *cicontext.Context) { diff --git a/internal/cicontext/context.go b/internal/cicontext/context.go index 8522b86..7d8b9ed 100644 --- a/internal/cicontext/context.go +++ b/internal/cicontext/context.go @@ -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 { - out[k] = s - } + 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 {