CI pipelines

The model

A pipeline is a YAML file under .sklp/pipelines/. sklp run <name> loads .sklp/pipelines/<name>.yaml, diffs the branch to find the impacted scopes, and runs only the steps those scopes select, in parallel along a DAG, each one cached by content.

ConceptWhereWhat it decides
steps:pipelinethe units of work, ordered by depends_on:
scopes:pipelinenamed glob lists, matched against the diff
tools:pipeline + steppinned toolchain versions a step opts into
services:pipeline + stepsidecars (Postgres, NATS…) a step needs live
space:pipelineinside a Space container (default) or on the host
on:pipelinewhich branches may select and run it

CI is never GitHub Actions. There is no .github/workflows/ driving sklp: remote runs are dispatched by the Skalpai app, local runs by sklp run.

Pipeline file format

# yaml-language-server: $schema=../../apps/sklp/pipeline/pipeline.schema.json
version: 1
kind: pipeline
name: ci
 
on:
  branches: ['**', '!main']
 
tools:
  go: "1.26.6"
  pnpm: "11.3.0"
 
secrets:
  - GITHUB_TOKEN
 
scopes:
  core:
    - "apps/core/**"
    - "go.work"
  web:
    - "apps/web/**"
    - "pnpm-lock.yaml"
 
services:
  timescaledb-test:
    image: timescale/timescaledb:2.17.2-pg16
    ready:
      tcp: localhost:5432
      timeout: 60s
 
steps:
  - name: vet
    scope: [core]
    tools: [go]
    run: go vet ./...
 
  - name: test
    scope: [core]
    tools: [go]
    depends_on: [vet]
    services: [timescaledb-test]
    run: go test -race -count=1 ./...

Top-level fields

FieldMeaning
versionalways 1
kindpipeline; anything else is rejected by sklp run
namepipeline name
spacefalse runs on the host instead of a Space container
extendssibling file(s) whose scopes, tools, env, secrets are merged in
onbranches: globs (last match wins, ! excludes) and clean_tree:
toolsname: version pins
env / env_filesplain env, and gitignored dotenv files (.env.local)
secretsnames resolved before the run; missing → fail fast
registriespush credentials used on a CI node (see Publish)
cachepersisted directories keyed by content
scopesname: [globs]
services / jobssidecars and one-shot jobs, same shape as sklp dev
tagpublish-tag strategy, exported as $SKLP_PUBLISH_TAG / {{ publish_tag }}
stepsthe work

Step fields

FieldMeaning
nameunique; what depends_on references
runshell snippet run via sh -c (required for a shell step)
kindempty = shell step; publish = build + scan + push an image
scopescopes that select the step; none = always runs
depends_onstep names that must finish first
toolswhich pinned tools are put on PATH
servicessidecars that must be Ready before run
secretsper-step opt-in to pipeline secrets (both levels are required)
env, cwd, ifstep env, working dir, condition
cachefalse opts the step out of the content cache
inputs / outputsextra cache-key inputs and produced paths
networknone unshares the network namespace
filesystemdefault confined (Landlock: no $HOME); open lifts it

An unknown key is parsed and ignored, never reported: a typo enforces nothing. Use the schema header for editor validation.

Space vs host

By default a pipeline runs in a Space: a rootless container whose rootfs is built from .sklp/space.yaml, the same recipe the dev stack uses. There is no separate runner image to maintain.

space: false runs the steps directly on the host. Publish pipelines need it: a rootless container cannot create the containers an image build runs.

Running a pipeline

sklp run                     # list the pipelines in .sklp/pipelines/
sklp run ci                  # run .sklp/pipelines/ci.yaml
sklp run -file path/x.yaml   # a pipeline outside .sklp/pipelines/

See sklp run. The runner also accepts:

