gitlab-runner exec
For years the official answer to “how do I run a CI job locally” was gitlab-runner exec. Understanding why that command failed is useful here, because glci is in large part a response to it.
exec has been removed from GitLab Runner as deprecated and unmaintained, and it is not in the current codebase. The remaining low-level command, gitlab-runner run-single, is not a local-testing tool: it refuses to start without a GitLab URL and a runner token, because it polls a real instance for real jobs.
Why it couldn’t work#
exec took your .gitlab-ci.yml, picked one job out of it, and ran that job. Everything else about a pipeline was out of scope, and the consequences cascaded.
One job, no graph. exec had no notion of stages or needs:, so nothing about pipeline shape could be tested. Whether your DAG is correct, whether a rules: expression excludes the job you thought, whether when: on_failure fires: none of it was answerable.
No artifacts between jobs. Artifact and cache support was the single most-requested improvement (#2226, #28068, #2409) and never arrived. Artifacts are uploaded to and downloaded from a server, and exec had none. With only one job running, there was also nothing to hand them to.
No GitLab at all. $CI_REGISTRY pointed nowhere. CI_JOB_TOKEN authenticated against nothing. Releases, packages, dotenv propagation, child pipelines, and cross-project triggers were all unreachable, so in practice any job that talked to GitLab, which is to say most interesting jobs, could not be run.
The pattern is worth naming: the runner was never the missing piece, GitLab was. exec had the hard half of the problem solved already and was defeated by the half that looks easy.
What glci does with that#
glci keeps the part exec got right and supplies the part it lacked. Job execution is still gitlab-runner, using the official image, unmodified, doing the same work it does in production. What changes is who it talks to. Instead of gitlab.com, it registers against a mock GitLab that glci runs for it, implementing the runner API, git over HTTP, artifacts, an S3-compatible cache, an OCI registry, releases, packages, and per-job HMAC tokens.
Because the server exists, everything exec couldn’t do stops being a special case:
exec couldn’t | Under glci |
|---|---|
| Run more than one job | A DAG-aware scheduler dispatches the whole pipeline, honoring needs:, stages, and resource_group: |
| Pass artifacts | The runner uploads and downloads through its own endpoints against glci’s artifact store |
| Use cache | An S3-compatible cache with SigV4 auth, persisted in a local volume across runs |
Reach $CI_REGISTRY | An embedded OCI registry with pull-through caching, so docker push works |
| Evaluate rules for a context | Context simulation for branch, tag, MR, and environment pipelines |
| Trigger child pipelines | Child and cross-project pipelines, including dynamic trigger:include:artifact |
None of that reimplements runner behavior. It is a server the runner already knows how to use.
And the ergonomics exec never had#
exec was a single blocking invocation that printed to your terminal and forgot everything afterwards. glci treats a local pipeline as a first-class object with a lifetime. A background daemon owns the run, so Ctrl+C detaches instead of killing it, and the run survives closing your terminal. Past runs stay queryable through glci history, glci log, and glci artifacts, and glci show renders the DAG before you commit to running anything. There is a full-screen TUI, --watch re-runs on save, and the daemon recovers in-flight pipelines if it crashes.
If you came here looking for the replacement for gitlab-runner exec, glci is the same idea with the missing half built.