diff --git a/.gitignore b/.gitignore index 5bcb40d..ad0f132 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ coverage.txt # Task runner cache .task/ +# Tmp +.tmp/ + # Claude Code project memory .claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ba7fd..112388a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org). +## [0.5.0] - 2026-07-30 + +### Added + +- **Windows ARM64 build target** — `task build-windows-arm64` cross-compiles `glint--windows-arm64.exe`; the Gitea release CI uploads it alongside the other five platform binaries. +- **`workflow.rules` AND-group support** — the `- -` nested-array rule form (AND-groups) is now parsed and evaluated with correct AND logic: all conditions in a group must match for the group to fire; `when:` and `variables:` from all matching members are merged. Previously this form caused a YAML unmarshal crash. +- **`workflow.name` and `workflow.auto_cancel` fields** — no longer cause a parse error when present; both are decoded into the `Workflow` struct. +- **GL046 — `pull_policy` validation** — errors when `image.pull_policy` or `services[n].pull_policy` uses a value other than `always`, `if-not-present`, or `never`; both the string scalar and list-of-strings forms are checked. +- **GL047 — `variables.options` default validation** — errors when a pipeline-level or job-level variable declares an `options:` list and its `value:` (default) is absent from that list; GitLab rejects such pipelines at creation time. +- **GL048 — `trigger.forward` key validation** — errors on unrecognised keys inside `trigger.forward:`; only `pipeline_variables` and `yaml_variables` are valid. +- **GL049 — `rules[n].allow_failure` validation** — validates rule-level `allow_failure:` (GitLab CI 15.0+): must be a boolean or a map with an `exit_codes:` key; applies the same checks as the job-level GL014. + +### Fixed + +- **GL032 false positive in workflow rules** — variables set by any workflow rule's `variables:` block are now excluded from GL032 undeclared-variable warnings when referenced in sibling workflow rule `if:` expressions; previously only pipeline-level `variables:` entries were exempt. + ## [0.4.1] - 2026-06-26 ### Fixed diff --git a/README.md b/README.md index a3d4f2b..7281a5b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

License - Release + Release

> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. @@ -15,7 +15,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G ## What it does -- **Lints** — 45 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001–GL045); run `glint explain ` for any rule +- **Lints** — 49 rules covering pipeline structure, keyword constraints, `needs:`/`dependencies:` graphs, expression reachability, and deprecations (GL001–GL049); run `glint explain ` for any rule - **Resolves includes** — local files, HTTPS URLs, GitLab project templates, and CI/CD Catalog components, with offline cache support and HTTP proxy support (`--proxy` flag or `proxy:` in `.glint.yml`) - **Renders merged pipeline** — `glint render` resolves all includes and `extends:` chains into a single flat YAML file, matching what GitLab CI actually processes - **Simulates context** — `--branch`, `--tag`, `--source` flags evaluate `rules:if:` and `only`/`except` to show which jobs would be active, manual, or skipped; `--context branch=main --context branch=develop` prints a multi-column comparison table across multiple contexts in one run diff --git a/ROADMAP.md b/ROADMAP.md index b6a3dfc..70ba820 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,6 +8,7 @@ This document tracks planned improvements to `glint`. Items are grouped by theme Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint graph` to evaluate `rules:if:` expressions and `only`/`except` filters against a specific pipeline event. +- [x] **`workflow.rules` AND-group semantics** — shipped v0.5.0; the `- -` nested-array rule form (AND-groups) is parsed and evaluated with correct AND logic: all conditions in a group must match for the group to fire; variables and `when:` from all matching group members are merged - [x] **Single-context simulation** — shipped v0.2.0; `--branch`, `--tag`, `--source`, `--var` flags on both subcommands; jobs classified as active / manual / skipped - [x] **`workflow:rules:variables:` propagation** — shipped post-v0.2.0; variables from the matching workflow rule entry injected into the evaluation context before job `rules:if:` expressions are evaluated - [x] **Expression evaluator: multi-line `if:` values** — shipped post-v0.2.0; newlines in block-scalar and folded YAML `if:` values treated as whitespace @@ -32,6 +33,10 @@ Pass `--branch`, `--tag`, `--source`, or `--var` to `glint check` or `glint grap The current rule set covers the most common sources of broken pipelines. These are the gaps most likely to matter in practice. +- [x] **`image.pull_policy` / `services[n].pull_policy` validation (GL046)** — shipped v0.5.0; validates that `pull_policy` values are one of `always`, `if-not-present`, `never`; applies to both `image:` map form and service entries; list form also checked element-by-element +- [x] **`variables.options` default value validation (GL047)** — shipped v0.5.0; errors when a variable declares an `options:` list and its `value:` (default) is not listed; checked at pipeline level and per-job; GitLab rejects such pipelines at creation time +- [x] **`trigger.forward` key validation (GL048)** — shipped v0.5.0; errors on unrecognised keys inside `trigger.forward:`; only `pipeline_variables` and `yaml_variables` are valid +- [x] **`rules[n].allow_failure` validation (GL049)** — shipped v0.5.0; validates rule-level `allow_failure:` (GitLab CI 15.0+) using the same rules as job-level GL014: must be a boolean or a `{exit_codes:}` map - [x] **Variable reference validation (GL032)** — shipped v0.2.11; warns when a `rules:if:` expression references `$VAR` / `${VAR}` not declared anywhere in pipeline YAML; predefined GitLab namespaces (`CI_*`, `GITLAB_*`, …) exempt; variables from included files are also considered - [x] **`rules:if:` static reachability (GL033)** — shipped v0.2.15; warns when every rule in a job's `rules:` block has `when: never`, making the job permanently excluded from any pipeline run; no `if:` evaluation required - [x] **`services:` validation (GL034)** — shipped v0.2.16; map form requires `name`; `alias` must be a valid DNS label diff --git a/Taskfile.yml b/Taskfile.yml index 9220737..9213036 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -117,6 +117,8 @@ tasks: ignore_error: false - cmd: ./{{.BINARY}} check testdata/rules_needs_invalid.yml ignore_error: true + - cmd: ./{{.BINARY}} check testdata/nested_rules.yml + ignore_error: true - cmd: ./{{.BINARY}} explain GL007 ignore_error: false - cmd: ./{{.BINARY}} explain gl042 diff --git a/internal/cicontext/reachability.go b/internal/cicontext/reachability.go index fa910ad..644963a 100644 --- a/internal/cicontext/reachability.go +++ b/internal/cicontext/reachability.go @@ -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 diff --git a/internal/linter/explain.go b/internal/linter/explain.go index 2ab9c48..a844e02 100644 --- a/internal/linter/explain.go +++ b/internal/linter/explain.go @@ -867,4 +867,86 @@ test: Fix: `include: - remote: https://ci-templates.example.com/build.yml`, }, + + RuleInvalidPullPolicy: { + Title: "'pull_policy:' has invalid value", + Severity: Error, + Description: "'image.pull_policy' and 'services[n].pull_policy' must be one of " + + "'always', 'if-not-present', or 'never', or a list of those values. " + + "Any other string is rejected by GitLab at pipeline creation time.", + Example: `my-job: + image: + name: alpine + pull_policy: on-demand # not a valid value + script: echo hi`, + Fix: `my-job: + image: + name: alpine + pull_policy: if-not-present + script: echo hi`, + }, + + RuleVariableValueNotInOptions: { + Title: "variable default value not listed in 'options'", + Severity: Error, + Description: "A pipeline variable declares an 'options' list that constrains what " + + "values can be chosen when triggering the pipeline manually. If the 'value' " + + "(the default) is not in that list, GitLab rejects the pipeline at creation " + + "time with a validation error.", + Example: `variables: + DEPLOY_ENV: + value: staging + options: + - production + - review # 'staging' is missing from the list`, + Fix: `variables: + DEPLOY_ENV: + value: staging + options: + - staging + - production + - review`, + }, + + RuleInvalidTriggerForward: { + Title: "'trigger.forward:' has unrecognised key", + Severity: Error, + Description: "'trigger.forward' controls which variables are forwarded to the " + + "downstream pipeline. Only 'pipeline_variables' and 'yaml_variables' are " + + "valid keys. Any other key is silently ignored by some GitLab versions and " + + "rejected by others.", + Example: `deploy: + trigger: + include: + - artifact: pipeline.yml + job: build + forward: + all_variables: true # not a valid key`, + Fix: `deploy: + trigger: + include: + - artifact: pipeline.yml + job: build + forward: + pipeline_variables: true + yaml_variables: true`, + }, + + RuleInvalidRulesAllowFailure: { + Title: "'rules[n].allow_failure:' invalid value", + Severity: Error, + Description: "'allow_failure' inside a 'rules:' entry (GitLab CI 15.0+) must be " + + "a boolean (true/false) or a map with an 'exit_codes' key. This overrides " + + "the job-level 'allow_failure' when the rule matches.", + Example: `my-job: + script: ./test.sh + rules: + - if: $CI_COMMIT_BRANCH + allow_failure: maybe # must be true/false or a map`, + Fix: `my-job: + script: ./test.sh + rules: + - if: $CI_COMMIT_BRANCH + allow_failure: true`, + }, } diff --git a/internal/linter/keywords.go b/internal/linter/keywords.go index bcdd781..2cc07ca 100644 --- a/internal/linter/keywords.go +++ b/internal/linter/keywords.go @@ -114,6 +114,8 @@ func checkJobKeywords(name string, job model.Job) []Finding { findings = append(findings, checkSecrets(name, job)...) findings = append(findings, checkPagesKeyword(name, job)...) findings = append(findings, checkCacheKeyFiles(name, job)...) + findings = append(findings, checkPullPolicy(name, job)...) + findings = append(findings, checkRulesAllowFailure(name, job)...) return findings } @@ -313,6 +315,20 @@ func checkTrigger(name string, job model.Job) []Finding { Message: "'trigger' map must specify 'project' or 'include'", }) } + // GL048: validate trigger.forward keys. + if fwd, ok := m["forward"].(map[string]any); ok { + validForwardKeys := map[string]bool{"pipeline_variables": true, "yaml_variables": true} + for k := range fwd { + if !validForwardKeys[k] { + findings = append(findings, Finding{ + Severity: Error, + Rule: RuleInvalidTriggerForward, + Job: name, + Message: fmt.Sprintf("'trigger.forward' has unrecognised key %q; valid keys: pipeline_variables, yaml_variables", k), + }) + } + } + } } return findings } @@ -793,6 +809,85 @@ func checkPagesKeyword(name string, job model.Job) []Finding { }} } +// GL046: image.pull_policy and services[n].pull_policy must use recognised values. +var validPullPolicy = map[string]bool{ + "always": true, "if-not-present": true, "never": true, +} + +func checkPullPolicy(name string, job model.Job) []Finding { + var findings []Finding + findings = append(findings, checkPullPolicyValue(name, "image", job.Image)...) + for i, svc := range job.Services { + findings = append(findings, checkPullPolicyValue(name, fmt.Sprintf("services[%d]", i), svc)...) + } + return findings +} + +func checkPullPolicyValue(jobName, field string, v any) []Finding { + m, ok := v.(map[string]any) + if !ok { + return nil + } + pp, exists := m["pull_policy"] + if !exists || pp == nil { + return nil + } + var policies []string + switch x := pp.(type) { + case string: + policies = []string{x} + case []any: + for _, item := range x { + if s, ok := item.(string); ok { + policies = append(policies, s) + } + } + } + var findings []Finding + for _, p := range policies { + if !validPullPolicy[p] { + findings = append(findings, Finding{ + Severity: Error, + Rule: RuleInvalidPullPolicy, + Job: jobName, + Message: fmt.Sprintf("%s.pull_policy has unrecognised value %q; valid: always, if-not-present, never", field, p), + }) + } + } + return findings +} + +// GL049: rules[n].allow_failure must be a boolean or a map with exit_codes:. +func checkRulesAllowFailure(name string, job model.Job) []Finding { + var findings []Finding + for i, rule := range job.Rules { + if rule.AllowFailure == nil { + continue + } + switch v := rule.AllowFailure.(type) { + case bool: + // valid + case map[string]any: + if _, ok := v["exit_codes"]; !ok { + findings = append(findings, Finding{ + Severity: Error, + Rule: RuleInvalidRulesAllowFailure, + Job: name, + Message: fmt.Sprintf("rules[%d].allow_failure map form must contain 'exit_codes'", i), + }) + } + default: + findings = append(findings, Finding{ + Severity: Error, + Rule: RuleInvalidRulesAllowFailure, + Job: name, + Message: fmt.Sprintf("rules[%d].allow_failure must be a boolean or a map with 'exit_codes'", i), + }) + } + } + return findings +} + // GL041: cache.key.files must be a list of exact file paths, not glob patterns. func checkCacheKeyFiles(name string, job model.Job) []Finding { if job.Cache == nil { diff --git a/internal/linter/linter.go b/internal/linter/linter.go index 8d88144..c4bd494 100644 --- a/internal/linter/linter.go +++ b/internal/linter/linter.go @@ -64,6 +64,7 @@ func Lint(p *model.Pipeline, skipped map[string]bool) []Finding { findings = append(findings, checkDuplicateStages(p)...) findings = append(findings, checkDefault(p)...) findings = append(findings, checkWorkflow(p)...) + findings = append(findings, checkPipelineVariableOptions(p)...) findings = append(findings, checkJobs(p)...) findings = append(findings, checkNeeds(p, skipped)...) findings = append(findings, checkRulesNeeds(p, skipped)...) @@ -223,6 +224,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding { } findings = append(findings, checkJobKeywords(name, job)...) + findings = append(findings, checkVariableOptionsForJob(name, job)...) // Attach source location to every job-scoped finding collected above. for i := range findings { @@ -235,6 +237,55 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding { return findings } +// GL047: variable declared with options: must have its default value in the options list. + +func checkPipelineVariableOptions(p *model.Pipeline) []Finding { + return checkVariableOptionsMap(p.Variables, "", p.SourceFile, 0, 0) +} + +func checkVariableOptionsForJob(name string, job model.Job) []Finding { + return checkVariableOptionsMap(job.Variables, name, job.File, job.Line, job.Column) +} + +func checkVariableOptionsMap(vars map[string]any, jobName, file string, line, col int) []Finding { + var findings []Finding + for varName, v := range vars { + m, ok := v.(map[string]any) + if !ok { + continue + } + rawOpts, hasOpts := m["options"] + rawVal, hasVal := m["value"] + if !hasOpts || !hasVal || rawVal == nil { + continue + } + opts, ok := rawOpts.([]any) + if !ok || len(opts) == 0 { + continue + } + val := fmt.Sprint(rawVal) + inOptions := false + for _, opt := range opts { + if fmt.Sprint(opt) == val { + inOptions = true + break + } + } + if !inOptions { + findings = append(findings, Finding{ + Severity: Error, + Rule: RuleVariableValueNotInOptions, + Job: jobName, + File: file, + Line: line, + Column: col, + Message: fmt.Sprintf("variable %q: default value %q is not listed in 'options'", varName, val), + }) + } + } + return findings +} + // scriptNonEmpty reports whether a script/before_script/after_script field // (which may be a []any list or a plain string) is non-empty. func scriptNonEmpty(v any) bool { diff --git a/internal/linter/rules.go b/internal/linter/rules.go index 4a625a2..9fb8172 100644 --- a/internal/linter/rules.go +++ b/internal/linter/rules.go @@ -163,4 +163,19 @@ const ( // CI templates fetched over HTTP are transmitted in cleartext and can be // intercepted or modified in transit. RuleInsecureRemoteInclude = "GL045" + + // GL046: image: or services[n]: pull_policy: contains an unrecognised value. + // Valid values: always, if-not-present, never (or a list of those values). + RuleInvalidPullPolicy = "GL046" + + // GL047: a variable declared with options: has a default value: that is not + // listed in the options list. GitLab rejects the pipeline at creation time. + RuleVariableValueNotInOptions = "GL047" + + // GL048: trigger.forward: contains an unrecognised key. Only + // pipeline_variables and yaml_variables are valid. + RuleInvalidTriggerForward = "GL048" + + // GL049: rules[n].allow_failure: is not a boolean or a map with exit_codes:. + RuleInvalidRulesAllowFailure = "GL049" ) diff --git a/internal/linter/variables.go b/internal/linter/variables.go index b5bdd3e..f8035cf 100644 --- a/internal/linter/variables.go +++ b/internal/linter/variables.go @@ -110,7 +110,7 @@ func checkVariableRefs(p *model.Pipeline) []Finding { continue } for _, varName := range extractIfVars(rule.If) { - if isPredefinedVar(varName) || pipelineVars[varName] || seen[varName] { + if isPredefinedVar(varName) || pipelineVars[varName] || workflowRuleVars[varName] || seen[varName] { continue } seen[varName] = true diff --git a/internal/model/pipeline.go b/internal/model/pipeline.go index d4f55e5..bd6d17b 100644 --- a/internal/model/pipeline.go +++ b/internal/model/pipeline.go @@ -1,5 +1,7 @@ package model +import "gopkg.in/yaml.v3" + // Pipeline represents the top-level structure of a .gitlab-ci.yml file. // Unknown top-level keys are collected into Jobs. type Pipeline struct { @@ -41,7 +43,9 @@ type DefaultConfig struct { } type Workflow struct { - Rules []Rule `yaml:"rules"` + Name string `yaml:"name"` + AutoCancel any `yaml:"auto_cancel"` + Rules Rules `yaml:"rules"` } type Job struct { @@ -57,7 +61,7 @@ type Job struct { Image any `yaml:"image"` Services []any `yaml:"services"` Variables map[string]any `yaml:"variables"` // string or {value,description,options} map - Rules []Rule `yaml:"rules"` + Rules Rules `yaml:"rules"` Only any `yaml:"only"` Except any `yaml:"except"` Needs []any `yaml:"needs"` @@ -85,12 +89,71 @@ type Job struct { } type Rule struct { - If string `yaml:"if"` - When string `yaml:"when"` - Changes any `yaml:"changes"` // []string or {paths,compare_to} map - Exists any `yaml:"exists"` // []string or map form - Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+) - Needs []any `yaml:"needs"` // override needs: when this rule matches (GitLab CI 16.4+) + If string `yaml:"if"` + When string `yaml:"when"` + Changes any `yaml:"changes"` // []string or {paths,compare_to} map + Exists any `yaml:"exists"` // []string or map form + Variables map[string]any `yaml:"variables"` // set/override variables when rule matches (GitLab CI 15.0+) + Needs []any `yaml:"needs"` // override needs: when this rule matches (GitLab CI 16.4+) + AllowFailure any `yaml:"allow_failure"` // bool or {exit_codes:} map (GitLab CI 15.0+) + GroupID int `yaml:"-"` // >0 means AND-group; set by Rules.UnmarshalYAML +} + +// Rules is a list of Rule entries that also supports GitLab CI's nested-array +// form where each outer entry can itself be a sequence of rules (AND-group). +// Rules with the same non-zero GroupID form an AND-group: all conditions must +// match for the group to fire. GroupID == 0 means standalone rule. +type Rules []Rule + +func (r *Rules) UnmarshalYAML(value *yaml.Node) error { + if value.Kind != yaml.SequenceNode { + return nil + } + nextGroup := 1 + for _, item := range value.Content { + switch item.Kind { + case yaml.MappingNode: + var rule Rule // GroupID stays 0 (standalone) + if err := item.Decode(&rule); err != nil { + return err + } + *r = append(*r, rule) + case yaml.SequenceNode: + // AND-group: assign a shared GroupID so the evaluator can apply AND logic. + var group []Rule + if err := item.Decode(&group); err != nil { + return err + } + for i := range group { + group[i].GroupID = nextGroup + } + *r = append(*r, group...) + nextGroup++ + } + } + return nil +} + +// Groups returns the rules partitioned into AND-groups. Standalone rules +// (GroupID == 0) form single-element groups; rules with a shared GroupID form +// one group where all conditions must match. +func (r Rules) Groups() [][]Rule { + var groups [][]Rule + seen := map[int]int{} // groupID → index in groups + for _, rule := range r { + if rule.GroupID == 0 { + groups = append(groups, []Rule{rule}) + } else { + idx, ok := seen[rule.GroupID] + if !ok { + idx = len(groups) + seen[rule.GroupID] = idx + groups = append(groups, nil) + } + groups[idx] = append(groups[idx], rule) + } + } + return groups } // ReservedKeys are top-level GitLab CI keys that are NOT job definitions. diff --git a/testdata/keywords_invalid.yml b/testdata/keywords_invalid.yml index d198a57..6e04d42 100644 --- a/testdata/keywords_invalid.yml +++ b/testdata/keywords_invalid.yml @@ -2,6 +2,13 @@ stages: - build - test +variables: + DEPLOY_ENV: + value: canary # ERROR: not listed in options + options: + - staging + - production + workflow: rules: - if: '$CI_PIPELINE_SOURCE == "push"' @@ -126,3 +133,50 @@ bad-rule-when-job: rules: - if: '$CI_MERGE_REQUEST_ID' when: sometimes # ERROR: invalid rules[0].when + +bad-pull-policy-job: + stage: build + script: + - echo hello + image: + name: alpine + pull_policy: on-demand # ERROR: invalid pull_policy value + +bad-pull-policy-service-job: + stage: build + script: + - echo hello + services: + - name: postgres:15 + pull_policy: lazy # ERROR: invalid service pull_policy value + +bad-trigger-forward-job: + trigger: + project: mygroup/myproject + forward: + all_variables: true # ERROR: unrecognised trigger.forward key + +bad-trigger-forward-key2-job: + trigger: + include: + - artifact: child.yml + job: build + forward: + inherit: false # ERROR: unrecognised trigger.forward key + +bad-rules-allow-failure-job: + stage: build + script: + - echo hello + rules: + - if: $CI_COMMIT_BRANCH + allow_failure: maybe # ERROR: invalid rules[0].allow_failure value + +bad-rules-allow-failure-map-job: + stage: build + script: + - echo hello + rules: + - if: $CI_COMMIT_BRANCH + allow_failure: + codes: [1] # ERROR: map missing exit_codes key diff --git a/testdata/keywords_valid.yml b/testdata/keywords_valid.yml index 8e98f96..5e22e86 100644 --- a/testdata/keywords_valid.yml +++ b/testdata/keywords_valid.yml @@ -15,12 +15,19 @@ default: variables: GO_VERSION: "1.26" + DEPLOY_ENV: + value: staging + options: + - staging + - production + - review build-job: stage: build image: name: golang:1.26 entrypoint: [""] + pull_policy: if-not-present script: - go build ./... artifacts: @@ -72,6 +79,24 @@ release-job: action: start when: on_success +.trigger-valid: + trigger: + include: + - artifact: child.yml + job: build + forward: + pipeline_variables: true + yaml_variables: false + +.rules-allow-failure-valid: + script: echo ok + rules: + - if: $CI_COMMIT_BRANCH + allow_failure: true + - if: $CI_COMMIT_TAG + allow_failure: + exit_codes: [1, 2] + .template: before_script: - echo "before" diff --git a/testdata/nested_rules.yml b/testdata/nested_rules.yml new file mode 100644 index 0000000..e091784 --- /dev/null +++ b/testdata/nested_rules.yml @@ -0,0 +1,31 @@ +--- +# Tests that workflow.rules and job.rules accept the nested-array (AND-group) +# form where each outer entry can itself be a sequence of rule conditions. +workflow: + name: "${PIPELINE_NAME}" + auto_cancel: + on_new_commit: interruptible + rules: + - if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH" + variables: + PIPELINE_NAME: "main" + - - if: "$CI_PIPELINE_SOURCE == 'push' && $CI_COMMIT_BRANCH =~ /^feat\\///" + variables: + PIPELINE_NAME: "feature" + - - if: "$CI_COMMIT_TAG" + - if: "$DEPLOY" + variables: + PIPELINE_NAME: "deploy" + +variables: + DEPLOY: "false" + +build: + stage: build + script: echo building + rules: + - if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH" + - - if: "$CI_PIPELINE_SOURCE == 'push'" + allow_failure: true + - if: "$DEPLOY" + when: manual diff --git a/testdata/variable_refs.yml b/testdata/variable_refs.yml index 55de4cf..0f7713c 100644 --- a/testdata/variable_refs.yml +++ b/testdata/variable_refs.yml @@ -17,6 +17,10 @@ workflow: - if: '$CI_COMMIT_BRANCH == "main"' variables: DEPLOY_TARGET: "production" + WORKFLOW_FLAG: "true" + # workflow rule referencing a variable set by a sibling rule's variables: — GL032 must not fire + - if: '$WORKFLOW_FLAG == "true"' + when: always - when: always build: