KilnExperimental

CI that doesn't do the same work twice

Kiln is a CI/CD system for repositories built with Nix and TypeScript. It keys every result by the git trees the step read. A check that passed on a pull request doesn't run again after the merge, and a README edit doesn't rerun your tests.

Kiln is highly experimental. It runs CI for one developer's projects, its API changes every week, and there is no release to install.

  1. #38 main first run, nothing to reuse

    8 ran 55 s

  2. #39 pull request 14 edits core/Files.ts

    4 ran, 4 reused 47 s

  3. #40 main merges pull request 14

    8 reused 2 s

Open a pull request

Each row is a run and each column a step. A tile's glaze comes from the step's key, so a tile with the glaze of the tile above it had the same inputs and Kiln reused the result. Bright tiles ran, faded ones were reused, and orange ones are running. The repository is made up, and so are the durations.

A key is the git trees a step read

Before a task runs, Kiln computes its key. When the repository already has a result with that key, from the default branch or a pull request of the same repository, the step settles at once as reused.

Inputs
The git tree id of every path the task reads. That is the whole repository unless it names fewer.
packages/core 4b825dc642cb
Setup
The key of the install it starts from, which covers manifests and lockfile.
install 1c0a2f9d3e8b
Toolchain
The Nix dev shell it runs in, down to its store path.
devShells.default
Command
The command line, with every value interpolated into it.
pnpm run check

check
7c1e2a9f04d3

Git already hashes every file and directory, so a key needs no pass over the files. A change to the README leaves the tree of packages/core as it was, and so every key that reads only that tree.

Installs run once per lockfile

A setup such as Pnpm.install prepares a workspace once per key, and the tasks that need it start from a btrfs snapshot of it. When every one of those tasks reuses its result, the install doesn't run at all.

Tests run file by file

Vitest.files gives each test file a key of its own that follows its imports. Edit Plan.ts and only the test files that import it run again.

A changed outcome gets flagged

When a task fails under a key that passed before, or the other way round, Kiln marks the step. Either the key misses an input or the task is flaky. A test that passes on a retry counts as flaky, not as failed.

A pipeline is a TypeScript file

Pipelines live in .kiln/ci.ts and are written in Effect TypeScript. Kiln reads the default export and follows its rules to the steps they name. Your editor checks the file against the same SDK the workers run, and Nix flake outputs arrive as typed references.

Kiln's own pipeline. The tests run file by file in two shards, the website has an install of its own, and pull requests deploy a preview of it.

.kiln/ci.ts

import { Files, Task, cmd } from "@kiln/core"
import { Pnpm, Project, Vitest } from "@kiln/std"
import { flake } from "./flake.ts"

const shell = flake.devShells.default
const install = Pnpm.install({ shell })

const check = Task.make("check", {
  shell,
  setup: install,
  inputs: Files.of("packages", "sdk", "tsconfig.base.json"),
  run: cmd`pnpm run check`,
})

/** A test file runs again only when it, something it imports or the vitest config changed. */
const test = Vitest.files({
  shell,
  setup: install,
  tests: ["packages/*/test/**/*.test.ts"],
  inputs: Files.of("vitest.config.ts"),
  shards: 2,
})

/** The website has a lockfile of its own, so that Astro and Alchemy stay out of Kiln's package. */
const siteInstall = Pnpm.install({ name: "site-install", shell, dir: "site" })
const site = Task.make("site", {
  shell,
  setup: siteInstall,
  inputs: Files.of("site"),
  run: cmd`pnpm --dir site check`,
})

/** Pushes to main deploy the website (site/alchemy.run.ts) once every check passed. */
export default Project.alchemy({
  flake,
  stack: { shell, setup: siteInstall, dir: "site", env: "cloudflare" },
  checks: [check, test, site],
  previews: { url: (pr) => `https://pr-${pr}-kiln-site.hauke-911.workers.dev` },
})

Four kinds of step

Nix.build
Builds a derivation of the repository's flake. Its value is the output path, so a later step can deploy exactly what was tested.
Setup.make
Prepares a workspace once per key, such as an install. It runs only when a task that needs it runs.
Task.make
Runs a command in a dev shell, in a workspace that is checked out and cleaned before every task.
Action.make
Runs Effect code with side effects, such as a deploy. It names the steps it reads, the steps it waits for and what it may do.

Know before you push

kiln plan prints what an event would run, and fails when a path in the pipeline matches no file. kiln check runs your working copy like a pull request before you push, from jj or git.

$ kiln plan --event pr:7
pull request #7: pr, reuse all
  build  package
  setup  install (pulled in)
  task   check  ← needs install
  task   test  ← needs install
  setup  site-install (pulled in)
  task   site  ← needs site-install
  task   preview  ← needs site-install, after package, after check, after test, after site
  action preview-url  ← after preview

Pull requests can't deploy

A step that deploys or reads a secret says so in its grants. The rule On.pullRequest only accepts steps without grants, so a pipeline that hands a pull request a deploy doesn't type-check, and the controller refuses it again when it plans the run.

What each kind of run may do
Pull requestsPreviewsPushes to main
Runs asA user of its own, in a systemd unitThe trusted userThe trusted user
NetworkOnly allowlisted hosts, through the controller's proxyOpenOpen
SecretsNoneThose the project configuresThose the project configures
DeploysRefusedIts own stage onlyOne at a time, under the project's lease

Previews run only for branches of the same repository, never for forks, because whoever can push a branch is already trusted. Closing the pull request removes its stage. A deploy that a newer push overtook passes without deploying, and fleet hosts turn away a release whose fencing token is older than the last one they accepted.

Measured against what it replaced

The fleet ran on Gitea Actions until October 2026. These are the medians of its successful runs in September, next to Kiln's first warm runs.

Highly experimental

Kiln went live on 2 October 2026 and runs the CI and deploys of Hauke Schnau's projects. That is the only installation. Its controller assumes NixOS, Gitea, Bun and btrfs, its API changes most weeks, and there is no release, manual or support for anyone else.

This page explains how it works in case the ideas are useful to you. It lives in Kiln's repository, and Kiln's own pipeline checks it, deploys a preview for every pull request and deploys it to Cloudflare with Alchemy on every push to main.