Why glci
There are only a handful of ways to find out whether a change to .gitlab-ci.yml works: push it and watch, run a tool that imitates GitLab CI on your machine, or stand up a real GitLab and a real runner. Each answers the same question, “can I trust this result?”, with a different set of compromises.
Those compromises are what this section covers. Every tool mentioned here is a reasonable choice for somebody, so what follows is the case for when that somebody is you.
Whether you can trust the result#
A local run only earns its keep if it stops you from pushing to check. Three properties decide that.
The first is execution fidelity: how closely the thing running your job resembles the thing that will run it in production. Not the container image, but the layer above it. Which shell wrapper gets generated around your script, what order before_script, script, and after_script run in and what happens when one of them fails, how artifacts: globs are matched, when pull_policy re-pulls, how service containers are named and aliased, which FF_* feature flags are on.
The second is environment fidelity: whether the GitLab around the job is there. Most CI failures are not in your script. They are in the plumbing: a docker push to $CI_REGISTRY, an artifact a downstream job can’t find, a cache key that never hits, a dotenv report that doesn’t propagate, a child pipeline that never triggers, a CI_JOB_TOKEN that can’t authenticate. A tool that runs your script: faithfully but has no GitLab behind it cannot reproduce any of those.
The third is iteration cost: seconds versus minutes, one job versus the whole pipeline, and whether a failed experiment is free. People notice this one first, and it decides whether the tool gets used at all.
These three pull against each other, which is why most approaches sacrifice one to get the others.
| Approach | Gives you | Gives up |
|---|---|---|
| Pushing to test | Perfect fidelity, since it is production | Minutes per attempt, a commit log full of fix ci, real compute for every failed guess |
| Local reimplementations | A fast loop, trivial installation | Execution semantics are re-derived by hand, so they drift from the runner exactly where you needed the truth |
gitlab-runner exec | The real runner binary | One job at a time, no pipeline graph, no server, and the command has been removed from GitLab Runner |
| A real GitLab + runner | Both kinds of fidelity | Gigabytes of RAM, minutes to boot, and you still have to push to run anything |
How glci splits the problem#
glci takes neither of the two obvious routes. It does not reimplement job execution, and it does not ask you to rewrite your pipeline. It splits the problem where the seam already is.
Job execution stays with the real runner. glci runs the official gitlab/gitlab-runner image and lets it work. Every behavior in the execution-fidelity list above is inherited rather than modeled, because the same code produces it in production. When the runner changes how something works, glci picks that up by bumping an image tag, so there is no catching up to do and no divergence waiting to be discovered.
GitLab is the part that gets faked. glci implements the server side: the runner API, git over HTTP, an artifact store, an S3-compatible cache, an OCI registry serving $CI_REGISTRY, releases, generic packages, and HMAC job tokens. This is the tractable half of the problem, because it is a documented HTTP surface, whereas job execution is a decade of accumulated behavior. It is also why docker push $CI_REGISTRY_IMAGE, artifact passing through needs:, cache hits across runs, and child pipelines work by the same mechanism as production instead of being special-cased one at a time.
Everything then stays warm. A background daemon keeps the mock server and runner containers alive between runs, the embedded registry caches every image you pull, and the CI cache persists in a local volume. The first run pays for cold containers; later runs skip the image pulls and container creation entirely. You can run one job, run one stage, re-run with a different variable, or leave --watch on and let it re-run as you type, all against your uncommitted working tree.
See How it works for the architecture underneath.
Why the fidelity claim is checkable#
“Behaves like real GitLab CI” is easy to assert, so glci is built to be caught out on it. Its parser is diffed against ground truth from GitLab itself. For a set of real projects, including gitlab-runner and step-runner, the test suite fetches the authoritative post-rules job list from GitLab’s CI Lint API (dry_run=true&include_jobs=true) and fails if glci’s stages, job names, when, or allow_failure disagree. It also parses the GitLab monolith’s own pipeline, a couple of hundred jobs of it, as a regression fixture.
That is a harder bar than “the tests we thought to write pass,” because the tests are allowed to disagree with GitLab, and when they do, GitLab wins. The testing guide covers the tiers and how to run them.
Where to start#
Pushing to test is the alternative almost everyone is actually using, and the comparison with the strongest numbers. After that, read the page for whichever tool you were considering.
Weigh all of it against the gaps, because glci is an experimental project and has real ones: no Kubernetes executor, no shell executor, and a container engine as a hard prerequisite. The known limitations are worth reading before you commit to it. If your pipeline lands in that territory, one of the other tools here is the better answer, and it is cheaper to find that out now.
- Pushing to Test — Why a local run beats the push-and-watch loop that most teams default to
- gitlab-ci-local — glci runs the real runner against a fake GitLab; reimplementations run a fake runner against no GitLab
- gitlab-runner exec — The official local-run command was removed, and the missing piece was never the runner
- A Real GitLab + Runner — Running an actual GitLab and runner locally buys fidelity you already had, and keeps the slow loop