GitHub Actions Is Slowly Killing Your Engineering Team

I was an early employee at CircleCI. I have used, in anger, nearly every CI system that has ever existed. Jenkins, Travis, CircleCI, Semaphore, Drone, Concourse, Wercker (remember Wercker?), TeamCity, Bamboo, GitLab CI, CodeBuild, and probably a half dozen others I’ve mercifully forgotten. I have mass-tested these systems so that you don’t have to, and I have the scars to show for it, and I am here to tell you: GitHub Actions is not good. It’s not even fine. It has market share because it’s right there in your repo, and that’s about the nicest thing I can say about it.

Buildkite is what CI should feel like. But first, let me tell you what CI should not feel like.

If You’re a Nix Shop, You Can Leave Early

Before I get into it: if you’re a Nix shop, take a look at Garnix. It evaluates your flake, figures out what needs building, and builds it. No YAML. No pipeline configuration. It just looks at your flake.nix and does the right thing. Sometimes the best CI configuration is no CI configuration.

Most shops are not Nix shops. This post is for the rest of you. I’m sorry.


Part I: The Descent

The Log Viewer, or: Where Your Afternoon Goes to Die

Let me start with the most visceral thing, the thing that will sound like I’m exaggerating but I am not.

Your build fails. You get a red X on your pull request. You click through to see what happened. This is where the ordeal begins.

First you land on the checks summary page, which shows you a list of workflow runs. Maybe one failed. Maybe three failed. You click the one that looks relevant. Now you’re on the workflow run page, which shows you a list of jobs. You click the failed job. Now you’re on the job page, which shows you a list of steps, all collapsed. You click the step that failed. The page hitches. You scroll. There is a pause, a held breath, and then the logs appear, slowly, like a manuscript being revealed one line at a time to a supplicant who has not yet proven worthy.

That’s three or four clicks just to see the error, and every one of them loads a new page with its own loading spinner, and none of them are fast. You are navigating a bureaucracy. You are filling out forms at the DMV of CI.

And then the log viewer itself. I have used every CI system known to man, and the GitHub Actions log viewer is the only one that has crashed my browser. Not once. Repeatedly. Reliably. Open a long build log, try to search for an error, and Chrome will look you in the eye and die. This is the log viewer for the most popular CI system in the world. This is the tool you are expected to use to understand why your build failed. It cannot survive contact with its own output.

With large logs (and if you have a real build system, you have large logs) you often can’t even scroll to the bottom. The page just gives up. The scrollbar is there, technically, but it’s decorative. It’s a suggestion. You drag it down and the page chokes and stutters and eventually you realize you’re not going to get there this way. So you download the raw log artifact and open it in a text editor like it’s 2003 and you’re reading Apache access logs on a shared hosting box. Except it’s 2025 and this is a product made by one of the richest companies on earth.

And when you’re done, when you’ve finally found the error, processed your grief, and want to go back to the pull request that started this whole ordeal, you hit the back button. Does it take you to the PR? No. It takes you to some other page in the GitHub Actions UI. A summary page, maybe. Or a different run. Or a page you don’t recognize. The back button in the GitHub Actions UI is a roulette wheel. You will land somewhere. It will not be where you wanted to go. You will click the back button again. You will land somewhere else. Eventually you give up and type the PR URL from memory or go find it in your browser history, which is now 80% GitHub Actions URLs, a fact that will haunt you when you look at it later.

So the logs have betrayed you, or perhaps they simply could not be made to appear at all. Now begins the second ritual: the debugging.

You push a commit. You wait. A runner picks it up. You watch logs scroll. Something fails. The error looks like someone fed a stack trace through a paper shredder and then set the shredder on fire. You add a run: env step to see what’s going on. You push again. You wait again. A twenty-minute feedback loop for a one-line change. You do this fourteen times. This is your afternoon now. You had plans. You were going to go outside. The afternoon belongs to the CI now. It has always belonged to the CI. You are only now perceiving this truth.

There is something devotional about the experience. You approach the logs. You make your offering of clicks and patience. The page considers your request. Sometimes it grants you the knowledge you seek. Sometimes it takes your browser tab instead, a small sacrifice, consumed, gone. You open a new tab. You try again. This is the ritual. You did not choose it. The work continues.

The YAML Trap

Every CI system eventually becomes “a bunch of YAML.” I’ve been through the five stages of grief about it and emerged on the other side, diminished but functional. But GitHub Actions YAML is a special breed. It’s YAML with its own expression language bolted on, its own context object model, its own string interpolation rules, and a scattering of gotchas that will slowly hollow you out as a person. Each gotcha leaves a mark. The marks do not fade.

