feat(linter): add GL046-GL049 rules, AND-group support, and GL032 fix
ci / vet, staticcheck, test, build (push) Successful in 8m44s
release / Build and publish release (push) Successful in 9m52s

- GL046: validate image/service pull_policy values (always, if-not-present, never)
- GL047: error when a variables.options default value is not in the options list
- GL048: error on unrecognised trigger.forward keys
- GL049: validate rules[n].allow_failure (bool or {exit_codes:} map)
- Parse and evaluate workflow.rules/job.rules nested-array AND-groups; crash
  on !!seq nodes is fixed; all members of a group must match for it to fire
- Add workflow.name and workflow.auto_cancel fields to Workflow struct
- Fix GL032 false positive: variables declared in any workflow rule's variables:
  block no longer trigger an undeclared-variable warning in sibling workflow
  rule if: expressions
- Add Windows ARM64 release build target (task build-windows-arm64)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 22:44:22 +02:00
co-authored by Claude Sonnet 4.6
parent 3eea496b9f
commit 986162d270
16 changed files with 497 additions and 28 deletions
+40 -17
View File
@@ -42,22 +42,18 @@ func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
return true, nil
}
vars := ctx.Get
for _, rule := range p.Workflow.Rules {
// 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) {
// Workflow rules use strict evaluation: an unparseable condition is treated
// as no-match so later rules are reached. Permissive-true would cause an
// early rule with a complex condition to block all subsequent rules.
for _, group := range p.Workflow.Rules.Groups() {
matched, when, groupVars := evalRuleGroup(group, vars, ctx, true)
if !matched {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
when := rule.When
if when == "" {
when = "always"
}
return when != "never", ExtractStringVars(rule.Variables)
return when != "never", groupVars
}
return false, nil // no rule matched → pipeline does not run
}
@@ -74,14 +70,12 @@ func EvalJob(job model.Job, ctx *Context) JobState {
// rules: takes priority over only/except.
if len(job.Rules) > 0 {
for _, rule := range job.Rules {
if !ruleIfMatches(rule.If, vars) {
for _, group := range job.Rules.Groups() {
matched, when, _ := evalRuleGroup(group, vars, ctx, false)
if !matched {
continue
}
if !changesMatch(rule.Changes, ctx) {
continue
}
return whenToState(rule.When)
return whenToState(when)
}
return JobSkipped // no rule matched → job is excluded
}
@@ -98,6 +92,35 @@ func EvalJob(job model.Job, ctx *Context) JobState {
// ── Helpers ───────────────────────────────────────────────────────────────────
// evalRuleGroup evaluates one rule group (one or more rules forming an AND-group).
// All conditions in the group must match for the group to fire.
// Returns (matched, effectiveWhen, mergedVars). When strict=true, unparseable
// if: expressions count as no-match; when false, they count as match (permissive).
func evalRuleGroup(group []model.Rule, vars func(string) string, ctx *Context, strict bool) (bool, string, map[string]string) {
for _, rule := range group {
var ifOk bool
if strict {
ifOk = ruleIfMatchesStrict(rule.If, vars)
} else {
ifOk = ruleIfMatches(rule.If, vars)
}
if !ifOk || !changesMatch(rule.Changes, ctx) {
return false, "", nil
}
}
merged := make(map[string]string)
effectiveWhen := ""
for _, rule := range group {
for k, v := range ExtractStringVars(rule.Variables) {
merged[k] = v
}
if rule.When != "" {
effectiveWhen = rule.When
}
}
return true, effectiveWhen, merged
}
func ruleIfMatches(ifExpr string, vars func(string) string) bool {
if ifExpr == "" {
return true // no if: condition → rule always matches