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 same kind: pipeline schema is used by _base.yaml (shared header) and any other task file that inherits from it (extends: _base).

Hard rules: every value in step.scope must be a key in pipeline.scopes. Every value in step.services must be a key in pipeline.services. A service is either run: (shell) or image: (OCI container), never both — same rule as devstack. oneshot: true cannot have ready:. 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).

The same YAML works in two execution contexts:

ContextHow 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

FieldTypeRequiredDescription
versionintyesSchema version. 1 today.
kindstringyesMust be pipeline.
namestringyesLogical name, surfaced in logs.
extendsstring or [string]noOne or more sibling files (without .yaml) to merge as parents. Current file's values win on key collision.
toolsmap[string]stringnoTool name → version pin. The runner installs these on demand per step.
scopesmap[string][]stringnoScope name → list of doublestar globs. Referenced by step.scope.
servicesmap[string]ServicenoService name → spec. Referenced by step.services.
envmap[string]stringnoEnvironment merged into every step's shell.
env_files[]stringno.env files loaded before env (which wins on collision).
secrets[]stringnoNames of secrets the spawner must resolve and inject as env.
steps[]StepyesThe ordered list of steps.

Step

FieldTypeRequiredDescription
namestringyesHuman label.
runstringyesShell command(s) executed via sh -c. Multi-line YAML supported.
scopestring or [string]noOne or more scope names. Empty = the step always runs. OR semantics across listed scopes.
services[]stringnoOne or more service names from the top-level services: catalogue. Must be live and Ready before run: starts.
tools[]stringnoSubset of pipeline-level tools resolved for this step only. Empty = inherit all (legacy).
envmap[string]stringnoMerged on top of pipeline-level env for this step.
ifstringnoReserved 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:

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

Service

Same schema as sklp dev services. Either run: (local process) or image: (OCI container).

FieldTypeWhenDescription
runstringexclusive with imageShell command launched via sh -c, kept alive for the pipeline duration.
imagestringexclusive with runOCI image reference. Runtime auto-detected on PATH: docker → podman → nerdctl.
command[]stringwith image onlyOverride the image's default CMD.
ports[]stringwith image onlyhost:container or :container. In K8s sidecars the host port is ignored (Pod-local network).
volumes[]stringwith image onlyNamed volumes only (pg-data:/var/lib/postgresql/data). Bind mounts are rejected for safety. In K8s, mapped to emptyDir.
envmap[string]stringanyOverlays the pipeline env for this service.
workdirstringwith run onlyRepo-root relative.
depends_on[]stringanyNames of other services that must reach Ready first. Cycles fail at load time. Topo-sorted by the orchestrator.
readyReadyanyProbe describing how the supervisor knows the service is up. See below.
oneshotboolwith run onlyThe 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.

FieldTypeDescription
httpstringURL polled with GET; any 2xx = ready.
tcpstringhost:port opened with net.DialTimeout.
logstringRegex matched line-by-line against the service's stdout/stderr. Not supported in K8s sidecars — use http or tcp.
filestringPath that must exist on disk. Not supported in K8s sidecars.
timeoutstringGo 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:

  1. Diff computed: list of changed files vs origin/main.
  2. Step gating: each step's scope: checked against the diff. Steps with no matching files are skipped.
  3. Service union computed across the steps that will actually run. If test-integration is among them, migrate is requested; its depends_on: [postgres] pulls in postgres too.
  4. Topo-sort + start: postgres boots first, the orchestrator waits for tcp: localhost:5432 (≤ 60s). Then migrate runs to completion (exit 0 = ready); failure aborts the pipeline.
  5. Steps execute in order. test-integration sees DATABASE_URL pointing at the live Postgres.
  6. Teardown on pipeline exit (success or failure, including SIGINT/SIGTERM): all services stopped, OCI containers removed.

Local vs K8s — what changes

AspectLocalK8s
Process services (run:)Spawned via sh -c, killed via SIGTERM/SIGKILL on teardownNot 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.
ReadinessProbed by the orchestrator before the first step startsinitContainer named wait-for-services polls each sidecar (nc -z localhost <port> for TCP, wget for HTTP) before the runner container starts
CleanupOrchestrator teardown via defer, also fires on signalPod deletion takes care of it
Service namesContainer names prefixed sklp-ci- to avoid clash with sklp devContainer 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:

LevelMechanismWhat it bounds
Spacecgroups 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).
RunnerSKLP_CPUS=<budget> in the runner's env → default -jobsThe number of steps the scheduler runs concurrently. -jobs N on the command line still wins.
StepGOMAXPROCS=<budget> in every step's envGo 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 coresDefault budgetThree runs use
4 – 11 (any Mac via vz, most laptops)26
1239
32824

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:

HostDriverWhat must be true
Rootless with systemd (a Linux dev box, the CI node)runc --systemd-cgroup under the user sliceThe 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

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:

  1. CLI flag: sklp run ci -diagnose-on-fail
  2. Env var: SKLP_DIAGNOSE_ON_FAIL=1 (also accepts true, yes, on)
  3. 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:

Authoring tips for AI agents

  1. Read _base.yaml first. Scope names you reference in ci.yaml come from there.
  2. Reuse, don't duplicate. If the same Postgres definition is needed in both dev.yaml and ci.yaml, the user may want to move it to a shared file via extends:.
  3. Service ↔ step coupling is by NAME ONLY. No magic — if a step says services: [postgres], the catalogue must have postgres:.
  4. Don't add latest tags. Production registry policy forbids them; CI should match that. Pin postgres:16-alpine, not postgres:latest.
  5. oneshot: true is 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