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’tUnder glci
Run more than one jobA DAG-aware scheduler dispatches the whole pipeline, honoring needs:, stages, and resource_group:
Pass artifactsThe runner uploads and downloads through its own endpoints against glci’s artifact store
Use cacheAn S3-compatible cache with SigV4 auth, persisted in a local volume across runs
Reach $CI_REGISTRYAn embedded OCI registry with pull-through caching, so docker push works
Evaluate rules for a contextContext simulation for branch, tag, MR, and environment pipelines
Trigger child pipelinesChild 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.

Esc