FlagEffect
-resolve-onlyprint the plan (impacted scopes + changed paths), run nothing
-base-ref <ref>base of the impact diff (default HEAD~1)
-forceevery scoped step is in scope, whatever the diff
-only a,bonly steps scoped to a or b; wins over -force
-no-pullskip the git pull --ff-only before the diff
-no-cachebypass the content cache (run everything, write nothing)
-no-remote-cacheignore SKLP_REMOTE_CACHE_URL for this run
-jobs Nmax parallel steps (default: the CPU budget)
-fail-fastcancel in-flight steps on the first failure
-resumeskip steps that passed in the previous run
-quiet, -tail-lines N, -no-hotkeyslive output controls
-env-file <path>env file for the Space run (default <repo>/.env)
-runner-image <ref>override the Space image ($SKLP_CI_RUNNER_IMAGE)

Impacted-service scoping

The runner diffs HEAD against the base ref and matches every changed file against each scope's globs. A step runs when any changed file matches any glob of any of its scopes.

sklp run ci -resolve-only            # what would run
sklp run ci -base-ref origin/main    # diff against main, not HEAD~1
sklp run ci -only web                # just the web steps
sklp run ci -force -no-cache         # a real full rebuild
  • No usable diff (git error, missing base) means run everything.
  • A step with no scope: always runs, and -only never selects it.
  • An unknown scope in -only or in a step is a hard error.
  • -force and -only do not bypass the cache; add -no-cache.

CI on a pull request

git push feat/x  →  sklp flow end opens the PR
GitHub webhook   →  core
core             →  CI node (sklp-run-api) runs the selected pipelines

sklp flow end pushes the branch and opens the PR; the PR triggers CI on the node, so CI is not run locally first. Pipelines are selected by their on: branches against the diff. To check a change before it leaves the machine:

sklp flow end --local

When no CI node is registered for the project, the PR reports the node as unreachable. That is expected, not an incident: sklp flow end --local is then the validation that counts.

Follow a remote run with sklp ci status and sklp ci watch:

sklp ci status --branch feat/x   # current status, no tail
sklp ci watch <run-id>           # replay + tail logs until done

Both take --project (defaults to SKLP_PROJECT) and --json.

Running a CI node

sklp run --listen turns a Linux VM into a CI node: it serves the run API (default :6464, TLS + Bearer token) and executes pipelines for the refs it is sent. --install makes it a systemd --user service.

sklp run --listen --advertise 10.0.0.4 --work-root /var/cache/sklp --concurrency 2 --install

The pipeline YAML comes from the branch under test, so a node refuses any pipeline that sets space: false unless it was started with --allow-host-steps. A host pipeline runs as the node user with the run API's environment, its token file and every run's workdir in reach; only enable it on a node that runs trusted refs, never one that builds pull requests from forks.

The CI rootfs and caches

The CI Space's rootfs is built on the fly from .sklp/space.yaml. Inside it:

MountHolds
/workspacethe repo checkout
/cache-sharedGOMODCACHE / GOPATH, content-addressed, shared
/cacheGOCACHE, HOME, per-task state

The tool cache grows with every checkout. sklp cache gc evicts least-recently-used buckets, skipping those held by a live Space:

sklp cache status
sklp cache gc --dry-run
sklp cache gc --max-size 12G --runner-max-size 8G

Never delete anything under ~/.cache/sklp by hand; use sklp clean.

To build or inspect the runner image from the recipe, sklp runner build:

sklp runner build --print        # show the generated Dockerfile
sklp runner build --tag my-runner

Repository pipelines

PipelineWhat it does
sklp run ci-skalpailint + test of core, web, www (every branch but main)
sklp run generateregenerate the OpenAPI spec and the TypeScript client
sklp run regen-schemasregenerate pipeline.schema.json; fails on drift
sklp run publishbuild and push impacted images (see Publish)

regen-schemas declares on: { never: true }: no push selects it, it runs by name only.

Git hooks

Local gates are git hooks, not a hosted CI. Activate the versioned hook once per clone:

git config core.hooksPath .githooks

.githooks/pre-commit runs sklp run regen-schemas whenever apps/sklp/pipeline/schema*.go, apps/sklp/pipeline/spec/ or apps/sklp/cmd/genschema/ is staged. On drift, stage the regenerated JSON and commit again.