A GitHub check is a pass-or-fail report attached to a commit. The old status API posts a simple state, description, and link; the newer checks API, which GitHub Actions uses, adds annotations, summaries, and action buttons, and both show up in the same Checks tab on a pull request. A check becomes required through branch protection or a repository ruleset, and once it is required, the merge button stays disabled until that specific check name reports success. Most repositories need exactly one required check tied to one workflow; anything more just adds places for a merge to get stuck.
A pull request with ten green ticks looks reassuring until one of them is the only one that matters and it is stuck grey.
This post separates the two check APIs, gives the minimum setup that actually blocks a bad merge, and covers the failure mode that wastes the most time: a required check that never runs.
Table of contents
- Status checks vs check runs
- Where checks come from
- Required checks: branch protection or a repository ruleset
- A minimum setup: one workflow, one required check
- Why Expected, waiting for status hangs forever
- Checks that gate deploys
- The cost of flaky checks
- How this fits the rest of the stack
- FAQ
Status checks vs check runs
GitHub has two APIs for reporting pass or fail on a commit, and both results land in the same place on a pull request. The commit status API is the older one: a state of pending, success, failure or error, a short description, and an optional link to more detail. Any service with a token can post one, which is how early third-party CI integrations worked before GitHub Apps existed.
The checks API is newer and richer. It belongs to GitHub Apps, and GitHub Actions is the most common one using it. A check run carries a title, a summary in Markdown, annotations that point at specific lines in the diff, and buttons for actions like re-run or request changes. Branch protection treats a required check by name regardless of which API produced it, so the distinction mostly matters when you are debugging why a check will not appear on the required list. See GitHub’s status checks documentation for the full field list.
Where checks come from
A workflow does not produce one check for the whole run; it produces one check per job, using the job’s name as the check name. Rename a job in the YAML and you have quietly renamed the check that branch protection is watching for, which is a common way a required check stops appearing after a refactor.
- Actions creates one check run per job, not one per workflow
- Other CI apps installed on the repo create their own check runs the same way
- A workflow with several jobs produces several checks, and only the ones you mark required actually block a merge
Required checks: branch protection or a repository ruleset
Classic branch protection lives under Settings, Branches: add a rule for a branch pattern, turn on require status checks to pass before merging, then pick checks from a list. That list is populated by history; a check has to have run at least once on the repository before it can be selected, so a brand-new workflow cannot be marked required until its first run exists.
Repository rulesets are the newer mechanism and solve a specific annoyance: one ruleset can target several branch patterns at once and rulesets stack, instead of one rule replacing another per branch. See GitHub’s branch protection documentation. For a single repository with one protected branch the two are functionally the same; rulesets earn their keep once you are protecting more than one branch pattern the same way.
A minimum setup: one workflow, one required check
The smallest useful setup is one workflow with one job that runs the test suite, and that job’s name marked required in branch protection.
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
The job is named test, so that is the check name to mark required. Add a second job later for lint or a build step, and either leave it optional or add it to the required list by its own name, one at a time.
Why Expected, waiting for status hangs forever
A pull request that shows a required check as grey with Expected, waiting for status means branch protection knows the check’s name but nothing has reported against it yet, and GitHub does not time this out on its own. It will sit there until something posts a result or someone removes the requirement.
- The workflow’s branch or path filters do not match this particular change, so it never triggers
- A job was renamed, so the check name branch protection expects no longer exists
- The workflow file was deleted or disabled after the check was marked required
- The pull request comes from a fork and the workflow needs a permission the fork does not have
The fix is usually to re-run the workflow manually from the Actions tab, or, if a class of change will genuinely never trigger it, to stop requiring that check for that branch pattern.
Checks that gate deploys
Deployment environments add a second layer on top of required checks: required reviewers, an optional wait timer, and branch restrictions before a job can deploy to an environment like production. Put a required check earlier in the same workflow and an environment protection rule on the deploy job, and a release needs the build to pass and a human to approve it, not green checks alone.
Skip the environment rule and the first workflow run to finish wins, which is a fine way to discover that whichever job finished first is now in production, deploy order included.
The cost of flaky checks
A required check that fails for reasons unrelated to the change, roughly one run in ten, trains a team to click re-run before reading the log. That habit is cheap until the day the failure is real, because a real failure looks identical to a flaky one from the pull request screen.
The fix is smaller than it feels: move the flaky step, usually a network call or a timing-dependent test, into its own non-required check, or add a retry around it. A required check allowed to be unreliable stops being a gate and becomes a formality everyone clicks past.
How this fits the rest of the stack
A required check is only as good as the workflow producing it, and that workflow still has to build the thing being shipped. Once checks are green, deploying straight from the repository on every push to main is the natural next step, and the RunxBuild hosting calculator adds up what a service and its database cost once that pipeline is live.
Useful related references:
- GitHub Merge Queue: What It Fixes, and the CI Change You Must Make
- GitHub Pipelines Log Retention: The Default, the Override, and the Audit Trail That Survives a Year
- CI/CD Pipeline Tools: 7 That Matter in 2026 (and How to Pick)
- Deploying from GitHub on RunxBuild
FAQ
What is the difference between a status check and a check run?
A status check is the older, simpler API: a state, a short description, and a link, postable by any authenticated service. A check run is the newer API used by GitHub Apps like Actions, adding a title, a Markdown summary, line-level annotations, and action buttons. Branch protection can require either by name.
How do I make a GitHub check required?
Go to Settings, Branches, and add or edit a protection rule for the branch, then enable require status checks to pass before merging and pick the check by name from the list. The check has to have run at least once on the repository before it appears in that list, so a new workflow needs one run before it can be required.
Why is my check stuck on Expected, waiting for status?
It means branch protection is watching for a check name that has not reported anything yet, and GitHub will not time it out automatically. Common causes are branch or path filters that do not match the change, a renamed job, a disabled workflow, or a fork pull request missing a permission the workflow needs.
Do required checks also gate deployments?
Only if you add a deployment environment with protection rules on top of them. Required status checks control whether a pull request can merge; environment rules like required reviewers and wait timers control whether a job can deploy, and combining both is what actually stops an unreviewed release.