Blocking Breaking Changes with Rover in GitHub Actions
A schema check that reports its verdict and lets the merge proceed is documentation, not a gate. Turning it into one is a small amount of YAML and a branch-protection setting, plus a handful of decisions about credentials, concurrency and overrides that determine whether the gate survives contact with a deadline. This page is the complete workflow. It sits under Schema Validation in CI/CD Pipelines.
When to use this pattern
- Use it for every subgraph repository, because a check that runs in some repositories and not others gives a false sense of coverage.
- Use it as soon as a second team publishes to the graph, which is the moment one team can break another without knowing.
- Skip the publish half only if your deploys are manual, but never skip the check half — checking is cheap and catches the expensive class of mistake.
Prerequisites
Two jobs, two triggers, two credentials
The single most important structural decision is that checking and publishing are separate jobs with different triggers and different keys.
The check job runs on pull requests. It reads the registry, composes your proposed schema against every other subgraph, and reports breaking changes against real traffic. It needs a key that can read and check, and nothing more — a compromised pull-request runner should not be able to write to your graph.
The publish job runs only on the default branch after merge. It writes the new schema to the registry, which triggers composition and a launch. It needs a publish key, and it should be the only place that key exists.
Keeping them in one workflow file makes the ordering legible to whoever reads it next; keeping them as separate jobs with distinct if conditions makes a misconfigured trigger unable to turn a pull-request run into a production write.
Implementation walkthrough
The workflow below is complete. Every non-obvious line carries a comment explaining what it prevents.
# .github/workflows/schema.yml
name: schema
on:
pull_request:
push:
branches: [main]
env:
APOLLO_GRAPH_REF: my-graph@current
SUBGRAPH_NAME: orders
ROVER_VERSION: v0.23.0 # pinned: a moving latest changes verdicts silently
jobs:
check:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rover
run: |
curl -sSL https://rover.apollo.dev/nix/${{ env.ROVER_VERSION }} | sh
echo "$HOME/.rover/bin" >> "$GITHUB_PATH"
- name: Check against the registry
env:
# Read-only. This job must not be able to write to the graph.
APOLLO_KEY: ${{ secrets.APOLLO_CHECK_KEY }}
run: |
rover subgraph check "$APOLLO_GRAPH_REF" \
--name "$SUBGRAPH_NAME" \
--schema ./schema.graphql
publish:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
# Serialise publishes for this subgraph: two concurrent runs overwrite
# each other and the registry ends up matching neither commit.
concurrency:
group: publish-${{ github.repository }}-orders
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Install Rover
run: |
curl -sSL https://rover.apollo.dev/nix/${{ env.ROVER_VERSION }} | sh
echo "$HOME/.rover/bin" >> "$GITHUB_PATH"
- name: Publish
env:
APOLLO_KEY: ${{ secrets.APOLLO_PUBLISH_KEY }}
run: |
rover subgraph publish "$APOLLO_GRAPH_REF" \
--name "$SUBGRAPH_NAME" \
--schema ./schema.graphql \
--routing-url https://orders.internal.example.com/graphql
Then make it a gate rather than a report. In branch protection, mark the check job as a required status. This is the step people skip, and without it the entire workflow is advisory — a red check next to a merge button that still works trains everyone to ignore it within a fortnight.
Verification steps
Prove the gate blocks, prove it does not block valid work, and prove the publish is serialised.
# 1. Open a pull request that removes a field real clients still select.
# Expect: check fails, merge button disabled.
sed -i '/legacyRef/d' schema.graphql && git commit -am "remove field" && git push
If the merge button remains available, branch protection is not enforcing the status — that is a settings problem, not a workflow problem, and it is by far the most common reason a gate does not gate.
# 2. Open a purely additive pull request. Expect: green, merge available.
# 3. Merge two subgraph changes in quick succession and confirm the registry
# ends up matching the second commit rather than an interleaving of both.
Finally, confirm the check runs against the right variant. A workflow pointed at a staging variant reports on staging’s traffic and clients, which is reassuring and irrelevant to production. Print APOLLO_GRAPH_REF in the job log so the variant is visible in every run.
A gate is only as durable as its noise level, so it is worth thinking about what happens after the first month. Two numbers tell you whether yours is healthy: how often the check fails, and how often a failure was genuinely worth stopping for. A gate that fails on a third of pull requests and is overridden most of those times has already stopped functioning — people learn the override before they learn the error message, and the protection is gone while the status badge remains green.
The remedy is almost never to loosen the check globally. It is to separate the findings that must block from the findings that should only inform, and to report them as two distinct statuses. Composition failures must block, because merging one guarantees a broken default branch that affects every team. Lint findings and low-traffic deprecation warnings can be advisory, visible in the pull request without preventing a merge. Splitting them keeps the blocking status meaningful, which is the only reason anyone reads it.
The opposite failure is a gate that has never failed at all. That usually means it is scoped to one subgraph, pointed at the wrong variant, or not actually required — and it is worth investigating rather than celebrating, because a check that cannot fail is protecting nothing.
Before treating the workflow as done, it is worth checking it against four properties. Each is a yes-or-no question, and a no in any of them means the gate is weaker than it appears.
Common mistakes & gotchas
Publishing from a feature branch. It changes what the routers believe about a subgraph that has not been deployed. Restrict the publish job to the default branch with an explicit if, not only with a trigger.
Installing Rover from latest. A new version can change a verdict, and the pull request that discovers it is unrelated to the change. Pin it and upgrade deliberately.
Publishing before deploying. For additive changes, publish after the deploy so the schema never promises a field the running service cannot serve. For removals, publish first.
Frequently Asked Questions
How should an override work when a break is intentional?
Through a labelled escape hatch that records the decision. A schema-break-approved label that the workflow reads, combined with a required comment explaining why, keeps the gate meaningful while making the exception visible in the pull request forever. What does not work is a gate with no override at all — it gets bypassed by disabling the check, and it is rarely re-enabled.
Should the check run on draft pull requests?
Yes. The whole value is learning about a conflict while the change is cheap to reshape, and a draft is the cheapest possible moment. The publish job is what must be restricted, not the check.
What about a change spanning two subgraph repositories?
Check each repository normally, then compose the two proposed schemas together locally as an extra step in whichever pull request lands second. Land them in a deliberate order — additive side first — so no intermediate state breaks composition. The registry-based check cannot see an unmerged change in another repository, and pretending otherwise is how coordinated changes break the default branch.
Is a nightly check worth adding?
Yes, and it is cheap. A scheduled composition of every registered subgraph catches drift nobody’s pull request would touch: a subgraph deleted by hand, a routing URL that no longer resolves, a variant left behind by a rollback. Change-triggered checks by definition never look at the parts of the graph nobody is changing.
Where should the SDL in the repository come from?
Generate it from the running schema in a build step rather than maintaining it by hand, and commit the generated file. A handwritten SDL drifts from the code that serves it, and the check then validates a document nobody is running — which is worse than no check at all, because it produces a green status for a schema that does not exist. Generating it also makes the diff in a pull request an accurate picture of what the change does to the API, which is the artifact reviewers actually want to read.
Related
- Schema Validation in CI/CD Pipelines — parent guide
- Federated Schema Validation in CI/CD Pipelines — what the check actually validates
- Publishing Subgraph Schemas with Rover CLI — the publish command in detail
- Apollo Studio Schema Checks for Managed Federation — the three verdicts a check returns
- Schema Proposals and Approval Workflows — governance on top of this gate