Appearance
CI parity
Starting state
The repo has a daft.yml driving worktree creation, plus a .github/workflows/test.yml doing roughly the same operations — in a slightly different order, sometimes with subtly different commands.
yaml
# daft.yml — abridged
hooks:
worktree-post-create:
jobs:
- name: install-tools
run: mise install
- name: install-deps
run: pnpm install --frozen-lockfile
needs: [install-tools]
- name: codegen
run: ./scripts/codegen.sh
needs: [install-deps]
- name: services-up
run: docker compose up -d --wait
needs: [install-deps]
- name: migrate
run: pnpm db:migrate
needs: [services-up]
- name: warmup
run: cargo build --workspace
background: true
needs: [install-deps]yaml
# .github/workflows/test.yml — abridged
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: pnpm install --frozen-lockfile
- run: ./scripts/codegen.sh
- run: pnpm testA comment near the top of test.yml: "Keep in sync with daft.yml."
Someone adds protoc-gen-go to the codegen job in daft.yml and doesn't update the workflow. CI breaks; the failing job's logs point at a missing binary inside a generated module. Or a PR bumps mise's pinned Node version, and reviewers are arguing whether to "also update the CI matrix." Whether the workflow gets the right edit is a coin flip — and that's the part that scares you.
The reach for daft: stop maintaining two parallel descriptions of "how this project sets up." Make CI run the same hooks the worktree does — same daft.yml, same job orchestration, same env contract.
What changes
The CI workflow shrinks to four logical steps: install daft, trust the hooks, run daft hooks run worktree-post-create, run tests. The dep install, codegen, service boot — all of it — moves out of the workflow file into the same daft.yml your local worktrees already use.
Adding a step (a new mise tool, a new compose service) updates one file and applies to local AND CI. The "keep in sync" comment goes away because there's nothing to keep in sync.
Recipe
yaml
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install daft
run: curl -fsSL https://daft.avihu.dev/install.sh | bash
- name: Trust hooks
run: git daft-hooks trust --all
env:
DAFT_NONINTERACTIVE: "1"
- name: Run worktree-post-create hooks
run: daft hooks run worktree-post-create
- name: Run tests
run: pnpm testdaft hooks run worktree-post-create invokes the same jobs the worktree-post-create hook fires locally. Whatever your contributors' worktrees do on daft start, CI does too — using the same daft.yml, the same needs: graph, the same env vars.
DAFT_NONINTERACTIVE=1 tells daft never to prompt. git daft-hooks trust --all pre-trusts the hooks (they won't run otherwise — see Trust & security).
Variants
By CI vendor — the daft contract is the same; only the workflow syntax differs.
GitLab CI
yaml
# .gitlab-ci.yml
test:
image: ubuntu:24.04
variables:
DAFT_NONINTERACTIVE: "1"
before_script:
- apt-get update && apt-get install -y curl git
- curl -fsSL https://daft.avihu.dev/install.sh | bash
- git daft-hooks trust --all
- daft hooks run worktree-post-create
script:
- pnpm testGeneric shell-based CI (Buildkite, Jenkins, CircleCI)
For any CI without a daft-specific helper, the pattern is the same: install daft, trust hooks, run hooks, run tests.
bash
curl -fsSL https://daft.avihu.dev/install.sh | bash
export DAFT_NONINTERACTIVE=1
git daft-hooks trust --all
daft hooks run worktree-post-create
pnpm testSkipping local-only steps in CI
Some hook jobs make sense locally but not in CI:
direnv allow— no interactive shell in CI; direnv-loaded vars come from the workflow'senv:instead.op signin/ interactive vault unlocks — CI uses its own secret store, not your 1Password.- Backgrounded warmups — sometimes. See decision rule below.
Use skip: to gate these:
yaml
- name: seed-envrc
run: |
cp .envrc.example .envrc
direnv allow .
skip:
env: { CI: "true" }Most CI providers set CI=true automatically; the few that don't, set it yourself in the workflow.
The flip side — jobs that only make sense in CI — use only::
yaml
- name: ci-coverage-config
run: ./scripts/setup-coverage-reporter.sh
only:
env: { CI: "true" }Decision rule for warmups in CI
A Background warmup is correct to skip in CI when its only purpose is "first interactive command is fast" — the cache it primes is per-worktree (target/ in Rust, .vite/ in Vite) and CI's runner is ephemeral, so priming serves no one.
A warmup is correct to run in CI when it primes a shared, content-addressed cache that the test step also benefits from — sccache, the Go build cache (when CI persists ~/.cache/go-build), Gradle's configuration cache (when CI persists it). The "warmup" work isn't wasted: it's exactly what the test step needs to be fast.
Concretely:
yaml
# Skip — per-worktree cache, CI doesn't reuse it
- name: warmup-vite
run: pnpm exec vite optimize --force
background: true
skip: { env: { CI: "true" } }
# Run — primes a shared sccache that the next cargo build will hit
- name: warmup-build
run: cargo build --workspace
background: true
env:
RUSTC_WRAPPER: sccacheCI-specific env vars and secrets
Local hooks fetch secrets from a vault or sops. CI hooks fetch from CI's secret store (GitHub Actions secrets, GitLab variables). The hook stays the same; the source changes.
GitHub Actions:
yaml
- name: Run worktree-post-create hooks
run: daft hooks run worktree-post-create
env:
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
API_KEY: ${{ secrets.CI_API_KEY }}If the local hook has a seed-envrc-from-1password step that doesn't exist in CI, gate with skip: { env: { CI: "true" } } and inject the vars directly via the workflow's env:.
Layer caching with a daft-aware base image
The fastest CI builds reuse a base image that already has the toolchain and dependencies installed. Bake the install into your CI image:
dockerfile
# .ci/Dockerfile
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl git build-essential
RUN curl -fsSL https://daft.avihu.dev/install.sh | bash
COPY mise.toml /tmp/mise.toml
RUN mise install
WORKDIR /workspace
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfileThen in CI, skip the cached jobs by tag:
yaml
- name: Run hooks (skip already-cached steps)
run: daft hooks run worktree-post-create
env:
DAFT_SKIP_TAGS: "tools-install,deps-install"Tag the cached jobs in daft.yml:
yaml
- name: install-tool-versions
run: mise install
tags: [tools-install]
- name: install-deps
run: pnpm install --frozen-lockfile
tags: [deps-install]Idempotency & safety
CI-specific concerns on top of the local idempotency story:
Trust state is per-CI-host. git daft-hooks trust --all writes trust to the runner's local state. Ephemeral runners trust on every run (correct); persistent runners trust once during provisioning.
No interactive prompts. DAFT_NONINTERACTIVE=1 tells daft to fail fast instead of prompting. Always set it. A daft waiting on stdin in CI is a daft that times out 6 minutes later.
Don't run pre-remove hooks in CI
CI runners create a worktree implicitly via actions/checkout; they don't daft remove it. If you've wired pre-remove cleanup that destroys real state (a production database snapshot, an external registry entry), never fire it from CI. Pre-remove is for developer-machine teardown, not build-server cleanup.
Where to next
- Toolchain bootstrap — the primary candidate for CI parity. The same
pnpm install --frozen-lockfileis one source of truth for "how this project installs." - Trust & security — why hooks need trust, how
--allworks, and what happens with untrusted hooks. - YAML reference —
tags,skip,onlyschema for splitting hook execution by environment.