feat(linter): add GL046-GL049 rules, AND-group support, and GL032 fix
- 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:
@@ -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`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user