Workflow Rules

glci evaluates workflow: during pipeline planning, before any job is planned or dispatched – the same point GitLab evaluates it, before it creates the pipeline. rules: with if:, changes:, exists: and variables: all behave as they do in production GitLab CI. This page covers what is specific to glci.

Pipeline rejection#

Workflow rules gate the pipeline as a whole. The first matching rule decides; a rule with no when: accepts, and – unlike job rules – no matching rule at all rejects the pipeline:

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
    - when: never

build:
  script: echo ok

Under --context branch=main (CI_PIPELINE_SOURCE=push) neither rule accepts, so nothing runs:

$ glci jobs --context branch=main
the pipeline did not run: workflow:rules rule 2 (when: never) rejected it under CI_PIPELINE_SOURCE=push, CI_COMMIT_BRANCH=main [rule 1 if: $CI_PIPELINE_SOURCE == "schedule" → if: evaluated to false] — GitLab would not have created this pipeline either; simulate another context with --context, or review the workflow:rules configuration

The message names the rule that decided, why each earlier rule did not match, and the simulated context the rules were evaluated against — locally that context is a --context flag glci chose for you (default merge_request), so it is the first thing to check when the rejection is a surprise.

The bare glci TUI is the exception: its graph is a client-side preview that evaluates no rules at all, so it still draws a pipeline glci show refuses. Running it goes through the daemon, which does gate.

This is GitLab’s “The pipeline did not run. Review the workflow:rules configuration for the pipeline.” refusal. It applies to every command that resolves a pipeline (run, jobs, show, variables); no jobs are planned and glci exits 2, because on GitLab the pipeline would never have been created. Over the daemon (glci run) the same text arrives prefixed with daemon:.

Child and cross-project pipelines carry their own workflow: block and are gated the same way, as they are on GitLab. There the rejection fails the trigger job that would have created them:

preparing child pipeline for "trigger-deploy": the pipeline did not run: no workflow:rules rule matched …

A pipeline that is resumed after a daemon restart is not re-gated: GitLab evaluates workflow: once, when it creates the pipeline, so an already-created run finishes even if the context has drifted since. The rules are still re-read for their variables:, so if the drift means a different rule matches – or none does – the resumed jobs see that rule’s variables instead of the original’s. The daemon log says so when it happens. Invalid configuration is the exception: if the CI config (or a file it includes) was edited while the daemon was down into one GitLab rejects – a workflow: rules: if: that does not parse, say – the resume is refused and the pipeline is marked failed, as it is when the edited file no longer parses. Every job that had not already reached a terminal status fails with it, so the refused run is closed out in glci history and glci show rather than leaving jobs pending forever.

Rule variables#

The matched rule’s variables: join the pipeline’s global variables, so every job inherits them:

workflow:
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      variables:
        DEPLOY_ENV: production
    - variables:
        DEPLOY_ENV: review

deploy:
  script: echo "deploying to $DEPLOY_ENV"

They override the global variables: block but nothing above it: GitLab merges the matched rule into the pipeline’s root variables, so instance/group/project CI/CD variables, --env, --env-file, preset env, .glci.env, dotenv artifacts and a job’s own variables: all still win. See the precedence table. glci variables <job> shows the resolved value attributed to the global source, because that is the layer it joins.

Context simulation#

The variables available to workflow: rules: depend on the --context you pass:

# Branch context: sets CI_COMMIT_BRANCH, CI_PIPELINE_SOURCE=push
glci run --context branch=main

# Tag context: sets CI_COMMIT_TAG, CI_PIPELINE_SOURCE=push
glci run --context tag=v1.2.3

# Merge request context (default): sets CI_PIPELINE_SOURCE=merge_request_event
# and the 23 CI_MERGE_REQUEST_* variables a detached pipeline publishes
glci run --context merge_request

# Environment context: sets CI_ENVIRONMENT_NAME
glci run --context env=production

Variables set by context#

ContextKey variables set
branch=NAMECI_COMMIT_BRANCH=NAME, CI_COMMIT_REF_NAME=NAME, CI_COMMIT_REF_SLUG=<slug of NAME>, CI_PIPELINE_SOURCE=push, CI_DEFAULT_BRANCH=<resolved>
tag=NAMECI_COMMIT_TAG=NAME, CI_COMMIT_REF_NAME=NAME, CI_COMMIT_REF_SLUG=<slug of NAME>, CI_PIPELINE_SOURCE=push
merge_requestCI_PIPELINE_SOURCE=merge_request_event, CI_MERGE_REQUEST_EVENT_TYPE=detached, CI_MERGE_REQUEST_IID=1, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, CI_MERGE_REQUEST_TARGET_BRANCH_NAME, CI_OPEN_MERGE_REQUESTS, and the rest of the merge request set
env=NAMECI_ENVIRONMENT_NAME=NAME, CI_PIPELINE_SOURCE=push

Git-derived variables (CI_COMMIT_SHA, CI_COMMIT_SHORT_SHA, CI_COMMIT_AUTHOR, CI_PROJECT_DIR, etc.) are always available regardless of context.

Testing different contexts#

Use glci show to preview which jobs would run under each context without executing anything:

# Accepted (merge_request context is the default)
glci show

# See what runs for a branch push
glci show --context branch=develop

# See what runs for a tag
glci show --context tag=v1.0.0

Auto-cancel configuration#

workflow: auto_cancel: is parsed for compatibility but has no effect on local pipeline execution, since local pipelines are not triggered by remote push events.

Operators and operands in if:#

! binds to its own operand, tighter than ==, !=, =~ and !~; those in turn bind tighter than &&, and && tighter than ||. This matches GitLab, which numbers lexeme precedence from tightest to loosest – Not is 1, the comparisons are 10, And is 11 and Or is 12.

So !$FOO == "bar" means (!$FOO) == "bar", not !($FOO == "bar"). The negated side becomes a boolean, and is compared as the string "true" or "false" only when the other side is a string – the same coercion glci lint describes. Against null it stays a boolean and never matches, so !$FOO == null is always false. With FOO set to baz, !$FOO is false:

neg_eq_false:
  script: echo matched
  rules:
    - if: '!$FOO == "false"'    # matches: "false" == "false"

neg_eq_bar:
  script: echo skipped
  rules:
    - if: '!$FOO == "bar"'      # does not match: "false" is not "bar"

Wrap the comparison in parentheses to negate the whole of it: !($FOO == "bar"). Since && binds looser than !, !$UNSET && $FOO is (!$UNSET) && $FOO.

Either side of a comparison takes the same operands: a variable, a literal, a negation, or a parenthesised group. Parentheses are transparent grouping rather than a cast, so a group keeps the value inside it – ($FOO) == "baz" compares "baz" against "baz", and ("baz") == $FOO is the same test written the other way round. A group whose contents are themselves a comparison reduces to a boolean, and coerces like one: ($FOO == "baz") == "true".

glci used to read !$FOO == "bar" as !($FOO == "bar"), so a rule written that way may stop matching where it previously did. It also rejected ($FOO) == "baz" and "false" == !$FOO outright as invalid configuration; both are accepted now.

&& and || yield one of their operands rather than a boolean, as they do on GitLab, so ($UNSET || $FOO) == "baz" compares against the surviving operand.

Two gaps remain. Comparisons do not chain, so $A == "x" == "y" is reported as invalid here while GitLab evaluates it left-associatively – a shape a string literal can also produce on its own, as string literals describes. And &&/|| choose a side by asking whether the left operand is present, where GitLab uses Ruby truthiness and counts an empty string as true – so a variable that is defined but empty picks the wrong side ($EMPTY || $FOO matches here and not on GitLab). A variable that was never set agrees on both. =~, !~, ==, != and plain truthiness all tell the two apart; && and || are the only operators left that do not.

