Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbed44b1e9 |
@@ -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/).
|
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.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
|
## [0.2.11] - 2026-06-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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.2.11-blue.svg" alt="Release"></a>
|
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/release-v0.2.13-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.
|
||||||
@@ -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
|
- **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
|
- **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
|
- **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.
|
See [ROADMAP.md](ROADMAP.md) for planned improvements.
|
||||||
|
|
||||||
@@ -200,6 +202,7 @@ glint check --branch main --var DEPLOY_ENV=production .gitlab-ci.yml
|
|||||||
**Evaluated:**
|
**Evaluated:**
|
||||||
- `rules:if:` — full expression language: `==`, `!=`, `=~`, `!~`, `&&`, `||`, `!`, `()`, `$VAR`, string literals, `null`
|
- `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
|
- `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:`.
|
**Not evaluated** (no git tree at lint time): `rules:changes:`, `rules:exists:`.
|
||||||
Rules without an `if:` clause always match.
|
Rules without an `if:` clause always match.
|
||||||
|
|||||||
+8
-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
|
||||||
|
|
||||||
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.
|
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.
|
~~**`--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**
|
**Remaining work**
|
||||||
|
|
||||||
- **Multi-context simulation** — run multiple contexts in one invocation and print a comparison table:
|
- **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
|
- **`id_tokens:` / `secrets:`** — presence and required-key checks
|
||||||
- **`pages:publish`** — validate that the path is consistent with `artifacts.paths`
|
- **`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
|
- **`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
|
- **Duplicate stage names** — GitLab silently merges them; warn to avoid confusion
|
||||||
- **`cache:key:files`** — must be a list of paths, not a glob
|
- **`cache:key:files`** — must be a list of paths, not a glob
|
||||||
|
|
||||||
|
|||||||
+4
-7
@@ -404,17 +404,11 @@ func printVarMap(m map[string]any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func varValueString(v any) string {
|
func varValueString(v any) string {
|
||||||
switch val := v.(type) {
|
if s, ok := cicontext.ScalarString(v); ok {
|
||||||
case string:
|
|
||||||
return val
|
|
||||||
case map[string]any:
|
|
||||||
if s, ok := val["value"].(string); ok {
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return "(complex)"
|
return "(complex)"
|
||||||
}
|
}
|
||||||
return "(complex)"
|
|
||||||
}
|
|
||||||
|
|
||||||
// enrichContext injects pipeline-level variable defaults and then
|
// enrichContext injects pipeline-level variable defaults and then
|
||||||
// workflow-rule-generated variables into ctx before job evaluation.
|
// workflow-rule-generated variables into ctx before job evaluation.
|
||||||
@@ -432,6 +426,9 @@ func enrichContext(ctx *cicontext.Context, p *model.Pipeline) {
|
|||||||
for k, v := range ruleVars {
|
for k, v := range ruleVars {
|
||||||
ctx.Inject(k, v)
|
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) {
|
func printContext(p *model.Pipeline, ctx *cicontext.Context) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package cicontext
|
package cicontext
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// Context holds the simulated CI execution environment used for context-aware
|
// Context holds the simulated CI execution environment used for context-aware
|
||||||
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
|
// 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
|
// ExtractStringVars converts a map[string]any variable block (as used by
|
||||||
// Pipeline.Variables and Rule.Variables) to a flat map[string]string.
|
// Pipeline.Variables and Rule.Variables) to a flat map[string]string.
|
||||||
// Plain string values are used directly. Extended {value: "..."} map form
|
// Plain string values are used directly. Extended {value: ...} map form uses
|
||||||
// uses the "value" key. Other forms are skipped.
|
// 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 {
|
func ExtractStringVars(m map[string]any) map[string]string {
|
||||||
if len(m) == 0 {
|
if len(m) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make(map[string]string, len(m))
|
out := make(map[string]string, len(m))
|
||||||
for k, v := range m {
|
for k, v := range m {
|
||||||
switch val := v.(type) {
|
if s, ok := ScalarString(v); ok {
|
||||||
case string:
|
|
||||||
out[k] = val
|
|
||||||
case map[string]any:
|
|
||||||
if s, ok := val["value"].(string); ok {
|
|
||||||
out[k] = s
|
out[k] = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return out
|
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:
|
// slugify converts a ref name to its GitLab slug form:
|
||||||
// lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed.
|
// lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed.
|
||||||
func slugify(s string) string {
|
func slugify(s string) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user