Local-First & Portable CI | prefix.dev
Written on May 28, 2026<br>Local-First & Portable CI
Wolf Vollprecht
*]:my-0 prose-th:p-2 prose-th:font-semibold prose-td:[&>*]:my-0 prose-td:p-2">p]:m-0 [&_img]:mx-auto [&_li>p]:m-0">There's a weird thing we all accept about CI: you can't run it locally.<br>Your CI pipeline lives in some YAML file that only makes sense to one specific CI provider. It uses provider-specific actions to set up languages, provider-specific syntax for caching, provider-specific ways of defining matrix builds. The actual build logic (run the tests, lint the code, build the artifact) is in there somewhere, buried under all that plumbing.<br>And so you end up with this workflow where you push a commit, wait for CI, watch it fail on something you could have caught in 10 seconds on your laptop, fix it, push again, wait again. We've all done the "fix CI" commit chain of shame. Five commits in a row, each one a tiny tweak, because there's no way to run the pipeline locally and you're just guessing at what the CI environment looks like.<br>It doesn't have to be this way.<br>What if CI ran on your laptop first?<br>The core idea is simple: your build logic should live in your project, not in your CI provider's config format. If your test command is pytest -v tests/, that should be defined once, in your repo, and it should be runnable anywhere. On your laptop. On your colleague's machine. On CI. Same command, same environment, same result.<br>This is what we've been building towards with pixi. You define your tasks and dependencies in a pixi.toml that lives in your repo:<br>[project]<br>name = "my-project"<br>platforms = ["linux-64", "osx-arm64", "win-64"]<br>channels = ["conda-forge"]
[dependencies]<br>python = ">=3.11"<br>pytest = ">=8.0"<br>ruff = ">=0.4"
[tasks]<br>test = "pytest -v tests/"<br>lint = "ruff check src/"<br>check = { depends-on = ["lint", "test"] }<br>build = { cmd = "python -m build", depends-on = ["check"] }<br>And then you run it:<br>pixi run check<br>That's it. That works on your laptop right now. It also works on any CI system that can run a shell command. Which is all of them.<br>The lockfile makes this actually work<br>The tricky part with "just run it locally" has always been environment differences. Your laptop has Python 3.12, CI has 3.11. You have numpy 1.26, CI resolved 1.25. "Works on my machine" is a meme for a reason.<br>The pixi.lock file solves this. When you run pixi install, pixi resolves your entire dependency tree and writes down the exact version, build hash, and download URL of every single package, for every platform you've declared support for. This lockfile goes into git.<br>So when your colleague clones the repo and runs pixi install, they don't get "compatible" packages. They get the exact same packages you have. Same versions, same builds, same hashes. When CI runs pixi install, same thing. The lockfile is the single source of truth, and it eliminates an entire class of "but it worked for me" bugs.<br>Your CI config becomes boring (and that's the point)<br>Once your build logic lives in pixi.toml and your environment is pinned by pixi.lock, your CI config shrinks to almost nothing. Its only job is: check out the code, set up pixi, run the task.<br>GitHub Actions:<br>steps:<br>- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2<br>- uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6<br>- run: pixi run check<br>Gitlab CI:<br>test:<br>image: ghcr.io/prefix-dev/pixi:latest<br>script:<br>- pixi run check<br>CircleCI:<br>jobs:<br>check:<br>docker:<br>- image: ghcr.io/prefix-dev/pixi:latest<br>steps:<br>- checkout<br>- run: pixi run check<br>These are all doing the same thing. The interesting logic isn't here. It's in pixi.toml, where it belongs.<br>And this has a nice side effect: switching CI providers is trivial. You're not migrating build logic. You're just writing a new thin wrapper that calls pixi run. That's a 15-minute job, not a week-long migration project.<br>The development loop changes<br>When you can run CI locally, your whole development loop gets tighter. You stop treating CI as this remote oracle that you submit code to and hope for the best. Instead, you run pixi run check before you commit. If it passes on your machine, it's going to pass on CI, because it's the same environment, the same dependencies, the same commands.<br>New people joining the team don't need a "getting started" doc with 30 setup steps. They clone the repo, run pixi install, and they have the exact same environment as everyone else. The lockfile guarantees it.<br>Platform differences stop being scary too. You declare platforms = ["linux-64", "osx-arm64", "win-64"] and the lockfile resolves the right packages for each one. Your Linux CI, your colleague's MacBook, the intern's Windows laptop: they all get correct, reproducible environments.<br>It's about where the logic lives<br>The deeper idea here is about separation of concerns. CI providers are good at triggering builds on events, managing runners, displaying results, handling secrets. That's what they should do. They...