feat(cli): variable expansion, scalar bool/int support, precedence fix
ci / vet, staticcheck, test, build (push) Successful in 2m15s
release / Build and publish release (push) Successful in 1m13s

- Add $VAR / ${VAR} expansion in effective context (ctx.ExpandVars):
  iterates up to 10 passes to resolve transitive chains; circular
  references are left as-is after the limit.
- Handle non-string YAML scalars (bool, int, float64) in
  ExtractStringVars and varValueString via new ScalarString helper;
  values like BUILD: true no longer render as "(complex)" or get
  silently dropped from the effective context.
- Variable precedence (GitLab spec): pipeline defaults < workflow-rule
  vars < CLI --var flags; implemented correctly in enrichContext;
  expansion applied after all sources are merged.
- Update README, CHANGELOG, ROADMAP for v0.2.13.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:32:47 +02:00
parent 1339ab4149
commit cbed44b1e9
5 changed files with 147 additions and 21 deletions
+118 -10
View File
@@ -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 {