How It Works

glci sits between your .gitlab-ci.yml and the official gitlab-runner. Instead of talking to a real GitLab instance, the runner talks to a mock GitLab API server running as a daemon-scoped container. Jobs execute in real Docker containers with the same shell scripts, image pulls, and lifecycle that GitLab CI uses in production — no simulation, no approximation.

Component overview#

graph TB CLI["glci CLI
glci run · glci show · glci (TUI)"] Daemon["Daemon
Background process
Auto-start/stop · Crash recovery"] Mock["glci-mock
Mock GitLab API · Git HTTP
Artifact store · S3 cache
OCI registry · Internal API"] Runner["Runner Manager
Shared + per-project + named runners
Isolated containers per config"] Jobs["Job Containers
One per job · User's Docker image
CI scripts · Service sidecars"] CLI -->|"Unix socket
~/.glci/daemon.sock"| Daemon Daemon -->|"docker exec
muxproto binary protocol"| Mock Daemon -->|"config volume
SIGHUP reload"| Runner Runner -->|"GitLab runner protocol
Docker DNS"| Mock Runner -->|"Docker executor
spawns containers"| Jobs Jobs -->|"extra_hosts (host-gateway)
FF_NETWORK_PER_BUILD"| Mock

Data flow#

graph LR CLI["CLI"] -->|"Unix socket"| D["Daemon"] D -->|"docker exec
(muxproto)"| M["Mock Server
git · artifacts
cache (S3) · registry (/v2/)"] R["Runner"] -->|"GitLab API
(Docker DNS)"| M D -->|"config volume
SIGHUP"| R R -->|"Docker executor"| J["Job Containers"] J -->|"extra_hosts (host-gateway)"| M

The mock server is the central hub — the only component both the daemon and the runner communicate with. Jobs push/pull artifacts, cache, and container images directly to/from the mock server via extra_hosts (host-gateway) with FF_NETWORK_PER_BUILD.

Infrastructure#

Daemon-managed (shared across pipelines)#

All containers are long-lived. They start on first use and stop when the daemon shuts down. If a container is missing (manually removed, Docker restart), the daemon recreates it on demand. Per-project and named runners are created lazily when a pipeline first needs them.

Per-pipeline#

Pipeline execution lifecycle#

This is the sequence of events when you run glci run [jobs...]:

sequenceDiagram participant CLI participant Daemon participant Mock as Mock Server participant Runner participant Job as Job Container CLI->>Daemon: StartPipeline (Unix socket) Daemon->>Daemon: Parse .gitlab-ci.yml (offline) Daemon->>Daemon: Create Docker network (glci-net-{id}) Daemon->>Daemon: Prepare bare git repo (tar.gz) Daemon->>Mock: Ensure container running + connect to network Daemon->>Mock: Upload git repo Daemon->>Runner: Ensure container running + connect to network loop Each job (DAG-aware scheduler) Daemon->>Mock: Queue job Runner->>Mock: Poll for job (POST /api/v4/jobs/request) Mock->>Runner: Job payload Runner->>Mock: Clone source (git HTTP) Runner->>Mock: Download dependent artifacts Runner->>Job: Execute scripts in Docker container Job->>Mock: Push artifacts / cache / registry images Runner->>Mock: Stream trace output (PATCH) Mock->>Daemon: Events (EventBus) Daemon->>CLI: Stream events (NDJSON) Runner->>Mock: Job complete (PUT) end Daemon->>Daemon: Persist artifacts to ~/.glci/

Detailed steps#

  1. CLI sends StartPipeline to daemon via Unix socket (daemon auto-starts if not running). If another pipeline is already being prepared for the same directory, the request is queued instead of rejected — the daemon holds the HTTP connection open and starts the queued pipeline automatically when the current preparation finishes. Cancelling the request (Ctrl+C) dequeues it. A maximum queue depth of 3 prevents resource exhaustion.
  2. Daemon parses .gitlab-ci.yml — offline parser resolves includes, extends, !reference tags; evaluates workflow/job rules; expands parallel: matrix:; builds execution plan (stages, DAG, auto-include upstream deps)
  3. Daemon creates per-pipeline Docker network (glci-net-{id})
  4. Daemon prepares source code — creates a bare git repo from the working directory (including uncommitted/untracked files in dirty mode), tar.gz for upload
  5. Daemon ensures mock container is running — connects to pipeline network, registers per-pipeline state (secret), configures S3 cache credentials, uploads git repo
  6. Daemon ensures runner container is running — connects to pipeline network, Docker socket or DOCKER_HOST for remote Docker
  7. Scheduler dispatches jobs — concurrent within stages, DAG-aware, respects resource_group exclusions. Per job: queue on mock server with HMAC token, persistent runner polls for job, clones source, downloads artifacts, executes scripts, uploads artifacts, streams trace, reports completion
  8. Trigger jobs spawn child pipelines sharing the same mock and runner containers (max depth: 2)
  9. Daemon persists artifacts to ~/.glci/projects/{key}/pipelines/{id}/
  10. EventBus streams events to all connected CLI clients

Mock server API#

The mock server (pkg/mockserver/) is the central hub. It implements the subset of the GitLab API that gitlab-runner needs, plus infrastructure services and an internal control API consumed by the daemon.

GitLab runner API#

These endpoints are consumed by the runner, which thinks it is talking to a real GitLab instance:

EndpointPurpose
POST /api/v4/jobs/requestToken-routed job dispatch — each job is routed to the runner presenting its unique HMAC-derived token
PUT /api/v4/jobs/{id}Job state updates (running/success/failed)
PATCH /api/v4/jobs/{id}/traceTrace output chunks streamed in real time
POST /api/v4/jobs/{id}/artifactsArtifact upload (multipart)
GET /api/v4/jobs/{id}/artifactsArtifact download for needs:/dependencies:
GET /api/v4/personal_access_tokens/selfToken validation for glab auth login --job-token
POST /api/v4/projects/{path}/releasesRelease creation
PUT /api/v4/projects/{id}/packages/generic/...Generic package upload
GET /api/v4/projects/{id}/packages/generic/...Generic package download
GET /api/v4/projects/{path}/repository/tags/{tag}Tag info lookup
GET /api/v4/projects/{path}/releases/{tag}Release info
POST /api/v4/projects/{path}/releases/{tag}/assets/linksRelease asset link creation

Infrastructure services#

EndpointPurpose
GET /{path}/info/refsGit smart HTTP ref advertisement (dynamic path per pipeline)
POST /{path}/git-upload-packGit smart HTTP pack data
PUT /cache/{key}S3-compatible cache upload (AWS Signature V4 auth)
GET /cache/{key}S3-compatible cache download
HEAD /cache/{key}S3-compatible cache existence check
GET /cacheS3 GetBucketLocation (region detection)
/v2/*OCI container registry (embedded, see Embedded Registry)

Internal API (daemon to mock server)#

These endpoints are consumed by the daemon to manage pipelines. They are not authenticated via the runner protocol — they go through the docker exec transport:

EndpointPurpose
POST /internal/configSet pipeline-wide configuration
POST /internal/pipelineRegister a new pipeline (multi-pipeline routing)
DELETE /internal/pipeline/{id}Tear down per-pipeline state
POST /internal/queue-jobQueue a job for dispatch to runner
POST /internal/cancel-jobCancel running/pending jobs for a pipeline (or a single job)
POST /internal/register-runnerRegister a project runner for tag-based dispatch
POST /internal/unregister-runnerUnregister a project runner
POST /internal/upload-git-repoUpload bare git repo as tar.gz
GET /internal/git-repo-shaGet SHA of uploaded git repo
POST /internal/reuse-git-repoReuse git repo from a previous pipeline
POST /internal/apply-git-overlayApply incremental overlay to git repo
POST /internal/artifact/{jobID}Inject reused artifact data
GET /internal/artifact/{jobID}Retrieve artifact data after job completion
GET /internal/trace/{jobID}Retrieve complete trace for a job
GET /internal/eventsHybrid binary/JSON event stream
GET /internal/statusGround-truth job state (crash recovery)
GET /internal/healthHealth check
GET /internal/cache/keysList all cache keys
GET /internal/cache/get/{key}Retrieve cached entry
PUT /internal/cache/preload/{key}Preload cache entry
GET /internal/releasesList releases created by pipelines
GET /internal/packages/keysList generic package keys
GET /internal/packages/get/{key}Retrieve generic package
POST /internal/push-through-configSet push-through mirror configuration
GET /internal/registry-statsRegistry storage statistics
POST /internal/registry-cleanClean registry blobs

Docker exec transport#

The daemon communicates with the mock server container exclusively through docker exec, which enables remote Docker daemon support (TCP via DOCKER_HOST, Docker contexts, SSH tunnels, cloud Docker).

Three transport implementations exist in pkg/daemon/transport.go:

TransportHow it worksUsed for
httpTransportDirect HTTP clientUnit tests with in-process mock server
execTransportOne-shot docker exec <container> glci internal proxy <METHOD> <PATH>Long-lived streaming (/internal/events)
persistentExecTransportLong-lived docker exec -i <container> glci internal proxy-session with binary framingAll control-plane requests

The persistentExecTransport keeps a single subprocess alive, multiplexing all requests over shared stdin/stdout with the muxproto binary protocol. This avoids the overhead of spawning a new docker exec for every API call.

Muxproto binary protocol#

pkg/muxproto/ implements a length-prefixed binary protocol for multiplexing HTTP-like request/response pairs over a byte stream:

Request:  int64(methodLen) method | int64(pathLen) path | int64(ctLen) contentType | int64(bodyLen) body
Response: int32(statusCode) | int64(bodyLen) body

All integers are big-endian. Max frame size is 1 GB (matching the artifact upload limit). The transport auto-restarts on stream corruption.

Event streaming#

The mock server emits events on /internal/events as a hybrid binary/JSON stream:

Both event types flow on a single FIFO channel per subscriber (buffer 4096), so a job_result never overtakes an earlier trace frame. Trace drops are tolerated and reconciled via GET /internal/trace. Control drops fall back to /internal/status polling. The daemon also polls /internal/status every 5 seconds as a safety net for results missed during event stream reconnection.

EventBus#

The daemon’s EventBus (pkg/daemon/eventbus.go) manages in-memory event storage and streaming for CLI consumers:

Scheduler#

The scheduler (pkg/scheduler/) provides DAG-aware concurrent job dispatch with concurrency limits and resource_group support.

Scheduling modes#

ModeWhen usedBehavior
Stage-basedJobs without needs:All jobs in a stage run in parallel. Next stage starts when all jobs in the current stage complete.
DAGJobs with needs: (even empty needs: [])Job starts as soon as all its declared dependencies complete, regardless of stage boundaries. needs: [] means no dependencies — start immediately.
MixedPipeline has both typesDAG jobs follow their needs:; stage-based jobs wait for all prior stages.

Job lifecycle states#

Jobs move through these states: pending -> running -> done (or skipped). Manual jobs (when: manual) start in a separate manual state and move to pending only when triggered.

Skip logic#

Authentication#

Pipeline-level security without a real GitLab instance:

  1. Pipeline secret: 32 bytes from crypto/rand, generated per pipeline
  2. Job tokens: HMAC-SHA256(secret, jobID) — each job gets a unique token
  3. Runner dispatch: job routed to the runner presenting the matching token (prevents FIFO dispatch races when multiple jobs are queued)
  4. Git auth: HTTP Basic with job token
  5. Registry auth: Docker-standard token auth
  6. Cache (S3): AWS Signature V4 with daemon-secret-derived credentials (stable across pipeline runs)

Crash recovery#

The daemon performs recovery on startup (pkg/daemon/recovery.go):

  1. Find recoverable pipelines: Scan ~/.glci/active-workdirs/ markers, read execution state from ~/.glci/projects/{key}/pipelines/{id}/execution.json, check if both mock and runner containers are still alive via docker inspect.
  2. Preserve resources: Build sets of container names, volume names, and network names belonging to recoverable pipelines.
  3. Clean orphaned resources: Remove any glci-* containers, volumes, and networks not in the preserve sets. Also clean stale temp files.
  4. Finalize stuck pipelines: Mark pipelines without surviving containers as failed in history.
  5. Resume: Reconnect to containers via docker exec, query ground-truth state via GET /internal/status, reconcile with persisted state, and re-dispatch pending jobs.

Execution state is written atomically (temp file + rename) on every job state transition. It contains container names, job states, runner tokens, pipeline secret, and the original StartPipelineRequest for re-parsing CI config during resume.

CI config parsing#

glci includes a full offline .gitlab-ci.yml parser as the primary parsing path (pkg/config/). It implements GitLab’s processing pipeline in Go:

  1. Load YAML with yaml.Node-based parser preserving !reference tags
  2. Interpolate inputs ($[[ inputs.key ]]) before and after include resolution
  3. Resolve all includeslocal: (disk), remote: (HTTP), template: (GitLab CDN), project: (API), component: (API). Component version selectors (@~latest, @~N, @~N.M) are resolved to the latest matching semver tag via the GitLab Tags API before fetching. Includes are cached and deduplicated.
  4. Resolve extends: — recursive deep merge with circular/depth detection (10 levels max)
  5. Resolve !reference tags — path lookup with cycle detection, sequence flattening
  6. Inject edge stages (.pre/.post)
  7. Apply default: inheritance with inherit: control (true/false/[keys])
  8. Parse workflow: rules with auto_cancel: support
  9. Parse all job fields into enriched types (trigger, release, retry, environment, hooks, id_tokens, etc.)
  10. Expand parallel: matrix: jobs

Also handles: rules: changes: (git diff matching), rules: exists: (filesystem glob), YAML anchors/aliases, multi-document YAML.

API-assisted mode (optional)#

Set GLCI_PREFER_API=1 to use the GitLab CI Lint API for YAML resolution. This requires a GitLab token but delegates include:, extends:, and !reference resolution to the server. The offline parser is the default because it requires no network access and handles the vast majority of CI configurations.

API Lint response — field inventory#

When using API-assisted mode, glci calls POST /projects/:id/ci/lint with include_jobs: true, dry_run: false. The API returns merged_yaml (fully resolved YAML) and a jobs array. These fields are available from each source:

FeatureIn merged_yaml?In jobs array?Notes
include: resolutionYes (key removed)N/ARecursively merged
extends: resolutionPartially (keys remain)N/AValues merged, key not removed
!reference tagsYesN/AResolved to actual values
rules: evaluationdry_run onlydry_run onlyStatic returns ALL jobs
needs:N/AStatic mode onlyNOT in dry_run mode
image:, services:, variables:, artifacts:, cache:YesNoMust parse from merged_yaml
parallel: matrix:Yes (unexpanded)NoMust expand ourselves
$[[ inputs ]]Yes (expanded)N/AComponent interpolation done
$VAR expansionNoNoShell expands at runtime

Daemon API#

The daemon runs as a background process and exposes an HTTP/JSON API over a Unix socket (~/.glci/daemon.sock). The CLI is the primary consumer.

graph LR CLI["glci CLI"] -->|"HTTP/JSON
Unix socket"| Daemon["Daemon
~/.glci/daemon.sock"] Daemon -->|"docker exec"| Mock["Mock Server"] Daemon -->|"config volume
SIGHUP"| Runner["Runner"]
EndpointPurpose
POST /api/start-pipelineStart a pipeline (accepts parsed jobs, variables, options). If another pipeline is being prepared for the same directory, the request is queued (max depth 3) and the connection is held open until preparation finishes. Context cancellation dequeues the request.
POST /api/cancel-pipelineCancel a running pipeline
POST /api/cancel-jobCancel a single job
POST /api/trigger-manual-jobTrigger a manual (when: manual) job
GET /api/pipeline-statusPipeline status for a project
GET /api/watch/{pipelineID}Event stream for a pipeline (NDJSON)
GET /api/logs/{pipelineID}/{jobName}Stream job log output
GET /api/registry-infoRegistry connection info
GET /api/registry-catalogList all repositories and tags in the embedded registry
GET /api/registry-manifestFetch a manifest by repo and ref (tag/digest)
GET /api/registry-blobDownload a blob by repo and digest
POST /api/registry-cleanClean up registry data
GET /api/active-pipelinesList all active pipelines and their job statuses
GET /api/healthDaemon health check

The daemon auto-starts on the first glci run and auto-stops after 30 minutes of idle. A version guard auto-restarts it if the CLI/daemon build versions mismatch.

Esc