Have you ever tried to conditionally set an environment variable based on which branch you’re on? Have you done the ${{ }} expression dance, misquoted something, and then waited four minutes for a runner to spin up just to discover your string got eaten? Of course you have. We all have. We have all stared at a diff that changes one character in a YAML expression and thought “I went to college for this.”

The expression syntax has the quality of a language that grew in the dark, unsupervised. It began as a convenience. It accreted features. Somewhere along the way it crossed a threshold, and now it exists in a liminal space- too complex to be configuration, too constrained to be a proper language. You learn its grammar not from documentation but from failure, each error message a koan that points toward understanding but does not provide it.

”But the Marketplace!”

Ah yes, the GitHub Actions Marketplace. The npm of CI. A bazaar of community-maintained actions of varying quality, most of which are shell scripts with a Dockerfile and a dream.

Every time you type uses: some-stranger/cool-action@v2, you’re handing a stranger access to your repo, your secrets, and your build environment. Yes, you can pin to a SHA. Nobody does. And even if you do, you’re still running opaque code you didn’t write and probably haven’t read, in a context where it has access to your GITHUB_TOKEN and whatever else you’ve stuffed in there. Every action you add is another set of house keys you’re handing to someone you’ve never met and hoping for the best.

The Marketplace has the energy of a night market in a city you don’t know, where every stall sells something that claims to solve your problem. Some of them do. Some of them have other intentions. You cannot tell which is which from the outside. You can only install them and see what happens. This is called “dependency management.” We have normalized it. The normalization does not make it safe.

You Don’t Own Your Compute

With GitHub Actions, you’re renting Microsoft’s runners. They’re slow, they’re resource-constrained, you can’t customize them in any meaningful way, and you’re at the mercy of GitHub’s capacity planning. Need a beefy machine for a big build? You can pay for a larger runner, at prices that will get you a calendar invite from finance titled “we need to talk,” and you still don’t control the environment.

You know how I know GitHub’s runners are bad? Because there’s an entire cottage industry of companies whose sole product is “GitHub Actions, but the runners don’t suck.” Namespace, Blacksmith, Actuated, Runs-on, BuildJet. There are at least half a dozen startups that exist purely to solve the problem of GitHub Actions being slow. Their pitch is, essentially, “keep your workflows, we’ll just make them not take forever.” The fact that this is a viable business model, that multiple companies can sustain themselves on the premise that the default compute for the world’s most popular CI system is inadequate, tells you everything you need to know.

Now, to be fair, you can bring your own runners to GitHub Actions. Self-hosted runners exist. You can set up your own machines, install Nix, configure your environment exactly how you want it. And this does solve the compute problem. Your builds will be faster. Your caches will be warm. But you’ll still be writing GitHub Actions YAML. You’ll still be fighting the expression syntax and the permissions model and the marketplace and the log viewer that crashes your browser. You’ve upgraded the engine but you’re still driving the car that catches fire when you turn on the radio.

I think the people who originally built GitHub Actions were probably well-intentioned. They probably cared about developer experience. But this is a Microsoft product now, and Microsoft is where ambitious developer tools go to become enterprise SKUs. The original engineers have long since been reorged into other divisions or ground down into product managers. The vision, if there was one, is entombed now. But if you press your ear to the floor during a particularly slow build, you can still hear its heart beating, faintly, beneath the floorboards.

The Little Things

Things that seem small but accumulate. Each one is survivable. Together they form a compelling case for simply walking into the sea. The sea does not have YAML. The sea does not require a GITHUB_TOKEN.

The actions/cache action is an exercise in futility. Cache keys are confusing, cache misses are silent, and cache eviction is opaque. You will spend more time debugging caching than you save by having a cache.

Reusable workflows can’t be nested beyond a certain depth, can’t access the calling workflow’s context cleanly, and live in YAML files that are impossible to test in isolation. At some point you realize you’re writing a distributed system in YAML and you have to sit down and think about the choices that led you here. The thinking changes you. You do not get the old version of yourself back. That person didn’t know what a workflow_call trigger was. That person was happy.

The GITHUB_TOKEN permissions model is a maze. permissions: write-all is a hammer, fine-grained permissions are a puzzle, and the interaction between repository settings, workflow settings, and job-level settings will make you want to lie down on the floor. I once spent an entire day on token permissions. I will never get that day back. It’s gone. I could have learned to paint. I could have called my mother. I could have mass-tested a new CI system. Anything.

Concurrency controls are blunt. Cancel in-progress runs on the same branch? Sure, one line. Anything more nuanced? No. The system does not wish to discuss nuance. The system has other concerns.

