CI/CD

A pipeline wants three things from a toolchain: prompts that become failures, versions it can account for, and a runner it does not have to babysit. kapsl's CI posture is built for all three, and each one is a decision the pipeline makes rather than something that happens to it.

Non-interactive is the default, and it is fail-closed

--non-interactive disables the TTY, and it is auto-detected: the binary enters non-interactive mode when the flag is set, when the CI environment variable is present, or when either stdio end is not a terminal — so a pipeline gets it without configuring it. What changes is what a prompt does: every decision point that would otherwise ask — a scan finding at the prompt tier, a destructive --clean — becomes a failure with a non-zero exit code. A CRITICAL finding failing the build is exactly what a pipeline wants, and it is what actually happens:

CI=1 kapsl omp --version
  ■ FAILED     Vulnerabilities: C: 1, H: 10, M: 13, L: 3, U: 0. — requires
               review, but no terminal to ask on (run interactively, use
               --skip-scan, or adjust security_scan_prompt)
  ■ ERROR      Vulnerability scan requires review, but no terminal to ask on

Exit 1, no hang, no stray prompt eating the runner. The same run interactively would stop and ask y/N/d; in a pipeline there is no one to answer, so it declines itself. The knobs are on the configuration page: security_scan_prompt (default critical), --skip-scan, --force-scan.

Determinism, stated as a decision

There are exactly two postures, and choosing between them is the pipeline's decision:

  • Floating, for "current and clean". A name without a pinned digest is expected to stay current: floating tags re-pull on the freshness window (default 7 days), and a failed scan on a floating tag re-pulls and re-scans once, because a rebuild may have fixed the CVE. The always-current page is the full story; the pipeline consequence is that "the tool my build ran" is a moving target that you are opting into.
  • Digest-pinned, for bit-for-bit. A pinned digest never moves and is never re-pulled. Pin in the project or runner config and the build is reproducible to the byte.

The catalogue itself carries no versions, no digests, no dates — that is a defining property of the format, not an omission. So "which bytes ran" is not answered by the index document; it is answered by the registry and the index site, which is why the CLI surfaces it: --info prints the resolved digest, and --pull lists the digests of the whole image set it prepared:

kapsl --pull mkdocs
  ■ SCANNING   mkdocs · 4 images
  ■ READY      prepared mkdocs · ready to run with --offline
               ghcr.io/kapsl-sh/kapsl-base-static-ubuntu:latest  sha256:219a…c73d
               ghcr.io/kapsl-sh/git:latest                      sha256:f8a2…021d
               ghcr.io/kapsl-sh/mkdocs:latest                   sha256:740e…c986
               ghcr.io/kapsl-sh/python:3.14                     sha256:619e…b645

The workflow for auditing a pipeline's tool versions: read the digests the run resolved (they appear in the pull/ready lines and in --info), and follow them to the index site's per-project pages, which show the findings, the VEX assessments and the raw provenance documents for that exact tag. The catalogue page explains the split; the docs never restate the site's data.

Old version lines are kept deliberately — so you can reproduce a bug against the version that had it — they age rather than being maintained, and the scan still runs on them. Running a known-vulnerable old line is --skip-scan with the risk stated, not a silent pass.

Caches, disks, and the first-run cost

Everything regenerable is cached, and the cache is addressable:

kapsl --status
kapsl storage
  cache      /Users/q/.cache/kapsl
  decisions  /Users/q/.local/share/kapsl

  scanner             9.6 MB  6 entries
    grype database       0 B  0 entries
  tool catalogue       60 KB  2 entries
  freshness stamps    7.3 KB  1 entry
  run scratch            0 B  0 entries
  decisions           222 MB  6 entries
  total               232 MB

  catalog.json  regular file

  not written by kapsl (never removed automatically):
    /Users/q/.local/share/kapsl/accepted-capabilities.json
    /Users/q/.local/share/kapsl/attestations
    /Users/q/.local/share/kapsl/env-signing-key.pem

Two roots, with the split being the point: ~/.cache/kapsl holds everything regenerable (env images, the scanner database, the catalogue cache), ~/.local/share/kapsl holds the human decisions — accepted CVE sets, trusted projects — which are small and precious. --clean with --dry-run previews, and the default targets are the ones that cost nothing to recreate; cache and decisions ask first, and in a pipeline the ask becomes a failure unless you pass --yes. Captured on a machine that had built several environments:

kapsl --clean
Removing 6 cached environment image(s)...
Untagged: localhost/kapsl-env-python-3.12:uv-ad44534d
  removing localhost/kapsl-env-python-3.12:uv-ad44534d ... done
