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.

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,
# CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, etc.
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_PIPELINE_SOURCE=push, CI_DEFAULT_BRANCH=<detected>
tag=NAMECI_COMMIT_TAG=NAME, CI_COMMIT_REF_NAME=NAME, CI_PIPELINE_SOURCE=push
merge_requestCI_PIPELINE_SOURCE=merge_request_event, CI_MERGE_REQUEST_IID=1, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, CI_MERGE_REQUEST_TARGET_BRANCH_NAME, CI_MERGE_REQUEST_TITLE=local
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. 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.

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.

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 |.

Esc