Debugging & Inspection
Variable precedence debugging#
When a job behaves unexpectedly, the variable stack is often the cause. See Variables & Secrets for the full precedence table.
Start by listing what each job actually resolves to and where each value came from:
glci variables my-job # job/rules vars + per-rule evaluation trace
glci variables my-job --all # also include predefined CI_* variables
glci run --show-variables my-job # fully resolved set (incl. secrets) at run time
glci variables resolves locally without running, so it also shows jobs that rules: excluded — see Inspecting resolved variables. To isolate which variable source is causing a problem:
glci run --secrets none my-job # run without remote variables
glci run --secrets project my-job # project-level only (skip group)
glci run --env MY_VAR=debug my-job # override a specific variable
glci run --refresh-secrets my-job # force refresh cached API variables
Debugging rule evaluation#
Rules evaluation works largely the same as production GitLab CI (docs), with one intentional difference for exists: noted below. glci supports if:, changes: (with compare_to:), and exists: conditions. Both changes: and exists: accept ** globs that match at any depth (e.g. exists: ['**/*.php'] matches .php files in the project root and any subdirectory) and brace expansion ({a,b}, e.g. ['*.{php,inc}']), mirroring GitLab’s FNM_EXTGLOB. exists: matches regular files only, never directories – though a trailing slash (exists: ['src/']) is a directory-presence check that matches when any file lives under that directory. See workflow rules for the full glob reference.
exists: evaluates the project’s non-ignored files — everything git tracks plus untracked files, but excluding anything matched by .gitignore (git ls-files --cached --others --exclude-standard). This mirrors glci’s dirty mode, which sends your working tree (minus gitignored files) to the runner, so an exists: rule sees the same files your job will. Build artifacts such as node_modules/, target/, and other gitignored output therefore do not satisfy an exists: rule, while files you have created but not yet committed do.
Difference from GitLab: GitLab evaluates
exists:against the committed tree at the pipeline SHA (tracked files only). glci evaluates your local working tree minus gitignored files, so uncommitted/untracked files match locally where they would not on GitLab until committed. When the project directory is not a git repository, glci falls back to scanning the entire working tree (gitignore can’t be consulted).
Like GitLab, exists: glob matching has a comparison budget (50,000 path×glob comparisons). On a project large enough to exceed it, glci — like GitLab — assumes a match (fails open) rather than risk dropping a job, and prints a warning. Literal paths and **/*.ext extension globs are matched directly and are not subject to the budget.
Conditions are evaluated against the job’s own scope — its variables: block and any parallel: matrix: values — layered over the pipeline-wide set, so a rule can gate a job on a variable it defines itself and a matrix job can keep only the shards it wants. Note that glci variables cannot fetch instance, group or project CI/CD variables, so a rule one of those would decide on a real run may resolve differently there than under glci run --show-variables.
See exactly which rule matched (or why none did) for a job, including the variable values each condition was evaluated against:
glci variables my-job # per-rule trace + resolved variables
glci variables my-job --context branch=main
A rule that could not be evaluated at all reports why on its trace line and as a warning on stderr. A secret cannot be disclosed that way: only the expression as written can fail to parse, never an expanded value. A /pattern/ written literally reports a syntax code — invalid regex: missing closing ], or unsupported regex flag x — never the pattern, and a string literal that does not lex reports only its position. A pattern held in a variable is never compiled unless its value is written as one, so it has nothing to fail about. See Rule evaluation never quotes a value.
Compare the pipeline across contexts to understand which rules matched:
glci jobs --context merge_request
glci jobs --context branch=main
diff <(glci show --json --context merge_request) \
<(glci show --json --context branch=main)
If a command fails with the pipeline did not run: ..., workflow: rules: rejected the entire pipeline for that context — GitLab would not have created it either. The message names the rule that decided; try a different context. See workflow rules.
The fully-resolved configuration#
A real pipeline is a root config plus include: entries, extends: chains, !reference splices and a default: block. When a job does not look like you expect, the first question is what the job actually is after all of that is merged. glci merged prints it:
glci merged # merged YAML
glci merged --json | jq '.["build-job"]' # one job, machine-readable
glci merged --input env=staging # with pipeline inputs
This is the local equivalent of GitLab’s “Full configuration” view — glci’s own fully-resolved view, no push required, and it works on a config that only exists on your machine. include: and extends: keys are gone, !reference is expanded in place, default: and global variables: have been applied to the jobs that inherit them, and .pre/.post are anchored at the ends of stages: when the config declares a stages: key. It is not byte-for-byte identical to GitLab’s merged YAML — see glci merged for the divergences to normalize before diffing the two.
The output is the config before rules: are evaluated, so it is context-independent — pair it with glci variables for rule traces and glci show for what would run in a given context.
Keys are sorted and the output is byte-stable, so it diffs cleanly:
glci merged > /tmp/after.yml
git stash push -- .gitlab-ci.yml && glci merged > /tmp/before.yml
git stash pop && diff /tmp/before.yml /tmp/after.yml # what did my refactor change?
(A bare git stash on a clean tree stashes nothing and exits 0, which makes the later git stash pop fail — hence the explicit pathspec. Keep the outputs in /tmp/ so they stay out of the tree being diffed.)
Only the config goes to stdout; warnings such as glci: skipping project include ...: no GitLab token configured go to stderr, so a redirect keeps the file clean while still telling you something was omitted.
The merged output is sensitive: it carries variable values from every included file, including private project:/component: includes fetched with your token, plus any --input values. Review it before pasting into an issue or committing.
Include-resolution warnings and errors on stderr quote the include: path after $VAR expansion, so mark any local secret an include path interpolates with --mask KEY (or a preset’s masked = [...]) and its value is replaced with [MASKED]. glci marks nothing on your behalf, for the same reason GitLab does not: a value hidden locally but printed by a real pipeline would be worse than one you knew was visible. See Masking in include output; --unmask shows the real values while debugging.
Daemon logs#
The glci daemon writes logs to ~/.glci/daemon.log with details not visible in job output: mock server requests, runner container lifecycle, child pipeline events, cross-project trigger resolution, and scheduler decisions.
tail -f ~/.glci/daemon.log # follow in real time
glci system logs info # check file size
glci system logs clean # clear the log file (safe while daemon runs)
Log lines are prefixed with their source: daemon: (orchestration), mock-server: (API handling), runner: (gitlab-runner output).
Job logs#
glci log # all logs from latest pipeline
glci log 5 build-job # specific job from pipeline #5
glci history # find child pipeline IDs
glci log 7 child-job-name # child pipeline job log
glci log streams live output for running jobs and reads from disk for completed jobs.
Common issues#
Job is unexpectedly skipped#
Check which context is active (default is merge_request). Compare glci jobs --context merge_request vs glci jobs --context branch=main. If the job appears in one but not the other, the issue is in rules:. Also check workflow: rules:, which can reject the entire pipeline — see workflow rules.
Job fails with “variable not found”#
Isolate the source: glci run --secrets none my-job (remove remote vars), then glci run --secrets all my-job (add them back). Override with --env MISSING_VAR=value to confirm.
Trigger job fails immediately#
- Child pipelines: verify the include file exists on disk.
- Cross-project triggers: ensure
--project-dir group/project=../projectis set. extends target "..." not foundfrom a trigger job usually means the child’s owninclude: project:/component:was skipped for want of a token; look forskipping project includejust above it in the job output.- Check
tail -20 ~/.glci/daemon.logfor resolution errors.
Child pipeline runs but is missing jobs#
A child pipeline’s config resolves its own include: entries. If one names a project:/component: and no token is configured, the include is skipped and every job it defined is simply absent – the child still passes. Look for skipping project include <path>: no GitLab token configured in the trigger job’s output or the daemon log, and compare against glci jobs -f child.yml, which parses the same file at top level.
Include resolution fails#
include: project:requires a GitLab token – runglci doctorto verify. This applies to a child pipeline’s or cross-project target’s config just as it does to.gitlab-ci.yml.include: component:with a version selector (@~latest, or a partial version such as@2or@2.3) queries the Releases API. With no token at all the include is skipped rather than failed –skipping component include <path>: no GitLab token configured– and whatever it defined is simply absent. A token that exists but cannot read releases warnscomponent include "<project>@2.3": falling back to "2.3" as a tag or branch name — listing releases failed: fetching releases for "<project>": HTTP 403 (403 Forbidden)and carries on;@~latesthas no such fallback and fails outright. If nothing semver is published,"~latest" matches no published release; a git tag alone is not enough. A partial version matching neither a release nor a ref reportsnothing to read at "2.3" — no published release matches it, and GitLab returned 404 for that ref.$[[ component.reference ]]in an included file aborts the parse withunknown interpolation provided: `reference` in `component.reference`unless that file opts in with aspec:header listingreferenceundercomponent:and was reached through aninclude: component:entry (a component’s nestedlocal:includes count;project:,remote:andtemplate:do not). A file with nospec:header keeps the literal instead, and whatever it pins then fails as a missing file. See component includes.@~Nand@~N.Mare rejected:version selector "~2" is not valid on GitLab; use "@2" instead. GitLab spells only~latestwith a tilde, so drop it and use@2or@2.3.- Max nesting depth is 10 levels.
- Every top-level include location must end in
.ymlor.yaml, or you getlocal include "ci/child.txt": path must end in .yml or .yaml. GitLab checks the last path segment only, case-insensitively, and requires at least one character before the dot – soci/.ymlis rejected too. The check runs before the file is read, so a wrong extension reports as one rather than as a missing file. - A remote URL is checked the same way, and its query string or fragment counts as part of the file name.
https://.../f.yml?ref_type=heads– what the GitLab UI’s Open raw gives you – is rejected even though it looks like it ends in.yml. Strip from the?on. include: component:is exempt: a component location isFQDN/project/name@versionand never ends in.yml. So is an include entry carryingrules:, because glci cannot yet tell whether those rules would have dropped it – GitLab discards such an include before validating it. So is a location still holding an unexpanded$VAR, which surfaces through the fetcher’s own error instead.- A wildcard is expanded first and each match is checked, so the pattern itself is never rejected.
local: 'ci/*'fails ifci/holds aREADME.md;local: 'ci/*.yml'next to the sameREADME.mdis fine. Directories a wildcard matches are skipped, as GitLab’s wildcard search returns files only. - A local include may not name a file outside the repository, or you get
local include "../outside.yml" escapes project directory. A path reached through a symbolic link is refused as well, with...: a symbolic link, which GitLab does not follow– GitLab reads includes as blobs and neither traverses through a link nor follows one, so this holds even when the link’s target is inside the repository. - Leaving the repository is refused for every include,
rules:-gated or not. The symlink half alone is exempt for arules:-gated entry, which GitLab discards before examining – see the troubleshooting entry for why. Both rules apply totrigger: include:as well as to top-levelinclude:. glci mergedshows what the includes did resolve to, and reports on stderr the ones that were skipped.
Tips#
- Quick validation loop:
glci show --watchre-renders the pipeline graph on every save. - Environment health check:
glci doctorchecks the container engine (Docker or Podman), daemon, token, CI config, and git. - Simulation mode:
glci run --simulatereplaces scripts with echo commands and produces dummy artifacts. - Effective config:
glci config,glci config --network,glci config --gitlab. - Effective CI config:
glci merged(add--jsonto query it withjq). - Diffing against GitLab:
glci mergedis a useful starting point when glci and GitLab disagree about a config, but normalize the known divergences (key order,default:/variables:placement,extends:) before comparing line by line.