Untagged: localhost/kapsl-env-python-latest:pip-900e02cc
  removing localhost/kapsl-env-python-latest:pip-900e02cc ... done
  ...
Removing 4 package cache volume(s)...
  removing kapsl-cache-uv-arm64 ... done
  removing kapsl-cache-yarn-arm64 ... done
  removing kapsl-cache-npm-arm64 ... done
  removing kapsl-cache-pip-arm64 ... done
kapsl --clean --dry-run
               kapsl-env-* images, package caches and the vulnerability database (not enumerated under --dry-run)
  ■ INFO       --dry-run: nothing removed

The cost shape a pipeline should plan for, measured on this machine (today's numbers, warm runs after):

StepFirst runWarm
Tool pull + scan30–120 s (new tool)sub-second
Env build + scan ([email protected]:pyyaml)57 s2.7 s (scan verdict fresh: 0.4 s)
Vulnerability database rebuild (after --clean cache)1 m 7 s
kapsl [email protected]:pyyaml -c "import yaml; print('pyyaml', yaml.__version__)"
  ■ BUILDING   kapsl-env-python-3.12:uv-ad44534d
  ■ BUILDING   installing · 0%
  ■ BUILDING   capturing · 66%
  ■ BUILDING   exporting kapsl-env-python-3.12:uv-ad44534d (3.2 MB)
  ■ BUILDING   cataloguing kapsl-env-python-3.12:uv-ad44534d
  ■ BUILDING   signing kapsl-env-python-3.12:uv-ad44534d
  ■ SCANNING   python · reading 13 inventories
  ■ SCANNING   python · 13 images
pyyaml 6.0.3

Two mitigations:

  • --pull to pre-warm a stage. Pull and scan the image set without running anything, so the stage that runs later starts warm. The READY line above is the pre-warmed state: prepared and "ready to run with --offline".
  • --offline for sealed environments. Skips pulls, catalogue refresh and scanner-DB updates, and runs against what is already present — the offline mode page has the full semantics, including that the scan still runs against the cached database.
kapsl --offline python --version
Python 3.14.7

Exit codes pass through — and set -e works

The tool's exit code is the run's exit code, pass-through, and the supervising parent maps a signal death to the right code. A lint stage that fails stops the pipeline at the lint stage:

bash -c 'set -e
echo "=== lint stage"
kapsl flake8 .
echo "=== build stage (never reached)"'
=== lint stage
./probe.py:1:1: F401 'os' imported but unused
./probe.py:2:1: F401 'sys' imported but unused
./probe.py:4:1: E302 expected 2 blank lines, found 1
./probe.py:4:15: E201 whitespace after '('
./probe.py:4:17: E231 missing whitespace after ','
./probe.py:7:1: E305 expected 2 blank lines after class or function definition, found 1
./probe.py:7:80: E501 line too long (90 > 79 characters)

The script exits with 1 — flake8's own code — at the lint stage, and the build stage never runs.

And the passing path runs the next stage, which is itself a kapsl run — lint, test and build are all containerized steps in one pipeline:

bash -c 'set -e
echo "=== lint stage"
kapsl flake8 .
echo "=== build stage"
kapsl python clean.py'
=== lint stage
=== build stage
3

Pipelines inside pipelines

When a step needs to call another tool from within a tool — a build script that shells out to a containerized formatter — the nested call runs under --cap nest: the inner run is proxied by the outer kapsl through a brokered socket instead of the inner container being handed the engine socket (which would be a full sandbox escape), and the nested tool gets its own sandbox. It works on Linux today; on macOS the containers live in a VM and the socket cannot cross into them, so the call fails loudly:

kapsl --cap nest bash -c 'kapsl jq --version'
  ■ ERROR      --cap nest is not supported on macOS yet.
               kapsl runs on the host but its containers run inside a virtual
               machine, and the broker socket has to be on the same kernel
               as the container using it. The fix is to run the broker as a
               sidecar container; that is designed but not yet built.
               Nesting works today on Linux.

The runnable examples

The CLI repo ships boks-examples/ — one directory per project shape (flask, django, fastapi, express, nextjs, cargo, go, ruby, …), each with a captured transcript of every phase including failures, run from inside the directory with the output copied verbatim. The transcripts there are the pipeline patterns in their fullest form: environment builds, port publishing, .env handling, and the deploy phase, with the security notes for each. Cite the example for a project shape rather than re-deriving it here.