feat(gitlab-sim): 🚀 branches support
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
package cicontext
|
||||
|
||||
import "strings"
|
||||
|
||||
// Context holds the simulated CI execution environment used for context-aware
|
||||
// pipeline evaluation (rules:if:, only:, except:, workflow:rules:).
|
||||
// Variables are keyed by their name without the leading $.
|
||||
type Context struct {
|
||||
Vars map[string]string
|
||||
}
|
||||
|
||||
// New builds a Context from high-level shortcut values and optional KEY=VALUE
|
||||
// overrides. Predefined CI variables are derived from the shortcuts so callers
|
||||
// do not need to know their exact names.
|
||||
//
|
||||
// Returns an empty Context (IsEmpty() == true) when all inputs are zero values,
|
||||
// preserving the existing linting behaviour when no context flags are given.
|
||||
//
|
||||
// Override priority (highest wins): extraVars > branch/tag/source shortcuts.
|
||||
func New(branch, tag, source string, extraVars []string) *Context {
|
||||
if branch == "" && tag == "" && source == "" && len(extraVars) == 0 {
|
||||
return &Context{}
|
||||
}
|
||||
|
||||
vars := make(map[string]string)
|
||||
|
||||
if branch != "" {
|
||||
vars["CI_COMMIT_BRANCH"] = branch
|
||||
vars["CI_COMMIT_REF_NAME"] = branch
|
||||
vars["CI_COMMIT_REF_SLUG"] = slugify(branch)
|
||||
if source == "" {
|
||||
source = "push"
|
||||
}
|
||||
}
|
||||
if tag != "" {
|
||||
vars["CI_COMMIT_TAG"] = tag
|
||||
vars["CI_COMMIT_REF_NAME"] = tag
|
||||
vars["CI_COMMIT_REF_SLUG"] = slugify(tag)
|
||||
delete(vars, "CI_COMMIT_BRANCH") // tag pushes have no branch variable
|
||||
if source == "" {
|
||||
source = "push"
|
||||
}
|
||||
}
|
||||
if source != "" {
|
||||
vars["CI_PIPELINE_SOURCE"] = source
|
||||
}
|
||||
if _, ok := vars["CI_DEFAULT_BRANCH"]; !ok {
|
||||
vars["CI_DEFAULT_BRANCH"] = "main"
|
||||
}
|
||||
|
||||
// KEY=VALUE overrides win over shortcuts.
|
||||
for _, kv := range extraVars {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if ok {
|
||||
vars[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return &Context{Vars: vars}
|
||||
}
|
||||
|
||||
// IsEmpty reports whether no variables have been set (no context flags given).
|
||||
func (c *Context) IsEmpty() bool {
|
||||
return c == nil || len(c.Vars) == 0
|
||||
}
|
||||
|
||||
// Get returns the value of a CI variable (key without the leading $).
|
||||
// Returns an empty string when the variable is not defined.
|
||||
func (c *Context) Get(key string) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.Vars[key]
|
||||
}
|
||||
|
||||
// Summary returns a short human-readable description of the context for CLI output.
|
||||
func (c *Context) Summary() string {
|
||||
if c.IsEmpty() {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if v := c.Get("CI_COMMIT_TAG"); v != "" {
|
||||
parts = append(parts, "tag="+v)
|
||||
} else if v := c.Get("CI_COMMIT_BRANCH"); v != "" {
|
||||
parts = append(parts, "branch="+v)
|
||||
}
|
||||
if v := c.Get("CI_PIPELINE_SOURCE"); v != "" {
|
||||
parts = append(parts, "source="+v)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// slugify converts a ref name to its GitLab slug form:
|
||||
// lowercased, non-alphanumeric characters replaced with '-', leading/trailing '-' removed.
|
||||
func slugify(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package cicontext
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EvalIf evaluates a GitLab CI rules:if: expression string against the provided
|
||||
// variable resolver.
|
||||
//
|
||||
// Supported:
|
||||
// - Variable references: $VAR_NAME
|
||||
// - String literals: "value" or 'value'
|
||||
// - Null keyword: null
|
||||
// - Comparison: == != =~ !~
|
||||
// - Boolean: && || !
|
||||
// - Grouping: ( )
|
||||
//
|
||||
// Regex patterns use Go's regexp syntax, which covers the common RE2 subset
|
||||
// used by GitLab CI. Unsupported or unparseable expressions fall back to true
|
||||
// (permissive) so the linter never silently drops jobs it cannot evaluate.
|
||||
func EvalIf(expr string, vars func(string) string) bool {
|
||||
p := &exprParser{s: strings.TrimSpace(expr), vars: vars}
|
||||
result, ok := p.parseOr()
|
||||
if !ok || p.pos < len(p.s) {
|
||||
return true // unparseable → permissive
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type exprParser struct {
|
||||
s string
|
||||
pos int
|
||||
vars func(string) string
|
||||
}
|
||||
|
||||
func (p *exprParser) peek() byte {
|
||||
if p.pos >= len(p.s) {
|
||||
return 0
|
||||
}
|
||||
return p.s[p.pos]
|
||||
}
|
||||
|
||||
func (p *exprParser) startsWith(tok string) bool {
|
||||
return strings.HasPrefix(p.s[p.pos:], tok)
|
||||
}
|
||||
|
||||
func (p *exprParser) consume(tok string) bool {
|
||||
if p.startsWith(tok) {
|
||||
p.pos += len(tok)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *exprParser) skipWS() {
|
||||
for p.pos < len(p.s) && (p.s[p.pos] == ' ' || p.s[p.pos] == '\t') {
|
||||
p.pos++
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grammar (recursive descent) ───────────────────────────────────────────────
|
||||
//
|
||||
// or_expr → and_expr ( '||' and_expr )*
|
||||
// and_expr → not_expr ( '&&' not_expr )*
|
||||
// not_expr → '!' not_expr | primary
|
||||
// primary → '(' or_expr ')' | comparison
|
||||
// comparison → value ( op value | regex_op regex )? | value
|
||||
// value → '$' ident | '"' … '"' | "'" … "'" | 'null'
|
||||
// op → '==' | '!='
|
||||
// regex_op → '=~' | '!~'
|
||||
// regex → '/' … '/'
|
||||
|
||||
func (p *exprParser) parseOr() (bool, bool) {
|
||||
left, ok := p.parseAnd()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
for {
|
||||
p.skipWS()
|
||||
if !p.consume("||") {
|
||||
return left, true
|
||||
}
|
||||
right, ok := p.parseAnd()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
left = left || right
|
||||
}
|
||||
}
|
||||
|
||||
func (p *exprParser) parseAnd() (bool, bool) {
|
||||
left, ok := p.parseNot()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
for {
|
||||
p.skipWS()
|
||||
if !p.consume("&&") {
|
||||
return left, true
|
||||
}
|
||||
right, ok := p.parseNot()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
left = left && right
|
||||
}
|
||||
}
|
||||
|
||||
func (p *exprParser) parseNot() (bool, bool) {
|
||||
p.skipWS()
|
||||
// '!' is logical NOT only when not followed by '=' (which would be '!=').
|
||||
if p.peek() == '!' && !p.startsWith("!=") {
|
||||
p.pos++
|
||||
val, ok := p.parseNot() // right-associative: !!x == x
|
||||
return !val, ok
|
||||
}
|
||||
return p.parsePrimary()
|
||||
}
|
||||
|
||||
func (p *exprParser) parsePrimary() (bool, bool) {
|
||||
p.skipWS()
|
||||
if p.peek() == '(' {
|
||||
p.pos++ // consume '('
|
||||
val, ok := p.parseOr()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
p.skipWS()
|
||||
if p.peek() != ')' {
|
||||
return false, false // unmatched parenthesis
|
||||
}
|
||||
p.pos++ // consume ')'
|
||||
return val, true
|
||||
}
|
||||
return p.parseComparison()
|
||||
}
|
||||
|
||||
func (p *exprParser) parseComparison() (bool, bool) {
|
||||
p.skipWS()
|
||||
leftStr, ok := p.parseValue()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
p.skipWS()
|
||||
|
||||
switch {
|
||||
case p.consume("=="):
|
||||
p.skipWS()
|
||||
rightStr, ok := p.parseValue()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return leftStr == rightStr, true
|
||||
|
||||
case p.consume("!="):
|
||||
p.skipWS()
|
||||
rightStr, ok := p.parseValue()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return leftStr != rightStr, true
|
||||
|
||||
case p.consume("=~"):
|
||||
p.skipWS()
|
||||
pat, ok := p.parseRegexLiteral()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
re, err := regexp.Compile(pat)
|
||||
if err != nil {
|
||||
return true, true // bad pattern → permissive
|
||||
}
|
||||
return re.MatchString(leftStr), true
|
||||
|
||||
case p.consume("!~"):
|
||||
p.skipWS()
|
||||
pat, ok := p.parseRegexLiteral()
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
re, err := regexp.Compile(pat)
|
||||
if err != nil {
|
||||
return true, true // bad pattern → permissive
|
||||
}
|
||||
return !re.MatchString(leftStr), true
|
||||
}
|
||||
|
||||
// No operator: variable is truthy when non-empty (defined and non-null).
|
||||
return leftStr != "", true
|
||||
}
|
||||
|
||||
// parseValue reads $VAR, "string", 'string', or null.
|
||||
// null and undefined variables both produce an empty string.
|
||||
func (p *exprParser) parseValue() (string, bool) {
|
||||
p.skipWS()
|
||||
|
||||
if p.peek() == '$' {
|
||||
p.pos++ // consume '$'
|
||||
name := p.parseIdent()
|
||||
if name == "" {
|
||||
return "", false
|
||||
}
|
||||
return p.vars(name), true
|
||||
}
|
||||
|
||||
// null keyword — must not be a prefix of a longer identifier.
|
||||
if p.startsWith("null") {
|
||||
end := p.pos + 4
|
||||
if end >= len(p.s) || !isIdentByte(p.s[end]) {
|
||||
p.pos += 4
|
||||
return "", true // null → empty string
|
||||
}
|
||||
}
|
||||
|
||||
if p.peek() == '"' || p.peek() == '\'' {
|
||||
return p.parseStringLiteral()
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (p *exprParser) parseIdent() string {
|
||||
start := p.pos
|
||||
for p.pos < len(p.s) && isIdentByte(p.s[p.pos]) {
|
||||
p.pos++
|
||||
}
|
||||
return p.s[start:p.pos]
|
||||
}
|
||||
|
||||
func (p *exprParser) parseStringLiteral() (string, bool) {
|
||||
quote := p.s[p.pos]
|
||||
p.pos++ // consume opening quote
|
||||
var sb strings.Builder
|
||||
for p.pos < len(p.s) {
|
||||
b := p.s[p.pos]
|
||||
if b == quote {
|
||||
p.pos++ // consume closing quote
|
||||
return sb.String(), true
|
||||
}
|
||||
if b == '\\' && p.pos+1 < len(p.s) {
|
||||
p.pos++
|
||||
sb.WriteByte(p.s[p.pos])
|
||||
} else {
|
||||
sb.WriteByte(b)
|
||||
}
|
||||
p.pos++
|
||||
}
|
||||
return "", false // unterminated string
|
||||
}
|
||||
|
||||
func (p *exprParser) parseRegexLiteral() (string, bool) {
|
||||
if p.peek() != '/' {
|
||||
return "", false
|
||||
}
|
||||
p.pos++ // consume opening '/'
|
||||
var sb strings.Builder
|
||||
for p.pos < len(p.s) {
|
||||
b := p.s[p.pos]
|
||||
if b == '/' {
|
||||
p.pos++ // consume closing '/'
|
||||
return sb.String(), true
|
||||
}
|
||||
if b == '\\' && p.pos+1 < len(p.s) {
|
||||
p.pos++
|
||||
sb.WriteByte('\\')
|
||||
sb.WriteByte(p.s[p.pos])
|
||||
} else {
|
||||
sb.WriteByte(b)
|
||||
}
|
||||
p.pos++
|
||||
}
|
||||
return "", false // unterminated regex
|
||||
}
|
||||
|
||||
func isIdentByte(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package cicontext
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEvalIf(t *testing.T) {
|
||||
vars := func(key string) string {
|
||||
m := map[string]string{
|
||||
"CI_COMMIT_BRANCH": "develop",
|
||||
"CI_COMMIT_TAG": "",
|
||||
"CI_PIPELINE_SOURCE": "push",
|
||||
"DEPLOY_ENV": "staging",
|
||||
}
|
||||
return m[key]
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
want bool
|
||||
}{
|
||||
// ── Equality ──────────────────────────────────────────────────────────
|
||||
{"eq match", `$CI_COMMIT_BRANCH == "develop"`, true},
|
||||
{"eq no match", `$CI_COMMIT_BRANCH == "main"`, false},
|
||||
{"eq single-quote", `$CI_COMMIT_BRANCH == 'develop'`, true},
|
||||
{"neq match", `$CI_COMMIT_BRANCH != "main"`, true},
|
||||
{"neq no match", `$CI_COMMIT_BRANCH != "develop"`, false},
|
||||
|
||||
// ── Null checks ───────────────────────────────────────────────────────
|
||||
{"tag undefined eq null", `$CI_COMMIT_TAG == null`, true},
|
||||
{"tag undefined neq null", `$CI_COMMIT_TAG != null`, false},
|
||||
{"branch defined neq null", `$CI_COMMIT_BRANCH != null`, true},
|
||||
{"branch defined eq null", `$CI_COMMIT_BRANCH == null`, false},
|
||||
{"null eq null", `null == null`, true},
|
||||
|
||||
// ── Variable truthiness (no operator) ────────────────────────────────
|
||||
{"truthy branch", `$CI_COMMIT_BRANCH`, true},
|
||||
{"falsy tag", `$CI_COMMIT_TAG`, false},
|
||||
|
||||
// ── Logical NOT ───────────────────────────────────────────────────────
|
||||
{"not false", `!$CI_COMMIT_TAG`, true},
|
||||
{"not true", `!$CI_COMMIT_BRANCH`, false},
|
||||
{"double not", `!!$CI_COMMIT_BRANCH`, true},
|
||||
|
||||
// ── Boolean AND ───────────────────────────────────────────────────────
|
||||
{"and both true", `$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "push"`, true},
|
||||
{"and left false", `$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"`, false},
|
||||
{"and right false", `$CI_COMMIT_BRANCH == "develop" && $CI_PIPELINE_SOURCE == "schedule"`, false},
|
||||
{"and both false", `$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "schedule"`, false},
|
||||
|
||||
// ── Boolean OR ────────────────────────────────────────────────────────
|
||||
{"or both true", `$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main"`, true},
|
||||
{"or left true", `$CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "nope"`, true},
|
||||
{"or right true", `$CI_COMMIT_BRANCH == "nope" || $CI_COMMIT_BRANCH == "develop"`, true},
|
||||
{"or both false", `$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "nope"`, false},
|
||||
|
||||
// ── Parentheses ───────────────────────────────────────────────────────
|
||||
{"paren or+and", `($CI_COMMIT_BRANCH == "develop" || $CI_COMMIT_BRANCH == "main") && $CI_COMMIT_TAG == null`, true},
|
||||
{"paren or+and false", `($CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "nope") && $CI_COMMIT_TAG == null`, false},
|
||||
|
||||
// ── Regex match ───────────────────────────────────────────────────────
|
||||
{"regex match", `$CI_COMMIT_BRANCH =~ /^dev/`, true},
|
||||
{"regex no match", `$CI_COMMIT_BRANCH =~ /^main/`, false},
|
||||
{"regex not match", `$CI_COMMIT_BRANCH !~ /^main/`, true},
|
||||
{"regex not no match", `$CI_COMMIT_BRANCH !~ /^dev/`, false},
|
||||
{"regex case sensitive", `$CI_COMMIT_BRANCH =~ /^DEV/`, false},
|
||||
{"regex feat branch", `$CI_COMMIT_BRANCH =~ /^feat\//`, false},
|
||||
|
||||
// ── Extra whitespace ──────────────────────────────────────────────────
|
||||
{"extra spaces", ` $CI_COMMIT_BRANCH == "develop" `, true},
|
||||
{"tabs", "$CI_COMMIT_BRANCH\t==\t\"develop\"", true},
|
||||
|
||||
// ── Permissive fallback ───────────────────────────────────────────────
|
||||
{"unparseable returns true", `this is not valid syntax %%%`, true},
|
||||
{"empty expr returns true", ``, true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := EvalIf(tc.expr, vars)
|
||||
if got != tc.want {
|
||||
t.Errorf("EvalIf(%q) = %v, want %v", tc.expr, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package cicontext
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/gitlab-sim/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 returns false when the pipeline's workflow:rules block would
|
||||
// prevent any pipeline from starting in the given context.
|
||||
// Returns true when ctx is empty, when there is no workflow block, or when no
|
||||
// rule is configured.
|
||||
func EvalWorkflow(p *model.Pipeline, ctx *Context) bool {
|
||||
if ctx.IsEmpty() || p.Workflow == nil || len(p.Workflow.Rules) == 0 {
|
||||
return true
|
||||
}
|
||||
vars := ctx.Get
|
||||
for _, rule := range p.Workflow.Rules {
|
||||
if !ruleIfMatches(rule.If, vars) {
|
||||
continue
|
||||
}
|
||||
when := rule.When
|
||||
if when == "" {
|
||||
when = "always"
|
||||
}
|
||||
return when != "never"
|
||||
}
|
||||
return false // 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 _, rule := range job.Rules {
|
||||
if !ruleIfMatches(rule.If, vars) {
|
||||
continue
|
||||
}
|
||||
return whenToState(rule.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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func ruleIfMatches(ifExpr string, vars func(string) string) bool {
|
||||
if ifExpr == "" {
|
||||
return true // no if: condition → rule always matches
|
||||
}
|
||||
return EvalIf(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
|
||||
}
|
||||
Reference in New Issue
Block a user