feat(linter): propagate workflow:rules:variables: into job rule evaluation
workflow:rules: can define variables: on matching rules (GitLab CI 15.0+).
These variables are now injected into the evaluation context before job
rules:if: expressions are evaluated, making patterns like:
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
variables:
DEPLOY_TARGET: production
deploy:
rules:
- if: '$DEPLOY_TARGET == "production"'
work correctly with glint check --branch main.
Changes:
- model.Rule: add Variables map[string]any field (yaml:"variables")
- cicontext.Context: add pinned map tracking which vars must not be
overwritten; New() pins all shortcut and --var variables; add
Inject(key, value) which writes only when key is not pinned
- cicontext.ExtractStringVars: shared helper that converts map[string]any
variable blocks (plain string or {value:...} form) to map[string]string
- cicontext.EvalWorkflow: returns (bool, map[string]string) — the vars of
the matching workflow rule alongside the runs/no-runs result
- cmd/glint/main.go: enrichContext() injects pipeline-level variable
defaults then workflow-rule variables before printContext; applied in
both cmdCheck and cmdGraph
Injection priority (highest wins):
--var CLI overrides > --branch/--tag/--source shortcuts
> workflow-rule variables > pipeline variables: defaults
Adds 15 unit tests (TestEvalWorkflow, TestContextInject,
TestExtractStringVars, TestWorkflowVarsJobEval) and a testdata fixture
(workflow_vars.yml) validated across four branch contexts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
package cicontext
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
func TestEvalWorkflow(t *testing.T) {
|
||||
makePipeline := func(rules []model.Rule) *model.Pipeline {
|
||||
return &model.Pipeline{Workflow: &model.Workflow{Rules: rules}}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []model.Rule
|
||||
branch string
|
||||
wantRuns bool
|
||||
wantVars map[string]string
|
||||
}{
|
||||
{
|
||||
name: "no workflow block",
|
||||
rules: nil,
|
||||
branch: "main",
|
||||
wantRuns: true,
|
||||
wantVars: nil,
|
||||
},
|
||||
{
|
||||
name: "matching rule runs always",
|
||||
rules: []model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "main"`, When: "always"},
|
||||
},
|
||||
branch: "main",
|
||||
wantRuns: true,
|
||||
},
|
||||
{
|
||||
name: "matching rule when never",
|
||||
rules: []model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
|
||||
},
|
||||
branch: "main",
|
||||
wantRuns: false,
|
||||
},
|
||||
{
|
||||
name: "no rule matched",
|
||||
rules: []model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "main"`},
|
||||
},
|
||||
branch: "develop",
|
||||
wantRuns: false,
|
||||
},
|
||||
{
|
||||
name: "matching rule with variables",
|
||||
rules: []model.Rule{
|
||||
{
|
||||
If: `$CI_COMMIT_BRANCH == "main"`,
|
||||
When: "always",
|
||||
Variables: map[string]any{
|
||||
"DEPLOY_TARGET": "production",
|
||||
"ENVIRONMENT": "prod",
|
||||
},
|
||||
},
|
||||
{When: "always"},
|
||||
},
|
||||
branch: "main",
|
||||
wantRuns: true,
|
||||
wantVars: map[string]string{
|
||||
"DEPLOY_TARGET": "production",
|
||||
"ENVIRONMENT": "prod",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fallback rule with different variables",
|
||||
rules: []model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "main"`, Variables: map[string]any{"DEPLOY_TARGET": "production"}},
|
||||
{When: "always", Variables: map[string]any{"DEPLOY_TARGET": "staging"}},
|
||||
},
|
||||
branch: "develop",
|
||||
wantRuns: true,
|
||||
wantVars: map[string]string{"DEPLOY_TARGET": "staging"},
|
||||
},
|
||||
{
|
||||
name: "empty context always runs",
|
||||
rules: []model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "main"`, When: "never"},
|
||||
},
|
||||
branch: "", // no context
|
||||
wantRuns: true,
|
||||
wantVars: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var p *model.Pipeline
|
||||
if tc.rules == nil {
|
||||
p = &model.Pipeline{}
|
||||
} else {
|
||||
p = makePipeline(tc.rules)
|
||||
}
|
||||
ctx := New(tc.branch, "", "", nil)
|
||||
runs, vars := EvalWorkflow(p, ctx)
|
||||
if runs != tc.wantRuns {
|
||||
t.Errorf("EvalWorkflow runs = %v, want %v", runs, tc.wantRuns)
|
||||
}
|
||||
for k, want := range tc.wantVars {
|
||||
if got := vars[k]; got != want {
|
||||
t.Errorf("EvalWorkflow vars[%q] = %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
if len(vars) != len(tc.wantVars) {
|
||||
t.Errorf("EvalWorkflow returned %d vars, want %d; got %v", len(vars), len(tc.wantVars), vars)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextInject(t *testing.T) {
|
||||
t.Run("inject does not overwrite pinned var", func(t *testing.T) {
|
||||
ctx := New("main", "", "", []string{"DEPLOY_TARGET=override"})
|
||||
ctx.Inject("DEPLOY_TARGET", "workflow-value")
|
||||
if got := ctx.Get("DEPLOY_TARGET"); got != "override" {
|
||||
t.Errorf("Inject overwrote pinned var: got %q, want %q", got, "override")
|
||||
}
|
||||
})
|
||||
t.Run("inject does not overwrite shortcut var", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.Inject("CI_COMMIT_BRANCH", "other")
|
||||
if got := ctx.Get("CI_COMMIT_BRANCH"); got != "main" {
|
||||
t.Errorf("Inject overwrote shortcut var: got %q, want %q", got, "main")
|
||||
}
|
||||
})
|
||||
t.Run("inject sets new variable", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.Inject("DEPLOY_TARGET", "production")
|
||||
if got := ctx.Get("DEPLOY_TARGET"); got != "production" {
|
||||
t.Errorf("Inject did not set variable: got %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("inject later call overrides earlier call", func(t *testing.T) {
|
||||
ctx := New("main", "", "", nil)
|
||||
ctx.Inject("DEPLOY_TARGET", "pipeline-default")
|
||||
ctx.Inject("DEPLOY_TARGET", "workflow-override")
|
||||
if got := ctx.Get("DEPLOY_TARGET"); got != "workflow-override" {
|
||||
t.Errorf("second Inject did not win: got %q, want %q", got, "workflow-override")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractStringVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in map[string]any
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "plain strings",
|
||||
in: map[string]any{"A": "hello", "B": "world"},
|
||||
want: map[string]string{"A": "hello", "B": "world"},
|
||||
},
|
||||
{
|
||||
name: "extended value form",
|
||||
in: map[string]any{
|
||||
"KEY": map[string]any{"value": "extended", "description": "some desc"},
|
||||
},
|
||||
want: map[string]string{"KEY": "extended"},
|
||||
},
|
||||
{
|
||||
name: "mixed forms",
|
||||
in: map[string]any{
|
||||
"PLAIN": "str",
|
||||
"COMPLEX": map[string]any{"value": "val"},
|
||||
},
|
||||
want: map[string]string{"PLAIN": "str", "COMPLEX": "val"},
|
||||
},
|
||||
{
|
||||
name: "nil map",
|
||||
in: nil,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ExtractStringVars(tc.in)
|
||||
for k, want := range tc.want {
|
||||
if got[k] != want {
|
||||
t.Errorf("ExtractStringVars[%q] = %q, want %q", k, got[k], want)
|
||||
}
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Errorf("ExtractStringVars returned %d entries, want %d", len(got), len(tc.want))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowVarsJobEval verifies the end-to-end flow: workflow rule injects
|
||||
// DEPLOY_TARGET, which is then used in a job's rules:if: expression.
|
||||
func TestWorkflowVarsJobEval(t *testing.T) {
|
||||
rules := []model.Rule{
|
||||
{If: `$CI_COMMIT_BRANCH == "main"`, Variables: map[string]any{"DEPLOY_TARGET": "production"}},
|
||||
{When: "always", Variables: map[string]any{"DEPLOY_TARGET": "staging"}},
|
||||
}
|
||||
p := &model.Pipeline{
|
||||
Workflow: &model.Workflow{Rules: rules},
|
||||
Jobs: map[string]model.Job{
|
||||
"deploy-prod": {
|
||||
Rules: []model.Rule{
|
||||
{If: `$DEPLOY_TARGET == "production"`, When: "on_success"},
|
||||
{When: "never"},
|
||||
},
|
||||
},
|
||||
"deploy-staging": {
|
||||
Rules: []model.Rule{
|
||||
{If: `$DEPLOY_TARGET == "staging"`, When: "on_success"},
|
||||
{When: "never"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
branch string
|
||||
wantProd JobState
|
||||
wantStaging JobState
|
||||
}{
|
||||
{"main", JobActive, JobSkipped},
|
||||
{"develop", JobSkipped, JobActive},
|
||||
} {
|
||||
ctx := New(tc.branch, "", "", nil)
|
||||
_, ruleVars := EvalWorkflow(p, ctx)
|
||||
for k, v := range ruleVars {
|
||||
ctx.Inject(k, v)
|
||||
}
|
||||
if got := EvalJob(p.Jobs["deploy-prod"], ctx); got != tc.wantProd {
|
||||
t.Errorf("branch=%q deploy-prod = %v, want %v", tc.branch, got, tc.wantProd)
|
||||
}
|
||||
if got := EvalJob(p.Jobs["deploy-staging"], ctx); got != tc.wantStaging {
|
||||
t.Errorf("branch=%q deploy-staging = %v, want %v", tc.branch, got, tc.wantStaging)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user