Why I Ditched GitHub Actions for Local CI Gates
Every project starts with the same well-meaning setup. You create a .github/workflows/ directory. You write a YAML file that runs your tests on push. You pick an Ubuntu runner. You feel responsible.
Then you wait fifteen minutes for a green checkmark that tells you something you could have known in forty seconds on your own machine.
I am not against continuous integration. I am against the assumption that CI belongs in the cloud by default. For a lot of projects — especially projects with hardware dependencies, GPU workloads, or large test suites — cloud CI is actively worse than running the same checks locally. Here is why I moved my gates onto my own machine and what changed.
The Breaking Point
The narrator-tts project has 685 tests. Some of those tests need a GPU. Specifically, they need an NVIDIA RTX 4070 Ti with CUDA libraries installed, libtorch linked correctly, and models loaded into VRAM.
GitHub Actions does not have a GPU runner. Not on the free tier, not on the paid tier without bringing your own self-hosted runner infrastructure. So the options were:
- Skip GPU tests in CI. Let them run locally. Hope I remember to run them before pushing.
- Use a self-hosted runner. Set up the GitHub Actions runner agent on my own machine, configure it as a self-hosted runner, and let GitHub trigger tests on my hardware.
- Move the gates local entirely. Stop pretending CI needs to live in the cloud.
Option one is what most people do, and it is bad. You end up with a CI pipeline that tests a subset of your code and gives you a false sense of confidence. The green checkmark means "the tests that can run without a GPU passed." It does not mean "the code works."
Option two works technically, but now you have a persistent runner agent consuming resources on your machine, polling GitHub for work, and you have introduced a network dependency into a process that could be entirely local. You also have the joy of debugging YAML workflow files, which is nobody's idea of a good time.
I went with option three.
What Local CI Looks Like
The setup is a Git pre-push hook. When I run git push, the hook fires and runs a series of checks. If any check fails, the push is blocked.
The hook script is a plain shell script. No YAML. No runner configuration. No matrix strategies for Node versions I do not use.
#!/bin/bash
set -e
echo "Running pre-push checks..."
# 1. Format check
cargo fmt --check
# 2. Clippy lints
cargo clippy -- -D warnings
# 3. Unit tests (no GPU required)
cargo test --lib
# 4. Integration tests (GPU required)
cargo test --test integration
echo "All checks passed."
Four gates. Each one catches a specific class of problem. The whole thing runs in under two minutes on my machine, which is faster than GitHub Actions takes to spin up a runner.
Format check. Catches whitespace drift, import ordering issues, the accumulated grime of a coding session. Fast. Runs in seconds.
Clippy. The Rust linter. Catches correctness bugs, unnecessary allocations, deprecated APIs. This is the gate that has prevented the most bugs from reaching the repository.
Unit tests. The pure logic tests. No GPU, no filesystem, no network. These run on every push and catch the vast majority of regressions.
Integration tests. These need the GPU. They load models, run inference, verify output. They take longer — maybe ninety seconds — but they catch the bugs that unit tests cannot.
The push does not leave your machine until every gate passes. No waiting for a remote runner. No checking GitHub in a browser tab. The feedback is immediate.
The Feedback Loop Argument
The strongest argument for local gates is not about GPUs or cost. It is about the feedback loop.
When I push code and CI runs in the cloud, the cycle looks like this: write code, commit, push, switch to browser, wait for CI, see results, switch back to editor, fix issues, commit, push again. Each cycle is fifteen to twenty minutes if the queue is short. Longer if GitHub Actions is having a bad day.
When gates run locally, the cycle is: write code, push, wait two minutes, done. Or: fix the issue, push again. No context switch. No browser tab. No queue.
The length of your feedback loop determines how much you get done in a day. This is not a productivity hack. It is physics. If each iteration costs you fifteen minutes of waiting, you will iterate fewer times. If each iteration costs two minutes, you will iterate more, catch more bugs, and ship better code.
What About Collaboration?
This is the objection I hear most. "CI in the cloud exists so that everyone on the team runs the same checks."
That is true and important for teams. If you have five developers pushing to the same repository, you need a source of truth for what "passing" means. Cloud CI provides that.
But most of my projects have one developer. The source of truth is my machine. And for projects with multiple contributors, you can run both — local hooks for fast iteration during development, and cloud CI as the merge gate for the main branch.
The mistake is using cloud CI as your only gate. You pay the latency cost on every iteration, including the ones where you are the only person who will ever see the code.
The Cost Question
GitHub Actions gives you 2,000 free minutes per month for private repositories. That sounds like a lot until you do the math.
If your test suite takes ten minutes and you push twenty times per day across your projects, that is 200 minutes per day. Over a 22-day month, that is 4,400 minutes. You exceed the free tier in twelve days.
Public repositories have unlimited minutes, but now your CI configuration, test output, and failure logs are visible to anyone who looks at your repo. That may be fine. It may not.
Local gates cost nothing after the initial hardware investment. The electricity to run tests on a machine you already own is negligible. And the compute is yours — no queue, no rate limits, no surprise bills when a test hangs for six hours because you forgot to set a timeout.
The Real Principle
The principle is not "GitHub Actions is bad." It is a fine product. The principle is: your CI should run where your tests run fastest.
If your tests are pure unit tests with no special hardware needs, cloud CI is convenient and the latency is acceptable. Use it.
If your tests need a GPU, a specific OS, large model files, local databases, or any resource that exists on your development machine but not in a cloud runner, move the gates local. The cloud will not catch up to your hardware. You will just spend time working around its limitations.
The best CI pipeline is the one that runs in two minutes on the machine in front of you, not the one that takes fifteen minutes on a server you cannot SSH into.
I stopped reaching for .github/workflows/ by default. When I start a new project, the first thing I set up is a pre-push hook. If the project grows to the point where shared CI makes sense, I add it. But I do not start there.
Cloud CI is a tool, not a default. Use it when it earns its place.