An undefined variable is nil, not the empty string, and nil equals nothing but nil. So $UNSET == "" is false and $UNSET != "" is true, while a variable defined as empty equals "" and does not equal null. The quoted string "null" is an ordinary three-character string, distinct from the null literal, and a variable whose value is the text null is truthy. glci used to compare an undefined variable as "" and treat the bare word null as the literal wherever it appeared, so all of these answered the other way round – a rule written $VAR != null to mean “this was set at all” was inverted for any variable declared with an empty default. Use the bare $VAR to test for a non-empty value, and != null to test whether it is defined at all.

String literals#

A quoted string is taken verbatim. There are no escape sequences, in either quoting style, so "a\nb" is the four characters a, \, n, b – not a newline – and compares equal to a variable holding that same text. Write the variable with a single-quoted YAML scalar (VAR: 'a\nb') so YAML does not decode the escape either.

A backslash therefore does not protect a quote: the literal ends at the next quote of the same kind, whatever precedes it. The scan looks for that same quote character and no other, so 'a"b' and "a'b" are each one literal, and $A == "x' && $B == 'y" is a single literal whose body is x' && $B == 'y rather than two comparisons. What is left over after the literal closes decides what happens next, and of the two shapes that follow, only one is loud. $FOO == "a\"b" ends the literal with the body a\ and leaves b", which parses as nothing and is refused as ... if invalid expression syntax. But $A == "x\" && $B == "y" leaves && $B == "y", which is a perfectly good continuation – so it lints clean and quietly means $A == "x\" and $B == "y", comparing $A against x\ rather than against x"b. GitLab reads those two leftovers, the refused one and the reparsed one, exactly the same way.

A leftover that starts with a comparison operator is the one shape where the two disagree, and glci is the stricter side. $A == "x\" == "y" lexes identically on both – $A, ==, the literal x\, ==, the literal y – and GitLab’s shunting-yard parser then builds the left-associative ($A == 'x\') == 'y'. That is valid configuration, because the inner comparison yields a boolean and comparing a boolean against y compares the text "true" or "false"; the chained rule is therefore constant, always false for == and =~ and always true for != and !~. glci has no comparison chaining, so it stops at the leftover. glci lint exits non-zero and glci run refuses to start on a file GitLab accepts, both with GitLab’s own ... if invalid expression syntax; the parser’s own unexpected trailing input: "== \"y\"" shows up in a rule trace (glci show, glci variables) rather than in the lint verdict. A leftover beginning !=, =~ or !~ is refused the same way, since all four operators carry the same precedence. This is the comparison chaining gap reached through a string literal, not a second defect in the literal itself.

Write the other quote instead of escaping it – 'a"b', or "a'b". A literal that needs both kinds of quote cannot be expressed at all, on GitLab either.

A literal cannot span a newline: that is refused outright, as on GitLab. A carriage return is ordinary data and stays inside the string.

glci used to decode backslash escapes and accept a literal containing a newline. A rule that relied on "\n" meaning a newline now compares against the two characters instead, and one written with \" is now either refused or reads as a different expression. That matches what GitLab does with the same file, except for the comparison-operator leftover above, which GitLab accepts and glci refuses.

The right-hand side of a match#

=~ compares against a regular expression only when the right-hand side is written as one: /pattern/, optionally followed by the flags i, m or s. A variable or a quoted string counts if its value has that form, which is how a pattern can be supplied from a CI/CD variable – and a value may additionally carry GitLab’s fourth flag, U, which glci does not yet accept on a literal written directly in the expression. A value is not validated the way a literal is: "/[bad/" falls back to a substring test rather than being rejected.

Anything else is a substring test, and the right-hand side is the text being searched. $BRANCH =~ $LIST asks whether the branch name occurs inside $LIST, not whether $LIST matches the branch. GitLab documents the same pair: "23" =~ "1234" is true, while "23" =~ /1234/ is false.

