Federated Schema Validation in CI/CD Pipelines
This page shows exactly how to wire a federated composition check into CI so a breaking subgraph change is caught on the pull request, with the precise error payloads you will see and how to act on them. It is the focused companion to schema validation in CI/CD pipelines, itself part of GraphQL Federation Architecture & Design; read the parent for the broader checkpoint architecture.
When to use this pattern
- You run Apollo Federation v2 with two or more independently deployed subgraphs and need a per-PR gate.
- A managed supergraph variant exists in the registry that the check can diff a proposed SDL against.
- You want machine-readable results (
FAILURE/WARNING/INFO) you can parse to block or annotate a merge.
Prerequisites
How federated composition checks work
Federated composition is not isolated SDL validation. The composition engine computes a unified supergraph by resolving entity keys, merging type definitions across subgraphs, and validating directive compatibility — so a subgraph that is internally valid can still break the merge. A check therefore runs five stages: fetch the current supergraph baseline from the registry, inject the proposed subgraph SDL, diff the resulting supergraph against the baseline, classify each change by severity, and enforce the gate by blocking on FAILURE while allowing WARNING with a PR annotation.
A federated check is not a linter run against your file — it is a question asked of the whole graph, and that changes where it can run. Local validation can tell you the SDL parses and that your directives are imported. It cannot tell you whether your Product.price agrees with the Product.price another team published an hour ago, because your working copy has no idea that schema exists. The registry does. That asymmetry is the reason a federated pipeline has two distinct validation stages rather than one, and why only the second can block a merge with confidence.
Run both, in that order, and give them different jobs. Local validation belongs in a pre-commit hook where its speed is the whole point: it should catch the mistake before a pipeline ever starts. The registry check belongs on the pull request as a required status, because it needs credentials, it costs a network round trip, and its verdict is the one that genuinely predicts whether composition will succeed once merged.
Implementation walkthrough
The workflow below runs a composition check on every PR that touches subgraph SDL, then hands the JSON output to a small Node script that decides the build’s exit status.
name: Federated Schema Validation
on:
pull_request:
paths:
- 'subgraph/**/*.graphql'
jobs:
schema-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache Rover Binary
uses: actions/cache@v4
with:
path: ~/.rover
key: ${{ runner.os }}-rover-latest
- name: Install Rover
run: |
curl -sSL https://rover.apollo.dev/nix/latest | sh
echo "$HOME/.rover/bin" >> $GITHUB_PATH
- name: Run Composition Check
env:
APOLLO_KEY: ${{ secrets.APOLLO_KEY }} # graph:read + graph:write token
APOLLO_GRAPH_REF: ${{ vars.APOLLO_GRAPH_REF }} # graph-id@variant, e.g. platform-api@staging
run: |
rover subgraph check "$APOLLO_GRAPH_REF" \
--schema ./subgraph/schema.graphql \
--name subgraph-service \
--format json > check_output.json
- name: Evaluate Results
run: node ./scripts/evaluate-check.js check_output.json
The evaluator parses Rover’s structured output, prints each breaking change with its code and path, and exits non-zero to fail the pipeline.
const { readFileSync } = require('fs');
const checkData = JSON.parse(readFileSync(process.argv[2], 'utf8'));
// Rover JSON wraps results under data.changes
const changes = checkData.data?.changes ?? checkData.changes ?? [];
const failures = changes.filter((c) => c.severity === 'FAILURE');
if (failures.length > 0) {
console.error('Composition failed. Breaking changes detected:');
failures.forEach((f) => {
const path = Array.isArray(f.path) ? f.path.join('.') : (f.path ?? 'unknown');
console.error(` [${f.code}] ${f.description} (Path: ${path})`);
});
process.exit(1);
}
console.log('Schema composition passed. No breaking changes.');
process.exit(0);
Severity maps to action like this: FAILURE covers removed fields, changed argument nullability, modified @key directives, and type-ownership conflicts; WARNING covers deprecated fields, added optional arguments, and new entity references; INFO covers additive types and directive additions. Block only on FAILURE, annotate on WARNING.
Verification steps
Reproduce the check locally before trusting CI, then confirm the exact behaviour.
# Reproduce the merge locally
rover supergraph compose \
--config ./supergraph-config.yaml \
--output ./supergraph.graphql
# Extract only the failing paths from the CI JSON
jq '.data.changes[] | select(.severity == "FAILURE") | {code, description, path}' check_output.json
A clean run composes to supergraph.graphql and the jq filter returns nothing. The most common hard failures and their fixes:
| Error Code | Example Message | Root Cause | Resolution |
|---|---|---|---|
INVALID_FIELD_SHARING |
Field "User.email" is defined in multiple subgraphs but is not marked as @shareable |
Uncoordinated type ownership | Apply @shareable in each subgraph that defines the field, or consolidate to one owner |
KEY_FIELDS_MISSING_EXTERNAL |
@key field "id" must be declared @external in extending subgraph |
Extending subgraph not marking key fields @external |
Add @external to the @key fields in the extending subgraph |
FIELD_TYPE_MISMATCH |
Field "User.email" type mismatch: expected "String!", found "String" |
Nullability drift between subgraphs | Align SDL nullability; shared fields require exact type signatures |
To confirm the gate itself works, push a deliberately breaking change (drop a non-deprecated field) to a throwaway branch and verify the workflow exits non-zero with the field named in the log.
One more property of the check is easy to miss and expensive to relearn: the verdict is only valid against the graph as it was at that moment. If another team publishes between your check and your merge, your green status describes a graph that no longer exists. Most of the time that is harmless — the two changes are unrelated. When it is not, you get a composition failure on the default branch from two individually valid changes, which is the federated equivalent of a semantic merge conflict.
Two habits make this rare enough to ignore. Re-run the check as a required step on the merge queue or immediately post-merge, so a conflict is caught in minutes by whoever created it rather than by the next unlucky team. And keep changes to shared entities small and infrequent, since they are the only changes that can conflict this way — a purely additive field on a subgraph-private type has nothing to collide with.
Common mistakes & gotchas
- Checking against the wrong variant. Always pass an explicit
APOLLO_GRAPH_REFwith the right@variant. A check that diffs@stagingwhile production runs a different schema produces false greens — enforce the variant per environment, as covered in schema validation in CI/CD pipelines. - Hardcoding the supergraph SDL in CI. Fetch it dynamically with the check or
rover subgraph fetch; a stale committed copy drifts from the registry and masks real breaks. - Treating every
WARNINGas a blocker. Deprecations and additive changes are warnings by design. Blocking on them stalls planned migrations; annotate the PR and track the deprecation window instead.
Frequently Asked Questions
What permissions does the APOLLO_KEY need for a composition check?
A service token with graph:read and graph:write for the target graph. Read alone is insufficient because the check registers a transient composition against the variant.
Can I run the check without registry access?
Partly. rover supergraph compose --config validates a local merge offline, which catches structural conflicts like INVALID_FIELD_SHARING, but it cannot diff against production traffic or the registered baseline — so it will not detect client-impacting breaking changes. Use it as a fast local pre-check, not a replacement for the registry-backed gate.
How do I scale this across many subgraphs?
Use a CI matrix that runs one check per subgraph in parallel, each passing its own --name and --schema, against the shared APOLLO_GRAPH_REF. That keeps wall-clock time flat as the number of services grows.
Should the check block the build or just warn?
Block, with one carve-out. Composition failures must block, because merging one guarantees a broken default branch. Client-breakage findings should also block by default, but with a documented override — occasionally you genuinely do intend to break a client you have already migrated, and a pipeline with no escape hatch gets bypassed entirely rather than argued with. Record the override in the pull request so the decision is visible later.
How do I keep the check fast enough for a pull request?
The check itself is a single API call and is rarely the slow part; what makes pipelines slow is running it after a full test suite. Put it first, in parallel with linting, so a schema mistake fails in seconds rather than after ten minutes of unrelated work. Cache the Rover binary in the image rather than installing it per run — that install is usually the largest fixed cost in the job.
Do I need a check on branches nobody will merge?
Run it on every pull request, including drafts. The whole value is learning about a conflict while the change is still cheap to reshape, and a draft is the cheapest possible moment. What you should not do is publish from those branches — check everywhere, publish only from the default branch.