Debug journal

Zero checks on a fork PR means GitHub is waiting on a human

A single traffic signal on an empty road at dusk with all three lamps unlit and dark, neither red nor green, waiting for power.

I opened a small fix against a repo I had never contributed to before, from my own fork. The diff was clean, the tests passed locally, and the maintainer read it and liked it. Then he left a comment that is the reason for this post: the CI success gate seems stuck, is this something to do with your fork? The pull request was sitting there with no green checkmark, no red X, no spinning yellow dot. Just nothing. The merge box would not go green because it was waiting for a status that was never going to arrive on its own.

Here is the one sentence I want anyone hitting this to have first: a fork pull request from someone who has never had a commit merged into the repo will not run any workflow until a maintainer with write access clicks approve, and while it waits, every signal you can query reads zero, not red. The absence is the message.

What I thought was wrong

My first suspicion was the same as the maintainer's: my fork. When you push a branch on a fork and open a PR, it is easy to imagine a misconfigured workflow file, a missing secret, a branch protection rule that does not apply across the fork boundary. So I went looking for a broken run to read. I expected to find a failed job, or at least a queued one, somewhere I could open and inspect.

I found nothing, and nothing is harder to debug than a failure, because a failure at least tells you where to look. A pull request with no checks at all gives you no log to open, no red line to read, no stack trace to follow. It looks identical to a repo that simply has no CI configured, which is the trap. You start wondering whether the workflows exist at all.

How I found out

I stopped guessing and asked the API three direct questions about the head commit. The combined commit status:

gh api repos/OWNER/REPO/commits/$SHA/status returned {"state":"pending","total_count":0}. Pending, with zero statuses. Then the check runs: gh api repos/OWNER/REPO/commits/$SHA/check-runs returned {"total_count":0}. Zero. Then the Actions runs filtered to that exact commit: gh api "repos/OWNER/REPO/actions/runs?head_sha=$SHA" returned {"total_count":0}. Also zero.

Three queries, three zeros, and crucially not a single failure among them. A broken workflow produces a run you can read. A failed job produces a check run with conclusion: failure. Here there was no run to fail. The status was pending not because something was in progress but because nothing had been allowed to start.

The control that settled it: the repo's own in-branch automation, the dependabot PRs that live inside the repository rather than on a fork, were all getting green runs at the same time. So the workflows existed, the runner was healthy, and the configuration was fine. The only thing different about my PR was that it came from a fork, opened by an account with no prior merged contribution to that repo. That narrowed the cause to exactly one mechanism.

What was actually happening

GitHub holds workflow runs from fork pull requests behind a manual approval gate, and the default policy on public repositories is to require that approval for first-time contributors. GitHub's own documentation states it plainly: workflow runs triggered by a contributor's pull request from a fork may require manual approval from a maintainer with write access, and by default all first-time contributors require approval to run workflows on public repositories.

The reason is not bureaucracy, it is security. A fork PR can change the workflow files themselves, and those workflows run with access to the repository's secrets and runners. If anyone's first push from a fork ran arbitrary CI immediately, a stranger could open a PR that exfiltrates your tokens on the first job. So GitHub puts a human in the loop the very first time an account contributes, and only that first time. The repository admin chooses the strictness under the Actions settings, with three options: Require approval for first-time contributors who are new to GitHub, Require approval for first-time contributors, and Require approval for all external contributors. The middle one is the public default, and it is why a brand-new contributor's very first PR sits dark.

That also explains the shape of what I saw. The run is not failed and not really queued in the normal sense; it is withheld, pending a click. The merge-status panel on the PR shows it as awaiting approval to a maintainer who has the button, but to an outside contributor and to most API queries on the head commit, the run simply does not exist yet. You are not looking at a broken pipeline. You are looking at a pipeline that has not been switched on.

The fix

For the maintainer it is one action. On the pull request, the merge-status panel carries an approve control; clicking it releases the held workflows and they run normally from then on, for this PR and for every future PR from the same now-known contributor. There are two other paths if the maintainer would rather not wait on CI at all: an admin can merge directly, or, if the PR has allow edits from maintainers enabled, push the branch into the repository where in-repo workflows run without the gate.

For me, the contributor, the fix was to stop offering to debug my own fork and instead hand the maintainer the exact diagnosis and the exact button. Which is the real lesson, because the failure mode here is social as much as technical: the natural assumption on both sides is that the fork is misconfigured, and that assumption sends the contributor off auditing workflow files that were never the problem. Naming the gate out loud is the fix. I ran the suite locally, pasted the green output into the thread so the held CI was not blocking the decision, and said: this is the first-time-contributor approval gate, one click on your side opens it.

The rule I took away

An absent signal is still a signal, and it is a different one from a negative signal. Red means the system ran and disagreed with you. Empty can mean the system never ran, and the reasons it never ran are a separate diagnostic category that no amount of staring at a missing log will reveal. When CI is stuck with no checks, the productive move is not to hunt for the broken job. It is to ask whether a job was ever allowed to start, and the cleanest way to ask is three API calls on the head commit: combined status, check runs, Actions runs. If all three come back zero while the repo's own branches go green, you are not looking at a break. You are looking at a gate, and gates open from the other side.