in_list:
  script: echo matched
  rules:
    - if: '$CI_COMMIT_BRANCH =~ $BRANCHES'    # matches when BRANCHES is "main,release" and the branch is "main"

as_regex:
  script: echo matched
  rules:
    - if: '$CI_COMMIT_BRANCH =~ /^(main|release)$/'   # the same intent, written as a pattern

Because a right-hand side that is not written as a pattern is never compiled, a value that would be a broken regular expression is not an error – it is simply text to search. $X =~ "[abc" looks for [abc, and $X =~ "/re/x" looks for /re/x, since x is not one of the four flags. Only a malformed /pattern/ literal is rejected, as invalid configuration. glci checks it only where it is used as a pattern – the right-hand side of =~ or !~ – while GitLab builds every literal as it tokenises, so a bad one written anywhere else lints clean here and is refused on push. GitLab is also stricter about the body: it requires at least two characters, so /a/ and // are refused there and accepted here.

A right-hand side that is an unset variable, null, or a false boolean never matches: GitLab’s unless regexp is falsiness rather than a nil check, so a false literal, a negation, and a parenthesised comparison that came out false all settle the comparison before any pattern work. !~ against any of them is therefore always true. A variable that is defined but empty is a different thing – it is set, so it falls through to the substring test and matches only an empty left-hand side.

The left-hand side is not symmetric with it: an unset variable there is compared as the empty string, and every string contains the empty string, so $UNSET =~ "abc" is true.

glci previously compiled every right-hand side as a regular expression and matched it against the left. Every comparison that did not use /pattern/ therefore evaluated backwards, and an unset right-hand side became the empty pattern, which matched everything – so $X =~ $UNSET was true and $X !~ $UNSET was false, the exact inverse of GitLab. Rules relying on either behaviour will start or stop matching. Write the pattern as /re/ where a regular expression was intended. Where the intent was to test presence, use the bare $VAR for “set to something non-empty” or $VAR != null for “defined at all” – != "" is not a presence test, because an undefined variable is nil and nil != "" is true.

Differences from production GitLab CI#

FeatureglciProduction GitLab
workflow: rules: changes:Evaluated against the local diff (see context simulation); always matches in a child or cross-project pipeline, which has no diff – as on GitLabSupported (16.4+)
workflow: rules: exists:Evaluated against the working tree, not the committed treeSupported (16.4+)
workflow: auto_cancel:Parsed but not enforcedActively cancels pipelines
workflow: name:Parsed; surfaced only in the rejection messageDisplayed in pipeline UI, and exported as CI_PIPELINE_NAME

changes: and exists: use the same matcher and the same file sets as job-level rules, so the caveats below apply to both.

One more difference is worth knowing when a workflow rule keys off a CI/CD variable stored in GitLab: glci run fetches instance/group/project variables and evaluates workflow rules against them, but glci jobs, glci show and glci variables resolve without contacting the API. A rule like if: $DEPLOY_KEY therefore reads as empty in those three commands and can reject a pipeline glci run would accept. Supply the value locally with --env to preview it.

Job-level exists: and changes: glob behavior#

Job-level exists: and changes: share the same glob matcher:

exists: additionally evaluates against the files on disk, which adds a few exists:-only rules (changes: matches against the set of changed paths and is not affected by them):

All other workflow features – rules: if: expression evaluation, rules: variables:, pipeline rejection on no match – work identically to production, apart from the expression gaps listed under operators and operands and string literals.

A workflow: rules: if: that does not parse is rejected as invalid configuration, reported as workflow:rules:rule if invalid expression syntax – GitLab’s own wording – rather than being treated as false. Block scalars are accepted, so a long condition can be wrapped over several lines with > or | – except that a quoted string may not span a newline, which a | block preserves; see a string literal in a rule spans a newline.

Esc