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#
| Context | Key variables set |
|---|---|
branch=NAME | CI_COMMIT_BRANCH=NAME, CI_COMMIT_REF_NAME=NAME, CI_PIPELINE_SOURCE=push, CI_DEFAULT_BRANCH=<detected> |
tag=NAME | CI_COMMIT_TAG=NAME, CI_COMMIT_REF_NAME=NAME, CI_PIPELINE_SOURCE=push |
merge_request | CI_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=NAME | CI_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#
| Feature | glci | Production 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 GitLab | Supported (16.4+) |
workflow: rules: exists: | Evaluated against the working tree, not the committed tree | Supported (16.4+) |
workflow: auto_cancel: | Parsed but not enforced | Actively cancels pipelines |
workflow: name: | Parsed; surfaced only in the rejection message | Displayed 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:
**matches at any depth, including the project root –exists: ['**/*.php']matchesapp.phpat the root andsrc/lib/app.phpnested several levels deep.*,?, and[...]are supported, and (like GitLab)*does not cross directory separators.- Brace expansion (
{a,b}) is supported by both clauses –exists: ['*.{php,inc}']matches*.phpand*.inc. Multiple groups expand combinatorially ('{src,test}/**/*.{js,ts}'). - This grammar is not the one
include:uses. GitLab matches an include path with a different mechanism, where?,[and{are literal filename characters and there is no brace expansion. See wildcards in include paths.
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):
exists:matches regular files only, never directories. A trailing slash makes it a directory-presence check:exists: ['src/']matches when any file lives undersrc/.exists:evaluates against the project’s non-ignored files (everything git tracks plus untracked files, minus.gitignored paths; the full working tree when the directory is not a git repo). Paths outside the project, directories, and the.gitsubtree never match because they are not in that list. See debugging rule evaluation for the difference from GitLab’s committed-tree evaluation.- Like GitLab,
exists:glob matching has a comparison budget (50,000 path×glob comparisons); on a project large enough to exceed it, glci assumes a match (fails open) and prints a warning. Literal paths and**/*.extextension globs are matched directly and are not subject to the budget.
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 |.