Secrets can’t be used in if conditions. This means you can’t do things like if: secrets.DEPLOY_KEY != '' to conditionally run a step based on whether a secret is configured. GitHub doesn’t want secret values leaking into logs via expression evaluation, which is a reasonable security concern. But the practical result is that you can’t write workflows that gracefully degrade when optional secrets aren’t present. Instead you need awkward workarounds like setting a non-secret environment variable that flags whether the real secret exists. It’s one of those decisions that makes perfect sense in a security review and makes you want to scream when you’re actually trying to write a workflow that works in both forks and the main repo.

”Just Write Bash Scripts”

At some point in every engineer’s CI journey, a temptation presents itself.

“What if I just wrote bash scripts?” the voice whispers. “What if I stopped fighting the CI system and just run:’d a big shell script that does everything? I could run it locally. I could test it. I’d be free.”

I understand the appeal. I have felt it myself, late at night, after the fourth failed workflow run in a row. The desire to burn down the YAML temple and return to the simple honest earth of #!/bin/bash and set -euo pipefail. To cast off the chains of marketplace actions and reusable workflows and just write the damn commands. It feels like liberation. It is not.

Here’s what actually happens. Your bash script works. You feel clever. You tell your coworkers about it. Then the script grows. It acquires conditionals. It acquires functions. It acquires argument parsing. It acquires a second script that it sources. Someone adds error handling. Someone else adds logging. Someone (and this person should be stopped, but never is) adds “just a little bit of parallelism.”

Three months later you have 800 lines of bash that reimplements job parallelism with wait and PID files, has its own retry logic built on a for loop and sleep, and parses its own output to determine success or failure. The script has become self-aware. There’s a race condition in the cleanup trap that only manifests on Linux kernel 6.x, and you are the only person who understands the script, and you are on vacation, and your phone is ringing.

You have not escaped CI. You have built a CI system. It’s just worse than every other CI system, because it’s written in bash, and nobody can follow it, and it has no test framework, and shellcheck is screaming into the void, and the void is also written in bash.

Bash is fine for glue. Bash is fine for “run these four commands in order.” Bash is not a build system. Bash is not a test harness. The fact that it can be coerced into impersonating both is not a recommendation. It’s a warning sign. You are not simplifying. You are moving complexity from a place with guardrails to a place with none. The complexity will not be grateful for its new freedom. The complexity will use its freedom to make your life worse.


Part II: The Emergence

There are other ways to live. Buildkite is what CI should feel like. Not joyful, nothing about CI is joyful, but tolerable. A tool that understands its purpose and does not fight you. Let me tell you how the other half lives.

A Log Viewer That Does Not Consume Your Browser

Buildkite’s log viewer is just a web page that shows you logs and doesn’t crash. I realize that’s a low bar. It’s a bar that GitHub Actions trips over and falls face-first into the mud, gets up, slips again, and somehow sets the mud on fire.

The terminal output rendering is excellent. Build logs look like terminal output, because they are terminal output. ANSI colors work. Your test framework’s fancy formatting comes through intact. You’re not squinting at a web UI that has eaten your escape codes and rendered them as mojibake. This sounds minor. It is not minor. You are reading build logs dozens of times a day. The experience of reading them matters in the way that a comfortable chair matters. You only notice how much it matters after you’ve been sitting in a bad one for six hours and your back has filed a formal complaint.

Annotations let your build steps write rich Markdown output (test failure summaries, coverage reports, deploy links,) right into the build page. You don’t have to dig through log output to find the thing you care about. The information comes to you. After years of fighting GitHub Actions’ collapsible log groups and wondering which of the seventeen nested folds contains the actual error message, this feels like stepping out of a cave into sunlight.

And debugging? Buildkite doesn’t make CI debugging fun. Nothing does. Nothing can. It is one of the irreducible sufferings of our craft. But because the agent runs on your infrastructure, you can SSH into the box. You can look at what’s actually happening. You can reproduce the environment locally because you built the environment. You are still mortal, but at least you have tools.

YAML That Knows Its Place

Buildkite has YAML too, but the difference is that Buildkite’s YAML is just describing a pipeline. Steps, commands, plugins. It’s a data structure, not a programming language cosplaying as a config format. When you need actual logic? You write a script. In a real language. That you can run locally. Like a human being with dignity and a will to live.

This is the boundary the bash zealots were actually looking for: not “put everything in bash,” but “put the orchestration in config and the logic in code.” Buildkite respects this boundary. GitHub Actions blurs it until you can no longer tell where configuration ends and programming begins, and then the programming happens in a language that can’t do basic arithmetic without ${{ }} and a prayer.

You Own Your Compute

With Buildkite, the agent is a single binary that runs on your machines. Your cloud, your on-prem boxes, your weird custom hardware. You control the instance types, the caching, the local storage, the network. Run agents on a fleet of massive EC2 instances with NVMe drives and 20 gigs of Docker layer cache. Run them on a Raspberry Pi. It doesn’t care. The fastest CI is the one with a warm cache on a big machine that you control.

