fix(rules): Reject an unparseable if: instead of dropping the job

What this MR does and why?

Two defects sat behind one symptom: a job disappearing from the pipeline while the run exited 0.

Valid config was rejected. The expression parser treated only space and tab as whitespace, so a rules: if: written as a YAML block scalar — if: > or if: |, both of which keep a trailing newline — failed to parse. The chomped if: >- beside it worked, which made the difference look like an arbitrary YAML formatting rule rather than a bug. GitLab's lexer skips /\s+/ ([ \t\r\n\f\v]) leading, interior and trailing, so skipWhitespace now covers the same set.

Invalid config was swallowed. An expression that did not parse became a warning on stderr and the rule was treated as false. glci lint never validated expressions, so it reported the config valid and exited 0; and because planning happens inside the daemon, whose stderr is the log file, glci run printed nothing at all — the job was simply absent and the pipeline was green. GitLab refuses such a config at pipeline creation.

So an if: that does not parse is now a config error, worded exactly as GitLab's lint API words it — jobs:<name>:rules:rule if invalid expression syntax, or workflow:rules:rule ... for a workflow rule. GitLab rejects an if: that is present but empty, and a non-string if:, with that same message, so those count too; a rule with no if: key still matches unconditionally.

glci run now refuses to start on config GitLab would reject outright, rather than planning around it. That class is the parse-time diagnostics glci already collected plus these new ones, so malformed stages: and unknown hooks: sub-keys are now refused by run as well — previously only lint reported them. The graph checks lint adds on top (a missing needs: target, a cycle, a bad timeout:, an undefined stage) are deliberately not part of this and still behave as before on the run path.

Refusing to run on a parse failure is only sound while glci's parser accepts everything GitLab's lexer accepts, so the gaps had to close first. Against GitLab's 15 lexemes, glci was missing two: Boolean and Input. Boolean literals are now supported — if: 'true', if: '$FLAG == true' — matching Lexeme::Boolean and compare_with_coercion: a bare false is falsy, while a variable holding the string "false" is a non-empty string and therefore truthy, and a boolean compared against a string compares their text. Input stays out: $[[ inputs.x ]] is interpolated before rules are read.

A null if: (- if: or - if: ~) is accepted, since GitLab declares the validator with_options allow_nil: true and drops the key — the rule matches unconditionally, exactly as an absent if: does.

A job expanded by parallel: or parallel: matrix: is reported once under the entry name. Expansion runs before validation, so without collapsing shards one bad expression produced an error per shard under names GitLab never uses (jobs:build: [amd64, linux]:rules:rule).

Every expectation here was confirmed against GitLab — the lint API for what is valid, and a real pipeline in a scratch project for the boolean evaluation semantics, which selected exactly the jobs glci now selects. That included checking that all four block-scalar forms and an interior newline are accepted, that a bridge's rules are validated like a job's, that a hidden .template is not validated at all, and that a sharded job is reported once.

Two notes on scope. include: rules: carries the same defect (include:rule if invalid expression syntax) but is left alone — include rules: are not evaluated at all yet, which is its own bug. Child pipelines parse through a separate path and are not gated here either; both are worth their own issues.

Steps to reproduce

# .gitlab-ci.yml
b_folded:
  script: echo b_folded
  rules:
    - if: >
        $CI_COMMIT_BRANCH == "main"

c_chomped:
  script: echo c_chomped
  rules:
    - if: >-
        $CI_COMMIT_BRANCH == "main"

d_literal:
  script: echo d_literal
  rules:
    - if: |
        $CI_COMMIT_BRANCH == "main"

Before, two of the three equivalent conditions silently vanished:

$ glci jobs --context branch=main
glci: warning: could not evaluate rule if: "$CI_COMMIT_BRANCH == \"main\"\n": unexpected trailing input: "\n"
glci: warning: could not evaluate rule if: "$CI_COMMIT_BRANCH == \"main\"\n": unexpected trailing input: "\n"
JOB        STAGE  WHEN        ALLOW_FAILURE  NEEDS
c_chomped  test   on_success

After, all three run. And for a genuinely invalid expression:

deploy:
  script: echo deploy
  rules:
    - if: $CI_COMMIT_BRANCH == main   # unquoted right-hand side
$ glci lint            # before: "CI configuration is valid: 2 jobs, 5 stages", exit 0
CI configuration is invalid:
  jobs:deploy:rules:rule if invalid expression syntax
$ echo $?
2

$ glci run --context branch=main     # before: "✓ Pipeline PASSED", exit 0, deploy missing
daemon: CI configuration is invalid:
  jobs:deploy:rules:rule if invalid expression syntax
$ echo $?
2

GitLab rejects that same configuration outright:

$ curl -s --request POST --header "Content-Type: application/json" \
    --data @payload.json "$GITLAB/api/v4/projects/$ID/ci/lint" | jq '.valid, .errors'
false
[ "jobs:deploy:rules:rule if invalid expression syntax" ]
  • Closes #154 (closed)
  • if: expression parsing is shared with #155 (closed), which is in flight against the same files; the hunks do not overlap.
  • GitLab reference: Gitlab::Ci::Pipeline::Expression::Lexer#tokenize skips /\s+/, and Gitlab::Config::Entry::Validators::ExpressionValidator gates on Statement#valid?, which evaluates and rescues ExpressionError — which is why an invalid regex literal is a config error there too. Boolean comparison follows Lexeme::LogicalOperator#compare_with_coercion, which coerces a boolean to its string form whenever the other operand is a string.

Merge request reports

Loading
Loading