---
title: "My red CI was a lie: a deleted workflow haunted every push while nothing real ran"
canonical: https://dxdev.com/blog/ghost-workflow-zero-second-failure-masked-no-ci-running/
datePublished: 2026-05-18
---
Every push to my `internal-api` repo showed a red failure, so I assumed CI was running and just unhappy with my code. That assumption was wrong twice over. The red came from a workflow I had deleted days earlier, one GitHub keeps firing forever because the registration outlives the `.yml`. A failed Actions payment on the org was firing its own identical red on top of that. And underneath all of it, the workflows I actually cared about had never run on a single one of my pushes. "Builds clean locally" had never once been checked by CI, and the angry red badge was the exact thing keeping me from noticing.

This is the kind of bug where unrelated facts conspire to produce one confident, false signal. Here is how it came apart.

## The 0-second failure that wasn't mine

The symptom was consistent. Every push produced a run that failed in 0 seconds, with a `startup_failure` conclusion, before any job logged a single line. I had been pushing Phase 0/A feature branches across two repos all day, the classic-ASP-plus-SPA sports SaaS monorepo and the modern `internal-api` (a Hono service), and every one of them came back wearing the same red mark.

The natural read is "my CI config is broken." So I went to look at the run, and the run pointed at a workflow I did not recognize:

```
gh api repos/<org>/<repo>/actions/workflows/<workflow-id>
{"state":"deleted","path":"BuildFailed","name":""}
```

Empty name. Path of `BuildFailed`. State `deleted`. This was a workflow file I had removed days earlier, and GitHub had quietly kept its registration alive. Deleting the `.yml` does not delete the workflow as far as the Actions service is concerned. The registration sticks around with `state: deleted`, and it keeps firing a 0-second startup failure on every push that matches its old trigger.

Running `gh run view` on what looked like a "real" failure showed the same ghost id staring back. And these runs are not retriable. Trying to re-run one returns:

```
run ... cannot be rerun
```

There is no `gh api` call to permanently remove a deleted workflow. I checked. The only way to make a ghost like this stop is to contact GitHub Support and ask them to purge the registration. You cannot self-serve it away. So every push will keep getting decorated with a cosmetic red failure until a human at GitHub clears it.

That alone is worth knowing. But the registration ghost was not the real problem. It was the smoke that hid the real problem.

## The billing block that muddied everything

Before I got to the real finding, I spent a stretch convinced the ghost diagnosis was wrong and that this was purely a billing problem. That is worth naming, because both things were true at once and the overlap is exactly what makes a bug like this hard.

The same org had a separate, genuine Actions problem. It is on a grandfathered legacy GitHub Bronze plan that does not run Actions on private repos at all, and on top of that the org's Actions payment had failed or hit its spending limit. GitHub kills a run before it executes when the org can't pay for it, which produces the same 0-second `startup_failure` with no logs that the ghost produces. So midway through I flipped: "this isn't a deleted workflow, every push is just getting killed at the gate for billing, and the prior session's ghost call was wrong."

Then I reconciled. It was both. The ghost workflow is real and fires its startup failure on every push regardless of billing. The billing block is also real and would kill the real workflows even if the ghost were gone. Two separate mechanisms, each independently capable of producing an identical 0-second red, stacked on top of each other. That is why it took a flip-flop to untangle: fixing one would not have explained the other, and the 0-second `startup_failure` they both emit is indistinguishable at a glance.

## The real revelation: nothing was triggering anyway

Once I stopped treating the red as meaningful and went to read the workflow definitions themselves, the floor dropped out.

The actual workflows in that repo, the ones I assumed were guarding my pushes, were CodeQL, lint, gitleaks, and npm-audit. I opened them and looked at the triggers. They gate on this:

```yaml
on:
  push:
    branches: [main, ben]
  pull_request:
    branches: [main, ben]
```

`main` and `ben`. That is it. I had been pushing feature branches and coalescing to `develop` all day, the Phase 0/A work across the sports SaaS and `internal-api` repos. None of those pushes were to `main` or `ben`, and I was not opening pull requests for them either. So every feature-branch and `develop` push triggered exactly nothing. No CodeQL, no lint, no gitleaks, no npm-audit.

Which means the validation I thought I had was a fiction. The eight held feature branches I had queued up to ship had never been touched by CI. "Build clean locally" was the only check that had ever run on them, and "build clean locally" is not CI. It is me, on one machine, with my own node_modules and my own environment, declaring victory.

The red badge made this worse, not better. If those pushes had shown nothing at all, a blank Actions tab might have prompted the question "wait, is anything running?" Instead the ghost painted every push red, and red reads as "CI ran and found something," which is the opposite of the truth. The noise impersonated a signal, and the signal it impersonated was the reassuring one: that there was a CI gate here at all.

## The honest call

The right move once I understood the state was not to power through. It was to stop. I had been about to bulk-ship five more feature branches to `ben`, each one about to inherit the same startup-failure decoration and, more importantly, none of them validated by anything real. Shipping five more startup-failure-stamped branches would have buried the problem deeper and trained me to ignore red entirely, which is the worst possible habit to build around a CI surface.

So I paused the bulk ship until at least one branch could pass CI cleanly. The cleanest way to force the real workflows to fire here is to open a PR, since they trigger on `pull_request` against `[main, ben]` and a PR can't be masked the way a direct push can. Then confirm the run that comes back is a real job and not the ghost id, and only then trust the green. A green you can trace to a real job is worth something. A red from an orphaned registration is worth nothing, and a silence from a branch your triggers exclude is worth less than nothing because it looks like safety.

## The takeaway

A red check and a meaningful red check are different things, and so are a green check and a meaningful one. Before you trust any CI signal, verify two things by hand.

First, verify which workflows actually trigger on the branch you pushed. Open the `on:` block and read the `branches:` list. If your feature branches are not in it and you are not opening PRs, then your pushes are triggering nothing, and "it passed" means "nothing ran." This is the failure that hides the longest because it produces no error to investigate.

Second, verify that the failing (or passing) run is your workflow and not an orphaned registration. A deleted workflow keeps its registration and can fire a 0-second `startup_failure` on every push, forever, and you cannot remove it through the API. It takes GitHub Support. Check the workflow id on the run. If it points at a `state: deleted` workflow with an empty name, that red is a ghost, and you should look straight through it to ask the harder question: when this noise is gone, is anything real running at all?

The whole episode cost me a false sense of safety on eight branches and a flip-flop between two real causes that emit the same 0-second red. The fix was not a config change. It was learning to distrust the badge and go read the trigger.

## Related

- [Our grandfathered $25 GitHub plan silently blocked every CI run, and the cheaper fix was a one-way door](github-bronze-legacy-plan-silently-blocks-all-ci): the billing layer that was stacked on top of the ghost, emitting the same 0-second failure
- [One Private Dependency, Five Different Failures](five-layer-deploy-break-one-private-dep): multiple independent failure modes producing a single misleading signal across deploy targets
- [One monorepo, two build lanes: keeping classic-ASP pushes at zero CI minutes](monorepo-two-build-lanes-zero-ci-minutes-legacy-spa): designing CI triggers so the right branches run the right jobs
- [UTC logs, a local clock, and the canary request: timezone discipline in an incident](canary-request-utc-logs-incident-timing): reading observability signals carefully before acting on them
- [Three compounding bugs spawned a burst of near-identical tickets, and tail -15 hid the evidence](tail-15-hid-the-success-line-and-caused-an-infinite-retry): another case where the visible signal obscured the real failure beneath it
