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.
-
#38 main first run, nothing to reuse
8 ran 55 s
-
#39 pull request 14 edits core/Files.ts
4 ran, 4 reused 47 s
-
#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/core4b825dc642cb- Setup
- The key of the install it starts from, which covers manifests and lockfile.
install1c0a2f9d3e8b- 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
check7c1e2a9f04d3
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` },
})Ebb's pipeline, shortened. Pushes to main submit the build to the Chrome Web Store once qa passed.
.kiln/ci.ts
import { Action, Files, Kiln, On, Task, cmd } from "@kiln/core"
import { Bun } from "@kiln/std"
import { flake } from "./flake.ts"
import * as WebStore from "./WebStore.ts"
const shell = flake.devShells.ci
const install = Bun.install({ shell })
/** The extension as the store gets it. */
const build = Task.make("build", {
shell,
setup: install,
run: cmd`bun run zip`,
inputs: Files.glob("src/**", "static/**", "tsconfig.json"),
outputs: { zip: "ebb.zip" },
})
const qa = Task.make("qa", { shell, setup: install, run: cmd`bun run qa` })
/** Submits the build once qa passed. A pull-request rule refuses it, because it reads a secret. */
const release = Action.make("release", {
needs: { zip: build.outputs.zip },
after: [qa],
grants: { secrets: ["chrome-web-store"] },
}, function*({ zip }) {
return yield* WebStore.release({ zip })
})
export default Kiln.project({
rules: [On.pullRequest([qa, build]), On.push("main", [release])],
})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 previewPull 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.
| Pull requests | Previews | Pushes to main | |
|---|---|---|---|
| Runs as | A user of its own, in a systemd unit | The trusted user | The trusted user |
| Network | Only allowlisted hosts, through the controller's proxy | Open | Open |
| Secrets | None | Those the project configures | Those the project configures |
| Deploys | Refused | Its own stage only | One 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.