sklp ci — pipeline reference
Configuration for `sklp run ci`, the lint + test runner that gates Pull Requests. Diff-aware step gating via scopes, deterministic tool installs, and live service dependencies (Postgres, NATS, …) materialised as sidecars in K8s or local processes/containers on a laptop.
For AI coding agents (Claude Code, Codex, Cursor)
This page is your single source of truth for authoring or editing a
.sklp/pipelines/ci.yaml. The samekind: pipelineschema is used by_base.yaml(shared header) and any other task file that inherits from it (extends: _base).Hard rules: every value in
step.scopemust be a key inpipeline.scopes. Every value instep.servicesmust be a key inpipeline.services. A service is eitherrun:(shell) orimage:(OCI container), never both — same rule as devstack.oneshot: truecannot haveready:. Never invent ports — read the source or ask the user.
Mental model
A CI pipeline is a list of steps. Each step has a shell run: command and an optional scope: (which decides when it runs) and services: (which decides what infra is live during it).
- Scopes are named glob lists declared once in
_base.yaml. A step'sscope: [a, b]means "run me when at least one changed file matches scopeaOR scopeb". - Services are long-running dependencies declared in a top-level
services:map. A step'sservices: [postgres]means "make surepostgresis up and Ready before myrun:starts".
The same YAML works in two execution contexts:
| Context | How services are materialised |
|---|---|
Local laptop (sklp run ci) | Process via run: or OCI container via image: — auto-detected runtime (docker / podman / nerdctl) |
Kubernetes (CI Pod via K8sJobSpawner) | Sidecar containers in the same Pod, with an initContainer that waits for each Ready probe before the runner container starts |
The runner detects K8s via the SKLP_RUNNER_MODE=k8s env var injected by the spawner. You don't change the YAML.
Quick start
# .sklp/pipelines/_base.yaml
version: 1
tools:
go: "1.25.0"
node: "22.19.0"
pnpm: "10.27.0"
scopes:
backend:
- "apps/core/**"
- "go.work"
frontend:
- "apps/web/**"
- "pnpm-lock.yaml"
shared:
- ".sklp/**"
- "Makefile"
# .sklp/pipelines/ci.yaml
version: 1
kind: pipeline
extends: _base
name: ci
services:
postgres:
image: postgres:16-alpine
ports: ["5432:5432"]
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ready:
tcp: localhost:5432
timeout: 60s
steps:
- name: test-go
scope: [backend, shared]
tools: [go]
run: make test-core
- name: test-integration
scope: [backend, shared]
tools: [go]
services: [postgres]
run: make test-integration
- name: build-web
scope: [frontend, shared]
tools: [node, pnpm]
run: pnpm --filter @app/web run build
Run it:
sklp run ci # diff vs origin/main, dry-run by default
sklp run ci -base-ref HEAD~3 # explicit base
sklp run ci -dry-run=false # actually execute mutating commands
Top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
version | int | yes | Schema version. 1 today. |
kind | string | yes | Must be pipeline. |
name | string | yes | Logical name, surfaced in logs. |
extends | string or [string] | no | One or more sibling files (without .yaml) to merge as parents. Current file's values win on key collision. |
tools | map[string]string | no | Tool name → version pin. The runner installs these on demand per step. |
scopes | map[string][]string | no | Scope name → list of doublestar globs. Referenced by step.scope. |
services | map[string]Service | no | Service name → spec. Referenced by step.services. |
env | map[string]string | no | Environment merged into every step's shell. |
env_files | []string | no | .env files loaded before env (which wins on collision). |
secrets | []string | no | Names of secrets the spawner must resolve and inject as env. |
steps | []Step | yes | The ordered list of steps. |
Step
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Human label. |
run | string | yes | Shell command(s) executed via sh -c. Multi-line YAML supported. |
scope | string or [string] | no | One or more scope names. Empty = the step always runs. OR semantics across listed scopes. |
services | []string | no | One or more service names from the top-level services: catalogue. Must be live and Ready before run: starts. |
tools | []string | no | Subset of pipeline-level tools resolved for this step only. Empty = inherit all (legacy). |
env | map[string]string | no | Merged on top of pipeline-level env for this step. |
if | string | no | Reserved for a future expression evaluator. Currently rejected with a clear error if non-empty. |
Scope semantics
OR across named scopes scope: [a, b] runs when a OR b matched
OR across globs in a scope scopes[a] = [g1, g2] matches g1 OR g2
OR across changed files any file that matches any glob → match
An unknown scope name in step.scope is a load-time error — no silent miss.
Running with no diff (git failure, missing base, empty changeset) defaults to run everything — safer than silent skip.
Overriding the diff
The diff decides what runs, but two flags take that decision back:
sklp run ci -resolve-only # list what would run; execute nothing
sklp run ci -force # run every step, whatever the diff says
sklp run ci -only frontend # run only the steps scoped to `frontend`
sklp run ci -only frontend,shared # comma-separated, OR semantics
They apply to any pipeline, so the same flags drive a release — sklp run publish -only core republishes one service from a clean tree, without forcing every image.
-force widens (every scoped step is in-scope), -only narrows (only the named scopes run, diff ignored). When both are passed -only wins — asking for one service must never publish three.
Two things worth knowing about -only:
- A step with no
scope:is never selected. It names nothing, so no-onlyvalue can ask for it — the flag means "these scopes", not "these scopes plus whatever else runs unconditionally". - An unknown scope is a hard error listing the declared names, not an empty selection. A typo must not report a green run that built nothing.
Neither flag bypasses the content-addressed cache: a step whose inputs are unchanged still serves from cache. Pair with -no-cache for a real rebuild.
-resolve-only honours both flags, so the printed plan matches what a real run would do.
Services semantics
step.services: [postgres, nats]declares dependencies; both must be Ready before the step starts.- Service definitions live in the top-level
services:map (same shape assklp dev). - Unknown service names fail at load time.
services:is independent ofscope:— a step can be gated by scope AND require services. If the step is skipped (no scope match), its services are not started.- The runner starts the union of all services used by at least one step that will run, once per pipeline invocation. Unused catalogue entries are not booted.
Service
Same schema as sklp dev services. Either run: (local process) or image: (OCI container).
| Field | Type | When | Description |
|---|---|---|---|
run | string | exclusive with image | Shell command launched via sh -c, kept alive for the pipeline duration. |
image | string | exclusive with run | OCI image reference. Runtime auto-detected on PATH: docker → podman → nerdctl. |
command | []string | with image only | Override the image's default CMD. |
ports | []string | with image only | host:container or :container. In K8s sidecars the host port is ignored (Pod-local network). |
volumes | []string | with image only | Named volumes only (pg-data:/var/lib/postgresql/data). Bind mounts are rejected for safety. In K8s, mapped to emptyDir. |
env | map[string]string | any | Overlays the pipeline env for this service. |
workdir | string | with run only | Repo-root relative. |
depends_on | []string | any | Names of other services that must reach Ready first. Cycles fail at load time. Topo-sorted by the orchestrator. |
ready | Ready | any | Probe describing how the supervisor knows the service is up. See below. |
oneshot | bool | with run only | The service runs to completion (exit 0); ready is illegal in this case. Typical use: schema migrations. |
Ready probe
Exactly one of http, tcp, log, file should be set.
| Field | Type | Description |
|---|---|---|
http | string | URL polled with GET; any 2xx = ready. |
tcp | string | host:port opened with net.DialTimeout. |
log | string | Regex matched line-by-line against the service's stdout/stderr. Not supported in K8s sidecars — use http or tcp. |
file | string | Path that must exist on disk. Not supported in K8s sidecars. |
timeout | string | Go duration (30s, 2m). Default 30s. |
Worked example — integration tests with Postgres
version: 1
kind: pipeline
extends: _base
name: ci
services:
postgres:
image: postgres:16-alpine
ports: ["5432:5432"]
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ready:
tcp: localhost:5432
timeout: 60s
migrate:
run: go run ./cmd/migrate up
workdir: apps/core
env:
DATABASE_URL: postgres://test:test@localhost:5432/test_db?sslmode=disable
depends_on: [postgres]
oneshot: true
steps:
- name: test-unit
scope: [backend, shared]
tools: [go]
run: go test -short ./...
- name: test-integration
scope: [backend, shared]
tools: [go]
services: [migrate] # depends_on: [postgres], so postgres comes up too
env:
DATABASE_URL: postgres://test:test@localhost:5432/test_db?sslmode=disable
run: go test -run Integration ./...
What happens on sklp run ci -base-ref origin/main:
- Diff computed: list of changed files vs
origin/main. - Step gating: each step's
scope:checked against the diff. Steps with no matching files are skipped. - Service union computed across the steps that will actually run. If
test-integrationis among them,migrateis requested; itsdepends_on: [postgres]pulls inpostgrestoo. - Topo-sort + start:
postgresboots first, the orchestrator waits fortcp: localhost:5432(≤ 60s). Thenmigrateruns to completion (exit 0 = ready); failure aborts the pipeline. - Steps execute in order.
test-integrationseesDATABASE_URLpointing at the live Postgres. - Teardown on pipeline exit (success or failure, including SIGINT/SIGTERM): all services stopped, OCI containers removed.
Local vs K8s — what changes
| Aspect | Local | K8s |
|---|---|---|
Process services (run:) | Spawned via sh -c, killed via SIGTERM/SIGKILL on teardown | Not used — declare image: for parity. The Pod model is container-only. |
Container services (image:) | Run via auto-detected runtime: docker run / podman run / nerdctl run. Container name: sklp-ci-<svcname> | Sidecar in the same Pod. The runner container shares localhost with all sidecars. |
| Readiness | Probed by the orchestrator before the first step starts | initContainer named wait-for-services polls each sidecar (nc -z localhost <port> for TCP, wget for HTTP) before the runner container starts |
| Cleanup | Orchestrator teardown via defer, also fires on signal | Pod deletion takes care of it |
| Service names | Container names prefixed sklp-ci- to avoid clash with sklp dev | Container name: = service name (must be DNS-1123 label compatible) |
The SKLP_RUNNER_MODE=k8s env var is set by the spawner; the runner uses it to skip the local orchestrator path entirely.
CPU budget — how much one run may use
Every CI run is given a CPU budget, a whole number of cores, decided by the spawner before the space starts. It is the single knob that bounds what one run can do to the host, and it is enforced at three levels so it holds whether or not cgroups can be applied:
| Level | Mechanism | What it bounds |
|---|---|---|
| Space | cgroups v2 cpu.max on the runner container (CPULimit, quota over a 100ms period) | The hard ceiling for the runner and everything it execs, kernel-enforced — best-effort: silently dropped on hosts without cgroups v2 delegation (see below). |
| Runner | SKLP_CPUS=<budget> in the runner's env → default -jobs | The number of steps the scheduler runs concurrently. -jobs N on the command line still wins. |
| Step | GOMAXPROCS=<budget> in every step's env | Go toolchain parallelism (go build/go test -p default to it). A GOMAXPROCS in the step's env: wins. |
Without the budget, -jobs and the Go toolchain both default to runtime.NumCPU(), which ignores cgroups: three concurrent runs on a 10-core host schedule 3 × 10 steps × 10 compiler threads ≈ 300 runnable threads. The env levels need no cgroup at all, so the parallelism collapses even where the cap cannot be applied — the macOS vz guest has no systemd and therefore no delegation.
The default, and how to change it
budget = SKLP_CI_CPUS if set (whole cores, > 0)
= max(2, NumCPU / 4) otherwise, never above NumCPU
NumCPU is what the spawner sees — on macOS that is the vz guest's vCPUs (min(host cores, 8)), not the Mac's. The default assumes up to three concurrent spaces and keeps a quarter of the host free:
| Host cores | Default budget | Three runs use |
|---|---|---|
| 4 – 11 (any Mac via vz, most laptops) | 2 | 6 |
| 12 | 3 | 9 |
| 32 | 8 | 24 |
Override it with SKLP_CI_CPUS in the environment of the process that spawns the run (sklp run ci on your machine, the sklp-run-api unit on a CI node). A single run on an otherwise idle machine is deliberately slower under the default: raise it (SKLP_CI_CPUS=6) when you know you are the only run.
Inside the runner, SKLP_CPUS is the resolved budget. The scheduler reads it first, then the cgroup cpu.max chain it runs under, then NumCPU() — so a runner started outside sklp (a hosted CI Pod with a cgroup limit) still sizes itself correctly.
When the cap is a contract
In CI (CI=true or SKLP_CI set) the spawner sets RequireLimits: the cgroup cap is a contract, and a host that cannot apply it fails the run with the missing controller in the error rather than running unbounded. On a dev machine (CI unset) the cap is dropped silently and only the env levels bound the run.
How the cap is applied depends on who sklp runs as:
| Host | Driver | What must be true |
|---|---|---|
| Rootless with systemd (a Linux dev box, the CI node) | runc --systemd-cgroup under the user slice | The slice delegates cpu — sklet host setup checks it and, under sudo, writes the Delegate= drop-in. systemd < 252 delegates only pids+memory by default. |
Root without systemd (the macOS vz guest, a CI container) | cgroupfs directly under /sys/fs/cgroup/sklp/<id> | cgroups v2 mounted; sklp enables the controllers itself. Nothing to configure. |
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers
# rootless: must list cpu pids (memory for memory caps) — else: sudo sklet host setup
The cgroupfs path is what makes the cap hold inside the vz guest: it bounds the runner's whole subtree, so tools that ignore GOMAXPROCS (tsc, vite, vitest) are capped too. See deploy resources — rootless delegation.
Common pitfalls
-
Three runs feel like they eat the whole machine. Check the budget:
SKLP_CI_CPUSunset gives 2 cores per run. If the host is still saturated, something in a step is ignoringGOMAXPROCS(a Node/pnpm build,cargo) — bound it in the step'senv:(UV_THREADPOOL_SIZE,CARGO_BUILD_JOBS). -
Empty
services: []is the same as omitting the field. No service is started. -
Don't forget
tools:— a step that usespnpmbut liststools: [go]will fail withpnpm: command not found. Emptytools: []means "no tool resolved" (use for pure-git / pure-shell steps). -
sharedis an OR-extender, not a default scope. Add it to a step'sscope:if you want.sklp/**changes to also trigger that step. -
ports: ["5432:5432"]— even in K8s where the host port is ignored, write both for parity with the local mode. The runner uses the container port for sidecar reachability. -
Volumes must be named.
volumes: ["./data:/data"]is rejected. Usevolumes: ["my-data:/data"].
Automatic failure diagnosis
When a pipeline step fails, sklp run can call the diagnose engine on the captured step logs and print a structured hypothesis + suggested fix before exiting. It is opt-in — pipelines without explicit opt-in keep the default behaviour (step fail → pipeline fail, no LLM call).
Three sources of intent, checked in order — first hit wins:
- CLI flag:
sklp run ci -diagnose-on-fail - Env var:
SKLP_DIAGNOSE_ON_FAIL=1(also acceptstrue,yes,on) - Default: off.
When opt-in is on but the API key env var for the configured provider is empty (e.g. OVH_KEY, ANTHROPIC_API_KEY, …), the runner logs a single skip notice and keeps going — diagnosis is best-effort, never a gate.
The diagnosis is rendered:
- In text mode: as a boxed block on stderr between the
FAILURESsection and the final banner. - In JSON mode: attached to the failed step's
StepResult.diagnosisfield. Consumers that don't know the field (older spawner builds) keep parsing fine — it'somitempty.
Authoring tips for AI agents
- Read
_base.yamlfirst. Scope names you reference inci.yamlcome from there. - Reuse, don't duplicate. If the same Postgres definition is needed in both
dev.yamlandci.yaml, the user may want to move it to a shared file viaextends:. - Service ↔ step coupling is by NAME ONLY. No magic — if a step says
services: [postgres], the catalogue must havepostgres:. - Don't add
latesttags. Production registry policy forbids them; CI should match that. Pinpostgres:16-alpine, notpostgres:latest. oneshot: trueis for migrations and seeders. Long-running daemons are not oneshot.
Editor support
The repo ships a JSON Schema for .sklp/pipelines/*.yaml at
pipeline/pipeline.schema.json, generated from the Go Pipeline
struct (run go generate ./sklp/... to regenerate).
To get autocomplete + validation in VSCode, Cursor, Neovim, or any other editor that runs the YAML Language Server, add this comment as the first line of your pipeline file:
# yaml-language-server: $schema=../../sklp/pipeline/pipeline.schema.json
version: 1
kind: pipeline
extends: _base
# …
The path is relative to the YAML file (.sklp/pipelines/ is two levels deep,
so ../../). Field descriptions in the schema are sourced from the Go
doc comments on the struct fields — same source of truth as this page.
See also
sklp dev— devstack reference — sameservices:/ready:/depends_on:semantics, applied to local development.