Variables set by an earlier AND-group member's variables: block are now injected into the evaluation context before testing later members' if: expressions. This matches GitLab CI behaviour where e.g. element 0 sets DEPLOY=true and element 1 can then check $DEPLOY without requiring the caller to inject that variable manually. Previously evalRuleGroup passed the same base vars closure to every member, so cascaded variables were never visible during if: evaluation (only in the merged output returned after the group fired). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
393 lines
10 KiB
Go
393 lines
10 KiB
Go
package cicontext
|
|
|
|
import (
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.k3nny.fr/glint/internal/model"
|
|
)
|
|
|
|
// JobState describes whether a job would be included in a pipeline run for the
|
|
// given context.
|
|
type JobState int
|
|
|
|
const (
|
|
JobActive JobState = iota // job will run (on_success, always, delayed, …)
|
|
JobManual // job is gated behind a manual trigger
|
|
JobSkipped // job is excluded from the pipeline (when:never or no rule matched)
|
|
)
|
|
|
|
func (s JobState) String() string {
|
|
switch s {
|
|
case JobActive:
|
|
return "active"
|
|
case JobManual:
|
|
return "manual"
|
|
default:
|
|
return "skipped"
|
|
}
|
|
}
|
|
|
|
// EvalWorkflow evaluates the pipeline's workflow:rules block against ctx.
|
|
// Returns (runs, ruleVars):
|
|
// - runs=false means the pipeline would not start for this context.
|
|
// - ruleVars holds any variables: defined on the matching rule; inject these
|
|
// into the context so job rules can reference them.
|
|
//
|
|
// Returns (true, nil) when ctx is empty, when there is no workflow block, or
|
|
// when no rules are configured.
|
|
func EvalWorkflow(p *model.Pipeline, ctx *Context) (bool, map[string]string) {
|
|
if ctx.IsEmpty() || p.Workflow == nil || len(p.Workflow.Rules) == 0 {
|
|
return true, nil
|
|
}
|
|
vars := ctx.Get
|
|
// 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 when == "" {
|
|
when = "always"
|
|
}
|
|
return when != "never", groupVars
|
|
}
|
|
return false, nil // no rule matched → pipeline does not run
|
|
}
|
|
|
|
// EvalJob returns the effective JobState for job in the given context.
|
|
// When ctx is empty every job is treated as Active (preserves existing
|
|
// behaviour when no context flags are given).
|
|
func EvalJob(job model.Job, ctx *Context) JobState {
|
|
if ctx.IsEmpty() {
|
|
return JobActive
|
|
}
|
|
|
|
vars := ctx.Get
|
|
|
|
// rules: takes priority over only/except.
|
|
if len(job.Rules) > 0 {
|
|
for _, group := range job.Rules.Groups() {
|
|
matched, when, _ := evalRuleGroup(group, vars, ctx, false)
|
|
if !matched {
|
|
continue
|
|
}
|
|
return whenToState(when)
|
|
}
|
|
return JobSkipped // no rule matched → job is excluded
|
|
}
|
|
|
|
// Legacy only:/except: filtering.
|
|
if job.Only != nil || job.Except != nil {
|
|
if !onlyMatches(job.Only, ctx) || exceptMatches(job.Except, ctx) {
|
|
return JobSkipped
|
|
}
|
|
}
|
|
|
|
return whenToState(job.When)
|
|
}
|
|
|
|
// ── 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).
|
|
//
|
|
// Variables defined on an earlier member of the AND-group are cascaded to later
|
|
// members when evaluating their if: conditions. This matches GitLab CI behaviour
|
|
// where e.g. element 0 sets DEPLOY=true and element 1 can then test $DEPLOY.
|
|
func evalRuleGroup(group []model.Rule, vars func(string) string, ctx *Context, strict bool) (bool, string, map[string]string) {
|
|
merged := make(map[string]string)
|
|
effectiveWhen := ""
|
|
for _, rule := range group {
|
|
// Cascade: variables accumulated from previous group members are visible
|
|
// to this member's if: condition, shadowing the base context.
|
|
cascadeVars := func(key string) string {
|
|
if v, ok := merged[key]; ok {
|
|
return v
|
|
}
|
|
return vars(key)
|
|
}
|
|
var ifOk bool
|
|
if strict {
|
|
ifOk = ruleIfMatchesStrict(rule.If, cascadeVars)
|
|
} else {
|
|
ifOk = ruleIfMatches(rule.If, cascadeVars)
|
|
}
|
|
if !ifOk || !changesMatch(rule.Changes, ctx) {
|
|
return false, "", nil
|
|
}
|
|
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
|
|
}
|
|
return EvalIf(ifExpr, vars)
|
|
}
|
|
|
|
func ruleIfMatchesStrict(ifExpr string, vars func(string) string) bool {
|
|
if ifExpr == "" {
|
|
return true // no if: condition → rule always matches
|
|
}
|
|
return EvalIfStrict(ifExpr, vars)
|
|
}
|
|
|
|
func whenToState(when string) JobState {
|
|
switch when {
|
|
case "never":
|
|
return JobSkipped
|
|
case "manual":
|
|
return JobManual
|
|
default:
|
|
// on_success, always, delayed, "" (default) → active
|
|
return JobActive
|
|
}
|
|
}
|
|
|
|
// onlyMatches returns true when the job's only: filter is satisfied by ctx.
|
|
func onlyMatches(only any, ctx *Context) bool {
|
|
if only == nil {
|
|
return true // no only → runs everywhere
|
|
}
|
|
refs := extractRefList(only)
|
|
if refs == nil {
|
|
return true // unrecognised form → permissive
|
|
}
|
|
return refListMatches(refs, ctx)
|
|
}
|
|
|
|
// exceptMatches returns true when the job's except: filter excludes the job.
|
|
func exceptMatches(except any, ctx *Context) bool {
|
|
if except == nil {
|
|
return false // no except → nothing excluded
|
|
}
|
|
refs := extractRefList(except)
|
|
if refs == nil {
|
|
return false // unrecognised form → don't exclude
|
|
}
|
|
return refListMatches(refs, ctx)
|
|
}
|
|
|
|
// extractRefList normalises the only:/except: value into a flat []string.
|
|
// Returns nil for forms that cannot be statically evaluated.
|
|
func extractRefList(v any) []string {
|
|
switch x := v.(type) {
|
|
case string:
|
|
return []string{x}
|
|
case []any:
|
|
var refs []string
|
|
for _, item := range x {
|
|
if s, ok := item.(string); ok {
|
|
refs = append(refs, s)
|
|
}
|
|
}
|
|
return refs
|
|
case map[string]any:
|
|
// Map form may carry { refs: [...], variables: [...], kubernetes: ... }.
|
|
// We only evaluate the refs list; skip anything more complex.
|
|
if raw, ok := x["refs"]; ok {
|
|
return extractRefList(raw)
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// refListMatches returns true when at least one entry in refs matches ctx.
|
|
func refListMatches(refs []string, ctx *Context) bool {
|
|
branch := ctx.Get("CI_COMMIT_BRANCH")
|
|
tag := ctx.Get("CI_COMMIT_TAG")
|
|
source := ctx.Get("CI_PIPELINE_SOURCE")
|
|
|
|
for _, ref := range refs {
|
|
switch ref {
|
|
case "branches":
|
|
if branch != "" {
|
|
return true
|
|
}
|
|
case "tags":
|
|
if tag != "" {
|
|
return true
|
|
}
|
|
case "merge_requests":
|
|
if source == "merge_request_event" {
|
|
return true
|
|
}
|
|
case "schedules":
|
|
if source == "schedule" {
|
|
return true
|
|
}
|
|
case "pipelines":
|
|
if source == "pipeline" {
|
|
return true
|
|
}
|
|
case "pushes":
|
|
if source == "push" {
|
|
return true
|
|
}
|
|
case "web":
|
|
if source == "web" {
|
|
return true
|
|
}
|
|
case "api":
|
|
if source == "api" {
|
|
return true
|
|
}
|
|
default:
|
|
// Branch name glob or /regex/ pattern.
|
|
if refPatternMatches(ref, branch) || refPatternMatches(ref, tag) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func refPatternMatches(pattern, ref string) bool {
|
|
if ref == "" {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") {
|
|
re, err := regexp.Compile(pattern[1 : len(pattern)-1])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return re.MatchString(ref)
|
|
}
|
|
return globMatches(pattern, ref)
|
|
}
|
|
|
|
// globMatches supports the simple '*' wildcard used in GitLab only/except refs.
|
|
func globMatches(pattern, s string) bool {
|
|
if !strings.Contains(pattern, "*") {
|
|
return pattern == s
|
|
}
|
|
// Recursive match: consume pattern character by character.
|
|
return matchGlob(pattern, s)
|
|
}
|
|
|
|
func matchGlob(pattern, s string) bool {
|
|
for len(pattern) > 0 {
|
|
if pattern[0] == '*' {
|
|
pattern = pattern[1:]
|
|
if len(pattern) == 0 {
|
|
return true // trailing '*' matches everything
|
|
}
|
|
// Try matching the rest of the pattern at every position in s.
|
|
for i := range s {
|
|
if matchGlob(pattern, s[i:]) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if len(s) == 0 || pattern[0] != s[0] {
|
|
return false
|
|
}
|
|
pattern = pattern[1:]
|
|
s = s[1:]
|
|
}
|
|
return len(s) == 0
|
|
}
|
|
|
|
// ── rules:changes: evaluation ─────────────────────────────────────────────────
|
|
|
|
// changesMatch reports whether the rule's changes: filter is satisfied.
|
|
//
|
|
// - rule.Changes == nil → no filter; always true
|
|
// - ctx.changedFiles == nil → file list not provided; always true (permissive)
|
|
// - otherwise → true iff at least one changed file matches at least one pattern
|
|
func changesMatch(changes any, ctx *Context) bool {
|
|
patterns := extractChangesPaths(changes)
|
|
if patterns == nil {
|
|
return true // no changes: filter
|
|
}
|
|
if ctx.changedFiles == nil {
|
|
return true // no file list provided → permissive
|
|
}
|
|
for _, changed := range ctx.changedFiles {
|
|
for _, pat := range patterns {
|
|
if doublestarMatch(pat, changed) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extractChangesPaths normalises a rule.Changes value into a []string of glob
|
|
// patterns. Returns nil when no changes: filter is present.
|
|
func extractChangesPaths(changes any) []string {
|
|
switch v := changes.(type) {
|
|
case nil:
|
|
return nil
|
|
case string:
|
|
return []string{v}
|
|
case []string:
|
|
return v
|
|
case []any:
|
|
var out []string
|
|
for _, item := range v {
|
|
if s, ok := item.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
case map[string]any:
|
|
// Extended form: { paths: [...], compare_to: "..." }
|
|
if raw, ok := v["paths"]; ok {
|
|
return extractChangesPaths(raw)
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// doublestarMatch reports whether pattern matches the file path using GitLab-style
|
|
// glob rules: '*' matches any character sequence within a single path segment;
|
|
// '**' matches zero or more path segments (crossing '/' boundaries).
|
|
func doublestarMatch(pattern, filePath string) bool {
|
|
return matchParts(strings.Split(pattern, "/"), strings.Split(filePath, "/"))
|
|
}
|
|
|
|
// matchParts is the recursive engine for doublestarMatch.
|
|
func matchParts(pat, name []string) bool {
|
|
for len(pat) > 0 {
|
|
if pat[0] == "**" {
|
|
if len(pat) == 1 {
|
|
return true // trailing ** matches everything remaining
|
|
}
|
|
// ** can match 0, 1, 2, … leading segments of name.
|
|
for i := 0; i <= len(name); i++ {
|
|
if matchParts(pat[1:], name[i:]) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if len(name) == 0 {
|
|
return false
|
|
}
|
|
ok, err := path.Match(pat[0], name[0])
|
|
if err != nil || !ok {
|
|
return false
|
|
}
|
|
pat = pat[1:]
|
|
name = name[1:]
|
|
}
|
|
return len(name) == 0
|
|
}
|