README.md
Cave
A self-hosted code forge written in Common Lisp. Push code, review changes, merge with confidence.
Cave is built for small teams who want to own their infrastructure without the bloat of enterprise forges. It's fast, opinionated, and runs on a single server.
Features
The surface is split by maturity. Implemented features are in everyday use; Experimental ones work but have known gaps or sharp edges; Planned ones are not built yet.
Implemented
- Git hosting — SSH push/clone with per-repo access control, plus anonymous read-only clone over HTTPS; pushing a new branch prints an "open a pull request" link back in the terminal
- Deploy keys — per-repo SSH keys for CI/automation without a user account; read-only by default, opt-in write. Managed in repo Settings;
authorized_keysis regenerated automatically so access takes effect immediately - Protected branches — per-repo rules (exact name,
prefix/*glob, or*) that block direct pushes (forcing changes through pull requests; repo admins may override) and/or require signed commits. Enforced at push time in the pre-receive hook, before any check runs - Pull requests — graduated review model (approve, approve with concerns, request changes); enforced merge-eligibility rules: required approvals, blocking change-requests, unresolved concerns, and required status checks (failing/pending checks block the merge); squash merge. Draft PRs, close/reopen (UI/API/CLI), and auto-merge (arms the PR to merge automatically once review + checks pass). Review rounds are recorded per re-push, with interdiff (range-diff) links to see what changed between rounds. CODEOWNERS auto-requests and notifies owners of touched paths
- Live checks panel — pull requests show a GitHub-style checks panel combining external commit statuses and cave workflow jobs: a rollup summary (N failing / M in progress / K successful) plus per-check rows with green/red icons and spinners for in-progress checks, polled live while anything runs (the merge box refreshes once they settle)
- Issues — create, list, comment, close/reopen; labels with label filtering, assignees, and milestones (with open/closed issue counts)
- Notifications — in-app notification feed with an unread-count bell, plus per-repo watching (members are subscribed automatically; anyone can Watch); new issues, comments, PR reviews, and merges notify members + watchers. Email notifications too
- Releases — tag-backed releases with markdown body and per-asset uploads (≤ 100 MB each), download counts; release notes auto-generated from commits since the previous tag when the body is left blank
- Commit status API — external CI reports pass/fail per context on a commit; feeds merge eligibility and the checks panel
- CI secrets — per-repo encrypted secrets (AES-256-CBC at rest) injected into workflow jobs as environment variables and masked in job logs; managed in repo Settings
- Supply-chain security — CycloneDX SBOM ingest on push, dependency graph, OSV advisory-feed matching, severity-ranked dependency alerts with durable suppressions, and Dependabot-style auto-fix pull requests (speculative build before opening)
- Commit signature verification — SSH- and GPG-signed commits validated against keys registered in Settings (SSH keys / GPG keys), verified on push with a "Verified" badge in the commit list; a
cave-server reverifycommand backfills verification for existing history after a key is added - Git subprocess sandboxing —
gitpush/clone, repo metadata reads, and pre-receive checks run inside a Landlock filesystem sandbox (via landrun) scoped to the target repo: a compromised git or check process can touch only its own repo plus read-only system paths — not other repos or host secrets (kernel-enforced cross-repo isolation). Gated by:sandbox-landlock(on by default); degrades to a no-op where the kernel lacks Landlock - Code browser — file tree with per-file last commit and relative age, repo language breakdown, file-type icons, Monaco editor, syntax-highlighted diffs, branch/tag switcher (overview, code, tree, and file views), SSH/HTTPS clone widget, click-a-line-number permalinks (
#L42) with a per-line action menu - README & markdown rendering — server-side Markdown (CommonMark + GFM tables, via cl-commonmark) with fenced code blocks, rendered HTML cached by blob sha; math (KaTeX) and Mermaid diagrams rendered client-side, loaded lazily only when the syntax is present
- Code search — full-text code search powered by Zoekt, repo-scoped and global
- Repo mirroring — push to and pull from GitHub/GitLab/Codeberg, with scheduled sync
- Webhooks — HTTP callbacks on push, PR, and issue events
- Pulse tab — per-repo activity chart, total views, unique visitors, top contributors, referring sites (member-only)
- Public landing — anonymous visitors get a landing page driven by the
cave-landingsystem repo (edit content with a git push), with a list of public repos and recent activity, no login required - Explore — discover public repos with search, sort, and trending; browse people; facet by language with colored language dots (
/-/explore) - User themes — built-in (Terminal Warmth, Solarized Dark, Nord, Dracula, Light) plus custom themes via
cave-themesrepos - Built-in identity (Usher) — cave embeds its own OpenID Provider (Usher) in-process, so no external IdP is required: OIDC login, self-registration gated by admin approval, email verification, password reset, self-service password change, and 2FA (TOTP) with backup codes. SSH and GPG key management live in Settings. cave still speaks plain OIDC, so it can also federate to an external provider (e.g. Keycloak)
- Email — SMTP via mailpit (dev) or any external relay (Resend / Postmark / SES / Fastmail …) configured per deploy
- Observability — Prometheus metrics, Grafana dashboards, SBCL runtime stats
- Backup/restore — one-command backup and restore of Postgres + repos + config
- Declarative deployment —
cavectlreconciles containers from a singlecave.yaml;cavectl doctorruns end-to-end health checks - Quadlet deployment — systemd user services for production, with rollback
Experimental
- Stacked changesets — dependent PRs are tracked and displayed as a stack, but there is no atomic "land stack" yet; members still merge one PR at a time (see Planned)
- Automation runners + workflows —
.cave/workflows/*.ymljobs are scheduled across self-hosted gRPC runners and report status back, with a partial GitHub-Actions-compatible syntax:push/pull_request/tagtriggers, workflow/job/stepenv:, multi-linerun: |blocks, the standardGITHUB_*env (each with aCAVE_*twin) plusRUNNER_*/CI, and the$GITHUB_OUTPUT/$GITHUB_ENV/$GITHUB_PATH/$GITHUB_STEP_SUMMARYfile-command protocol,${{ }}expressions (inrun:/env:/if:, across thegithub/steps/matrix/needs/… contexts),strategy.matrix, joboutputs:/needs.*, anduses:actions (GitHub model: empty workspace + cave-local, Lisp-native built-ins likeactions/checkout). Admin policy gates repo-supplied jobs:privilegedis denied by default and images can be pinned to an allowlist (:workflows-allow-privileged,:workflows-image-allowlist); job dependencies without an explicit image resolve to a Nixery image. Encrypted per-repo secrets are injected as env vars and masked in logs (as is anything a step emits via::add-mask::). A reaper requeues jobs orphaned by a dead/restarted runner (bounded retries) so the queue self-heals. Still missing for a fully untrusted multi-tenant setup: per-repo policy overrides and stronger runner-side isolation — so prefer trusted repos - Multi-chamber storage — Praefect-style routing across git storage nodes (read/write split, health checks, async replication). Opt-in; single-chamber is the default and the well-exercised path
Planned
- Atomic stack landing — ordered validation plus all-or-nothing merge of a stack
Closes #Nauto-close — close issues automatically from commit/PR messages (labels, assignees, milestones, and filtering are now Implemented)- Deployment auth migration — the cave binary embeds Usher, but the
cavectltooling still provisions an external Keycloak (auth.mode: keycloak); wiringcavectlto deploy the embedded provider directly is pending - Fully sandboxed checks — pre-receive checks already run under a Landlock filesystem sandbox (cross-repo isolation, scoped to the extracted worktree) with a scrubbed environment, network isolation (
unshare -nplus Landlock TCP-deny, best-effort where the container can'tunshare), and a wall-clock timeout (:checks-*). Still missing for full isolation: cgroup memory/CPU limits and dropping to a per-repo unprivileged UID - Repo deployment / CD — build images, queue deploys, roll back, manage secrets
- Unit/integration tests — for migrations, the REST API, and merge-policy rules (today only the end-to-end Playwright suite exists)
Quick start
# Build cave-server + cave (CLI) + cavectl
make build
# Spin up the full stack locally (postgres, keycloak with cave theme + realm,
# mailpit, zoekt, cave, runner) — picks free ports, generates secrets
./cavectl init
# Visit cave at http://localhost:9080
cavectl init writes a cave.yaml with every secret already filled in. Edit it
to change images, ports, or auth mode, then cavectl apply --yes to reconcile.
Production deployment
Two paths. Pick one.
Path A — cavectl (recommended for new installs)
On the target host (Fedora/Rocky/Alma/Debian/Ubuntu, with podman or docker):
# 1. Pull cavectl from a release artifact, or build from source:
go build -o cavectl ./cli/cavectl
# 2. Generate cave.yaml. init also brings the stack up after it prints
# the plan; answer "no" at the prompt if you want to edit cave.yaml
# before anything is created.
./cavectl init --name cave
# 3. Edit cave.yaml: set base_url to https://cave.example.com,
# auth.mode to "keycloak", auth.keycloak.public_url to
# https://auth.cave.example.com, smtp.mode to "external" with your
# relay credentials, and ports.ssh to 22 if cave's SSH should be on
# the public port (then move system sshd off 22 first).
# Then reconcile the edits:
./cavectl apply --yes
# 4. Health-check
./cavectl doctor
Front it with Caddy:
cave.example.com { reverse_proxy 127.0.0.1:9080 }
auth.cave.example.com { reverse_proxy 127.0.0.1:9180 }
runner.cave.example.com { reverse_proxy h2c://127.0.0.1:9443 }
The runner. block is what lets remote automation runners reach
cave's gRPC service through TLS — point runners at
grpcs://runner.cave.example.com. h2c:// tells Caddy to speak
plaintext HTTP/2 to the upstream (gRPC).
Path B — Make + systemd quadlets
make build && make release # build images, tag :prod
make prod-install # drop quadlet units in ~/.config/containers/systemd/
make prod-start
Default ports: cave 9080, keycloak 9180, mailpit 9025, SSH 9222.
Rollback / backup
make prod-rollback # swap back to cave:prod-previous
make prod-backup # → ~/cave-backups/cave-YYYY-MM-DD.tar.gz
make prod-restore F=path/to/archive.tar.gz
Container images
Published from main and from tagged releases via the Publish container images
GitHub Actions workflow:
| Image | Source |
|---|---|
ghcr.io/atgreen/cave |
Containerfile.local |
ghcr.io/atgreen/cave-runner |
Containerfile.runner |
ghcr.io/atgreen/cave-zoekt |
Containerfile.zoekt |
ghcr.io/atgreen/cave-keycloak |
Containerfile.keycloak |
Tags: :sha-<short> (immutable), :main (rolling), and on v* tags
:<version>, :prod, :latest.
Architecture
Cave is a single Common Lisp (SBCL) binary serving HTTP via Hunchentoot, with a
gRPC service for runners (runner-service.lisp) and a gRPC service for git
storage (chamber.lisp). All HTML is generated by Spinneret. CSS is a single
file. No JavaScript framework.
src/
├── package.lisp — Package definition
├── config.lisp — S-expression config parser
├── db.lisp — PostgreSQL via Postmodern, numbered migrations
├── auth.lisp — Embedded Usher OpenID Provider, sessions, API tokens,
│ GPG/SSH key mgmt, notifications, sudo mode
├── model.lisp — Domain queries: users, orgs, repos, issues, PRs,
│ reviews, releases, signatures, page views, …
├── git.lisp — Git CLI integration (branch, log, tree, diff, merge,
│ tag, signature verification, trailers)
├── views.lisp — All HTML views via Spinneret
├── notify.lisp — Email notifications, webhooks
├── search-zoekt.lisp — Zoekt code search: indexing, API client, visibility
├── metrics.lisp — Prometheus metrics endpoint
├── runner-service.lisp — gRPC service for automation runners
├── workflow.lisp — Workflow orchestration: parse, schedule, deps
├── yaml.lisp — Minimal YAML parser for workflows
├── chamber.lisp — Git storage gRPC service
├── chamber-client.lisp — Chamber client with chamber-or graceful fallback
├── chamber-router.lisp — Multi-chamber routing (Praefect-style)
├── ssh.lisp — SSH transport, authorized_keys generation
├── server.lisp — HTTP routes and request handling
└── main.lisp — CLI subcommands (serve, init, migrate, runner, etc.)
cli/cavectl/ — Go deployment tool source
internal/cavectl/ — Go libraries: config, plan, apply, runtime, doctor, …
keycloak/themes/cave/ — Custom Keycloak login theme
keycloak/cave-realm.json — Realm import (placeholders substituted at runtime)
deploy/quadlet/ — systemd-user quadlet units
Key dependencies
| Library | Purpose |
|---|---|
| Hunchentoot + easy-routes | HTTP server and routing |
| Postmodern | PostgreSQL client |
| Spinneret | S-expression HTML generation |
| ag-grpc | gRPC server + client (runners, chamber) |
| Usher | Embedded OpenID Provider (in-process auth) |
| cl-commonmark | Markdown rendering (CommonMark + GFM tables) |
| Dexador | HTTP client (OIDC, webhooks) |
| Ironclad + jzon + flexi-streams | crypto, JSON, byte streams |
Configuration
Two configs live side-by-side:
-
cave.conf— runtime config consumed by thecavebinary (rendered bycavectlorentrypoint.shfrom environment variables). S-expression plist:(:http-port 8080 :grpc-port 9443 :ssh-port 22 :data-dir "/var/lib/cave" :db-host "cave-pg" :db-name "cave" :db-user "cave" :db-password "…" :base-url "https://cave.example.com" ;; OIDC issuer. For the embedded Usher provider, set this to your base URL — ;; cave self-serves OIDC, no external IdP needed. For an external provider ;; (e.g. Keycloak) point it at the realm and set :oidc-issuer-internal to the ;; in-cluster URL. :oidc-issuer "https://cave.example.com" :oidc-client-id "cave" :oidc-client-secret "…" :zoekt-enabled t :zoekt-web-url "http://cave-zoekt-web:6070" :chamber-enabled t :chamber-port 9444 ;; Runner policy for repo-supplied .cave/workflows (Cave's own ;; dep-scan/fix jobs bypass these): :workflows-allow-privileged nil ; deny `privileged: true` jobs :workflows-image-allowlist ("ghcr.io/" "docker.io/") ; nil = allow any ;; Pre-receive check sandbox: :checks-allow-network nil ; nil = isolate network if possible :checks-require-network-isolation nil ; t = reject push if isolation unavailable :checks-memory-mb nil ; integer = per-process address-space cap :checks-timeout-seconds 120 ; fallback wall-clock per check ;; Landlock filesystem sandbox for git + check subprocesses: :sandbox-landlock t) ; t = confine (cross-repo isolation); no-op without Landlock:workflows-allow-privilegeddefaults tonil— a repo workflow that requestsprivileged: trueis rejected (the run fails with the reason) until an admin flips it.:workflows-image-allowlistisnil(any image) unless set to a list of allowed name prefixes.Pre-receive checks run each configured command against an extracted worktree of the pushed tree, with a scrubbed environment and a per-check wall-clock timeout.
:checks-allow-networknil tries to isolate the network withunshare -n(best-effort — the default container can't, so it warns and runs without unless:checks-require-network-isolationist, which fails the push closed). Independently,:sandbox-landlock(on by default) wraps git push/clone, metadata reads, and checks in a Landlock filesystem sandbox scoped to the target repo — kernel-enforced cross-repo isolation, a no-op where Landlock is unavailable. In containers these map toCAVE_WORKFLOWS_ALLOW_PRIVILEGED,CAVE_WORKFLOWS_IMAGE_ALLOWLIST,CAVE_CHECKS_ALLOW_NETWORK,CAVE_CHECKS_REQUIRE_NETWORK_ISOLATION,CAVE_CHECKS_MEMORY_MB,CAVE_CHECKS_TIMEOUT_SECONDS, andCAVE_SANDBOX_LANDLOCK. -
cave.yaml— declarative deployment manifest consumed bycavectl:apiVersion: v1 cave: { image: ghcr.io/atgreen/cave:main, base_url: https://cave.example.com, secret_key: <32-byte hex> } ports: { http: 9080, ssh: 22, ssh_bind: 0.0.0.0, grpc: 9443, grpc_bind: 0.0.0.0, keycloak: 9180, mailpit: 9025 } database: { mode: local, image: docker.io/postgres:16-alpine, password: <random> } auth: mode: keycloak keycloak: { image: ghcr.io/atgreen/cave-keycloak:main, admin_user: admin, admin_password: <strong>, public_url: https://auth.cave.example.com, client_secret: <random 32-char> } runner: { enabled: true, image: ghcr.io/atgreen/cave-runner:main, count: 1 } zoekt: { enabled: true, image: ghcr.io/atgreen/cave-zoekt:main } smtp: mode: external host: smtp.resend.com port: 587 starttls: true user: resend password: re_… from: cave@example.com from_display_name: Cave chamber: { nodes: [] } runtime: { engine: auto, network: cave-net, prefix: cave }
CLI
cave — user CLI
Talks to a cave-server over HTTPS using a personal API token from Settings → API Tokens.
cave login [--base-url URL] [--token TOKEN] Save server URL and token
cave logout Remove saved config
cave status Show current auth config
cave repo create <name> [--description "…"] [--private]
[--mode empty|import|mirror]
[--url URL] [--auth-token TOKEN]
[--mirror-interval MINUTES] [--json]
cave issue list [--status open|closed] [--json]
cave issue get [--json] <number>
cave issue create --title TITLE [--body TEXT] [--json]
cave issue close <number>
cave issue reopen <number>
cave pr list [--state open|merged|closed] [--json]
cave pr view [--web] [--json] <number>
cave pr create --source BRANCH --target BRANCH [--json]
cave pr checks [--json] <number> # exit 1 unless all checks pass — CI-gateable
cave pr review <number> --approve|--request-changes|--comment [--body TEXT|-]
cave pr close <number>
cave pr reopen <number>
Set CAVE_REPO=owner/repo (or pass --repo) so issue and PR commands can find
the target without you typing it every time. cave pr checks <n> exits non-zero
unless every check is green, so it composes in scripts (cave pr checks 7 && …).
cave-server — server binary
cave-server serve Start the web server
cave-server init Initialize the database
cave-server migrate Run pending migrations
cave-server runner Start an automation runner agent
cave-server run-checks Run pre-receive checks + enforce protected branches
(called by git hook)
cave-server reverify Backfill commit signature verification over history
cave-server backfill-languages
Recompute and store each repo's primary language
cave-server sync-mirrors Sync repo mirrors (push mirrors after each push)
cave-server sync-themes Sync user themes from a cave-themes repo
cave-server update-keys Regenerate SSH authorized_keys
cave-server git-shell SSH git transport handler (called by sshd)
cave-server git-proxy Proxy git protocol through Chamber gRPC (called by
cave-shell.sh when chamber is enabled)
cave-server post-receive Handle post-receive events (legacy; HTTP endpoint is the
live path)
cavectl — deployment tool
cavectl init Generate cave.yaml and bring up the full stack
cavectl apply Reconcile containers to match cave.yaml
cavectl status Show running containers + assigned ports
cavectl logs <service> Tail container logs
cavectl doctor Run sanity checks: runtime, ports, DNS, containers,
schema, OIDC, SMTP realm config
cavectl backup Tar up postgres + repos + cave.yaml
cavectl restore <archive> Restore from a cavectl backup archive
cavectl instances List all cavectl-managed instances on this host
cavectl destroy Tear down (keep volumes with --keep-data)
cavectl version Print cavectl version
REST API
The API is described by an OpenAPI 3.1 document served at
/api/v1/openapi.json, with an interactive reference at /api/v1/docs. Point
any OpenAPI client generator (openapi-generator, Speakeasy, Fern, Kiota, …) at
the spec to produce a typed client library in your language. Timestamps are
RFC 3339 UTC strings; errors are { "error": "message" }.
Authenticate with Authorization: Bearer <api-token> (issued from Settings →
API Tokens); read endpoints on public repos work unauthenticated.
POST /api/v1/user/repos Create a personal repo
GET /api/v1/repos/:owner/:repo/issues
POST /api/v1/repos/:owner/:repo/issues
GET /api/v1/repos/:owner/:repo/issues/:number
PATCH /api/v1/repos/:owner/:repo/issues/:number Close / reopen
GET /api/v1/repos/:owner/:repo/pulls
POST /api/v1/repos/:owner/:repo/pulls
GET /api/v1/repos/:owner/:repo/pulls/:number
PATCH /api/v1/repos/:owner/:repo/pulls/:number Close / reopen ({"state": …})
GET /api/v1/repos/:owner/:repo/pulls/:number/reviews
POST /api/v1/repos/:owner/:repo/pulls/:number/reviews
GET /api/v1/repos/:owner/:repo/statuses/:sha
POST /api/v1/repos/:owner/:repo/statuses/:sha
Automation Runners
# Generate a registration token (Admin → Runners, or org/repo settings)
cave-server runner --url grpc://cave-host:9443 --token <token> --name my-runner
# Or use the runner container image (Podman-in-Podman)
make runner-image
podman run --privileged ghcr.io/atgreen/cave-runner:main \
--url grpc://cave-host:9443 --token <token>
Automations are configured per-repo in Settings → Automations, with triggers:
pre_receive, post_receive, changeset_opened, changeset_merged, manual.
Workflow files at .cave/workflows/*.yml are picked up on push and scheduled
across runners. Repo-supplied jobs are subject to admin policy: privileged
jobs are denied unless :workflows-allow-privileged is set, and job images can
be restricted with :workflows-image-allowlist. A job that violates policy
fails its run with the reason instead of being dispatched.
# .cave/workflows/ci.yml
on: [push]
jobs:
test:
# No image? One is built on the fly from these nixpkgs names via Nixery.
dependencies: [nodejs, git]
# Directories persisted across runs in a repo-scoped volume — a big
# speed-up for dependency installs and incremental builds.
cache:
- ~/.npm
- node_modules
env:
NODE_ENV: test # workflow/job/step env: layering
steps:
- name: install
run: npm ci
- name: test # multi-line run: | blocks are supported
run: |
echo "building $GITHUB_REPOSITORY @ $GITHUB_SHA"
npm test
echo "passed=true" >> "$GITHUB_OUTPUT"
Reusable caches — a job's cache: directive lists in-container directories
to persist across runs (npm, cargo, ~/.go/pkg, ~/.cache/common-lisp, …).
Each is backed by a repo-scoped persistent volume (different repos never
share a cache), mounted at the declared path. The runner additionally keeps a
built-in Common Lisp FASL cache at ~/.cache/common-lisp for every job, so SBCL
builds stay incremental without any configuration.
GitHub-Actions-style workflows — cave aims to run the common subset of GitHub
Actions syntax so existing run:-based workflows port with little change:
- Triggers —
on: [push],on: [pull_request], andon: [tag](a push to arefs/tags/*ref; lets a release workflow run only on tags).manualtoo. env:layering — at workflow, job, and step level (step overrides job overrides workflow), injected into each step.- Standard env — every step gets the usual
GITHUB_*variables (GITHUB_REPOSITORY,GITHUB_SHA,GITHUB_REF/REF_NAME/REF_TYPE,GITHUB_RUN_ID,GITHUB_WORKFLOW,GITHUB_ACTOR,GITHUB_EVENT_NAME,GITHUB_WORKSPACE, …), theRUNNER_OS/RUNNER_ARCH/RUNNER_TEMPset, andCI=true. For everyGITHUB_*variable there is aCAVE_*twin with the same value (CAVE_REPOSITORY,CAVE_SHA, …) for cave-native scripts. - File commands —
$GITHUB_OUTPUT,$GITHUB_ENV,$GITHUB_PATH, and$GITHUB_STEP_SUMMARY(each with aCAVE_*twin). Writing to$GITHUB_ENV/$GITHUB_PATHcarries into later steps;$GITHUB_STEP_SUMMARYis streamed into the job log;::add-mask::redacts a value from the logs. - Multi-line
run: |literal and>folded block scalars are parsed (shell#comments inside a block are preserved). ${{ }}expressions inrun:,env:, andif:are evaluated — the full GitHub Actions expression language (operators,contains/startsWith/format/join/toJSON/fromJSON/status functions/…) against thegithub,env,secrets,runner,steps,matrix,needs, andjobcontexts. Acavecontext mirrorsgithub, so${{ cave.sha }}works like${{ github.sha }}.- Step
if:conditions gate execution: the default issuccess()(a step is skipped once a prior step failed);always()/failure()/cancelled()and any expression (e.g.if: steps.build.outcome == 'success') are honored. steps.<id>.outputs— a step's$GITHUB_OUTPUTis captured (keyed by itsid:) withoutcome/conclusion, and is available to later steps'${{ }}andif:.strategy.matrixexpands a job into one job per combination (withinclude/exclude), each exposing its combo as${{ matrix.* }}.- Job
outputs:+needs.<job>.outputs/needs.<job>.result— a job resolves its declaredoutputs:from its steps and publishes them to dependent jobs. Aneeds:on a matrix job fans in: the dependent waits on every expanded instance, itsoutputsmerge, andresultissuccessonly if all succeeded. uses:actions — like GitHub, the workspace starts empty; a step mustuses: actions/checkout@v4to populate it (cave does not auto-clone). Actions are referencedowner/repo@refand resolved cave-local (no github.com), in two tiers:- Built-in
actions/*— cave-authored, compiled into the runner. They run as in-runner orchestrators that effect their changes in the job container viapodman exec(so they share therun:steps' filesystem and environment, like GitHub driving a container job).actions/checkoutis GitHubcheckout@v4-compatible: it honorsrepository,ref,token+persist-credentials,path,clean,filter,sparse-checkout(+-cone-mode),fetch-depth(0= full history),fetch-tags,lfs,submodules, andset-safe-directory, and setscommit/refoutputs. (ssh-key/SSH auth is unsupported — usetoken; forpull_requestcave checks out the triggering commit rather than a synthetic merge ref.)actions/cache(cache@v4-compatible) restores bykey/restore-keysand saves at job end (a post step);hashFiles('**/glob')works in keys. The keyed store is runner-operator-owned: by default a local host dir, or setCAVE_RUNNER_CACHE_REMOTE(an rclone remote likemys3:bucket, S3/R2/B2/MinIO) for a shared, persistent store. The runner does the store I/O host-side with the operator's credentials — workflow code and sandboxed actions never see them.actions/upload-artifact/actions/download-artifact(artifact@v4-compatible) use the same store with a run-scoped key (artifacts/<run-id>/<name>), so a later job downloads what an earlier job uploaded. Uploaded artifacts are registered with the server and listed + downloadable in the run view; for the download endpoint the server reads the same store via:artifact-store-remote(an rclone remote with read creds) or:artifact-store-dir(a dir shared with a co-located runner). - Third-party
owner/repo@ref— fetched from the chamber and run in a dedicatedcave-actionscontainer (ausing: lispaction), sharing only the workspace + the file-command runtime dir. Untrusted code never enters the runner process; a crash/runaway dies with the throwaway container. The action uses the dependency-freecave-actions(ca) SDK (input/set-output/export-var/add-path/sh/api);with:inputs arrive asINPUT_*.
- Built-in
Not yet supported: Docker/JS/composite actions; docker://, URL, and ./local
action refs; per-action network policy + a minted job-scoped token (the slot is
wired, currently empty); hashFiles() (stubbed).
Themes
Switch themes in Settings → Theme. Built-in: Terminal Warmth, Solarized Dark, Nord, Dracula, Light.
Custom themes: fork cave/cave-themes, add .toml files:
# mytheme.toml
bg = "#1a1b26"
accent = "#7aa2f7"
font-url = "https://fonts.googleapis.com/css2?family=JetBrains+Mono"
font-mono = "'JetBrains Mono', monospace"
Push → themes sync automatically. Invalid values get lint issues filed on your themes repo.
Makefile targets
make build Build cave-server + cave + cavectl + zoekt-git-index
make load Load-test the Lisp tree without producing an image
make lint Run ocicl lint on the source
make podman-up Bring up the laptop dev stack via plain podman commands
make podman-down Tear it down
make runner-image Build the runner container image
make release Build prod images and tag :prod
make prod-install Install systemd quadlet units
make prod-start Start production
make prod-stop Stop production
make prod-rollback Roll back to cave:prod-previous
make prod-backup Back up all data
make prod-restore F=path/to/archive.tar.gz
make prod-status Show systemd service status
Testing
End-to-end tests are Playwright browser tests in tests/, run against a
running Cave stack:
npm install # once
npm run test:install # install the Chromium browser (once)
# Bring a stack up first (cavectl init, or make podman-up), then point the
# tests at it:
CAVE_URL=http://localhost:9080 \
CAVE_ADMIN_USER=admin CAVE_ADMIN_PASSWORD=admin \
npm test
npm test runs playwright test (15 specs covering smoke pages, registration,
org/repo flows, and the file browser). The tests assume an admin user can log
in via Keycloak.
There is not yet a unit/integration suite for migrations, the REST API, or merge-policy rules — see Planned above.
License
MIT
Author
Anthony Green green@moxielogic.com