You don’t see a cottage industry of “Buildkite, but faster.” You don’t need one. You just run bigger machines.

GitHub Actions will never give you this. GitHub Actions will give you a mass-produced Ubuntu VM with the emotional warmth of a hospital waiting room.

A Note on Running Your Own Infrastructure

I can hear the objection forming: “But I don’t want to run my own CI infrastructure. I just want to push code and have tests run.”

Fair. Running your own agents is not for everyone. If you’re maintaining a small open source library in your spare time, you shouldn’t have to spin up EC2 instances and manage autoscaling groups. GitHub Actions’ free tier for public repos is genuinely valuable for the OSS ecosystem, and I’m not here to tell a solo maintainer they need to set up Terraform to run their unit tests.

This post is mostly aimed at teams running production systems at businesses, places where you’re already managing infrastructure, where CI time is measured in engineering hours lost per week, where the build that takes 45 minutes is costing you real money in both compute and salary. If that’s you, the overhead of running Buildkite agents pays for itself quickly. If you’re publishing a 200-line npm package on your weekends, the calculus is different and that’s fine.

Dynamic Pipelines

In Buildkite, pipeline steps are just data. You can generate them.

Your pipeline YAML can contain a step that runs a script, and that script can emit more pipeline steps. Dynamically. At runtime. Based on whatever logic you want: which files changed, what day of the week it is, the phase of the moon, whether the build gods are feeling merciful.

So you write a script that looks at your monorepo, figures out what changed, and uploads exactly the right set of steps for the things that need building and testing. No hardcoded matrix. No if: contains(github.event.pull_request...) spaghetti. Just a program that outputs steps.

GitHub Actions has matrix strategies and if conditions and reusable workflows and all sorts of mechanisms that try to approximate this. They’re all worse. They’re declarative in the worst sense: you’re declaring things in a language that isn’t powerful enough to express what you mean, so you end up building a Rube Goldberg machine out of YAML and regret. The machine grows. You feed it. It does not thank you.

On the Matter of Plugins

I’ll be honest: Buildkite’s plugin system is structurally pretty similar to the GitHub Actions Marketplace. You’re still pulling in third-party code from a repo. You’re still trusting someone else’s work. I won’t pretend there’s some magic architectural difference that makes this safe.

The real difference is narrower than I’d like: Buildkite plugins tend to be thin shell hooks rather than entire Docker images with their own runtime, so there’s less surface area to hide things in, and you can usually read the whole plugin in a few minutes. More importantly, they run on your infrastructure, so at least the blast radius is something you control. It’s not a solved problem. It’s a smaller problem.

The Small Delights

Buildkite has custom emoji. You can put a little :parrot: or :docker: or your team’s custom emoji next to your pipeline steps. This is, objectively, a frivolous feature. It is also one of my favorite things about Buildkite, because it tells you something about the people who built it. They thought about what it feels like to use their product. They knew that CI is a slog and that a small dumb thing like a custom emoji next to your deploy step can make the slog a fraction more bearable.

GitHub Actions would never do this. GitHub Actions is a product designed by a committee that has never once asked “but is it delightful?” and it shows.

Buildkite is built by people who clearly use CI every day and have thought hard about what makes it tolerable. The result is a tool that, if not exactly joyful, at least does not make you want to lie down on the floor. In this industry, that’s high praise.


But Everyone Uses It!

Yeah. GitHub Actions won by being the default, not by being good. It’s free for public repos, it’s built into the platform everyone already uses, and it’s Good Enough. It’s the Internet Explorer of CI. It ships with the thing. People use it because switching costs are real and life is finite and we’re all just trying to ship code and go home.

If you’re a small team with a simple app and straightforward tests, it’s probably fine. I’m not going to tell you to rip it out.

But if you’re running a real production system, if you have a monorepo, if your builds take more than five minutes, if you care about supply chain security, if you want to actually own your CI: look at Buildkite.

I’ve been doing this for a long time. I’ve watched CI systems come and go. I’ve helped build one. The pattern is always the same: the CI system that wins market share is never the one that’s best at being a CI system. It’s the one that’s easiest to start using.

GitHub Actions is the easiest CI to start using. Buildkite is the best CI to keep using. And in the long run (assuming your CI hasn’t already ground you into a fine paste, assuming the YAML hasn’t hollowed you out entirely, assuming there’s still someone left who remembers what it was like before,) that’s what matters.


If your CI works and you’re happy, great, keep going. But if you’ve got that nagging feeling that it’s fighting you more than helping you: you’re not the problem. The tooling is. There are other ways to live.