:focal(smart))
Local-First & Portable CI
There's a weird thing we all accept about CI: you can't run it locally.
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.
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.
It doesn't have to be this way.
What if CI ran on your laptop first?
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.
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:
[project] name = "my-project" platforms = ["linux-64", "osx-arm64", "win-64"] channels = ["conda-forge"] [dependencies] python = ">=3.11" pytest = ">=8.0" ruff = ">=0.4" [tasks] test = "pytest -v tests/" lint = "ruff check src/" check = { depends-on = ["lint", "test"] } build = { cmd = "python -m build", depends-on = ["check"] }
And then you run it:
pixi run check
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.
The lockfile makes this actually work
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.
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.
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.
Your CI config becomes boring (and that's the point)
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.
GitHub Actions:
steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 - run: pixi run check
Gitlab CI:
test: image: ghcr.io/prefix-dev/pixi:latest script: - pixi run check
CircleCI:
jobs: check: docker: - image: ghcr.io/prefix-dev/pixi:latest steps: - checkout - run: pixi run check
These are all doing the same thing. The interesting logic isn't here. It's in pixi.toml, where it belongs.
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.
The development loop changes
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.
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.
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.
It's about where the logic lives
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 shouldn't also be the place where you define how to build your project.
When your build logic is encoded in GitHub Actions YAML, you've coupled two things that have no reason to be coupled. Your project's build process and your choice of CI provider are independent decisions. Tying them together means you pay a tax every time you want to change either one.
Keeping the logic in pixi.toml (with a lockfile for reproducibility) means your project knows how to build itself. CI just triggers it. Your laptop just triggers it. A new CI provider you haven't even evaluated yet? It can trigger it too.
# this is your entire CI pipeline pixi run check
Same command everywhere. That's the whole idea.
pixi is a cross-platform package manager and task runner built by prefix.dev.