Pushing to Test
glci’s real competition is not another tool. It is the habit of committing a change, pushing it, and watching the pipeline. That loop has one enormous advantage, in that it is production and its verdict is final, and everything below is an argument about what it costs to consult.
The loop is minutes long#
Count what happens between saving the file and learning anything. You stage and commit, push, GitLab creates the pipeline, a runner picks the job up, provisions a container, pulls the image, clones the repository, restores cache, and only then runs the first line of your script. On a shared instance you can add queue time to the front of that, and queue time is the part that gets worse when your team is busy.
glci deletes most of that list instead of speeding it up. The mock server and runner containers are already running, held by the background daemon. Images are already in the embedded registry from the last run. The CI cache is a local Docker volume that survives between runs, and there is no queue to wait in.
The difference compounds because you can run less. In real CI the smallest unit you can trigger is a pipeline, and a retry of one job still waits for a runner. Locally the smallest unit is one job:
glci run test:unit # just this job
glci run --stage build # just this stage
glci run -p precheck # a named preset of jobs
glci run test:unit --env LOG_LEVEL=debug
glci run test:unit --watch # re-run on every save
That is the difference between checking a hypothesis and guessing at one.
You stop committing to ask questions#
Every push-to-test cycle leaves a commit behind, and everyone knows the shape of the resulting history:
fix ci
fix ci again
try without the cache
revert
actually fix ci
Then you squash, which hides which of the five attempts was the one that mattered. If the branch is shared, each attempt is also a broken pipeline for whoever else is on it, and if the pipeline touches a shared environment, some attempts are worse than broken.
glci runs your working tree, uncommitted and untracked files included, and it respects .gitignore while doing so. There is nothing to commit and nothing to clean up afterwards. Use --no-dirty when you specifically want to test only committed code.
Failed guesses stop costing compute#
Every speculative pipeline is billed. On GitLab.com that is CI minutes; on self-managed runners it is capacity somebody else is waiting for. The economics reward pushing a batch of changes and hoping, which is the opposite of what debugging wants, and it is why “add one debug echo and push again” feels expensive even though it is the correct next step.
Locally the marginal cost of an attempt is a few seconds of your own CPU. Adding a debug line, re-running with a different variable, and bisecting a rules: expression become free actions, so you actually do them.
A pipeline you can inspect#
Real CI hands you a log and, if you were foresighted, an artifact. Everything else about the run is gone by the time you read about it. Locally the run is still sitting on your machine:
glci log <pipeline-id> <job> # full trace, any past run
glci artifacts extract <id> <job> # unpack what the job produced
glci artifacts diff <id1> <id2> # what changed between two runs
glci pages serve # open the Pages output in a browser
glci variables <job> # every variable and where it came from
glci show # the DAG, before you run anything
glci variables is worth calling out, because “which value won, and why” is a question the real pipeline answers only by implication. It prints each variable with its source, resolved through GitLab’s full precedence order.
When the pipeline is wrong in a way that isn’t your code’s fault, say an image that needs a platform pin, you can override the field per job in .glciconfig.toml instead of editing .gitlab-ci.yml to work around a local quirk. Your committed pipeline stays honest.
Ask about contexts you can’t easily push#
Some pipelines are hard to trigger on purpose. Tag pipelines want a tag and merge request pipelines want a merge request; scheduled pipelines want you to wait, or to fake $CI_PIPELINE_SOURCE and hope. Testing what happens on main means merging to main.
Context simulation turns those into a flag:
glci show --context tag=v1.0
glci show --context branch=main
glci run --context merge_request --mr-source feature --mr-target main
Seeing which jobs a tag pipeline would create before the tag exists is not a faster version of something you were already doing, because there was no fast way to do it.
What pushing still wins#
This is a real list, and glci is not trying to argue it away:
- Protected branches and protected variables. glci fetches variables regardless of protection state and does not model protected-branch rules. If a bug depends on a variable being withheld, only the real pipeline shows it.
CI_JOB_TOKENscope. Locally it is your personal token wearing a job token’s name, with your permissions.--no-tokenand--secrets nonelet you test the absence of credentials, which is often the failure you care about.- Real runner fleets. Tags, hardware, sizes, cloud metadata, network egress rules, and Kubernetes runners are properties of your infrastructure, not your pipeline.
rules: changes:against the target branch. GitLab diffs against the merge target, while glci works from local git state. Related edge cases are catalogued in troubleshooting.- The final word. The pipeline that gates your merge request is the one that gates your merge request.
That last point is the whole aim. glci does not propose to replace that run. It proposes that you should reach it on your first push instead of your seventh, having already answered every question that did not require production to answer.