Schema Validation in CI/CD Pipelines

As distributed GraphQL architectures scale, the moment that breaks production is rarely a bad resolver — it is a subgraph SDL that composed fine in isolation but introduced a breaking change once merged into the supergraph. Because Apollo Federation composition is all-or-nothing, one unchecked field removal or nullability narrowing can fail composition for every team sharing the graph. Effective GraphQL Federation Architecture & Design therefore depends on automated guardrails that intercept breaking changes before they reach the registry. This guide details the checkpoint architecture, the Rover CLI workflows, and the contract-enforcement rules that make schema validation a reliable gate rather than a flaky bottleneck.

The focused companion page, federated schema validation in CI/CD pipelines, drills into the composition-engine mechanics and exact error payloads; managed publishing and approval flow lives in schema registry and managed federation.

Prerequisites

Concept Deep-Dive: Validation Checkpoints

Validation must be spread across the CI/CD lifecycle so it balances developer velocity against production stability. Catching everything in one expensive post-merge step is too slow to act on; catching nothing until deploy is too late. The standard architecture has three checkpoints.

Pre-commit linting validates SDL syntax, directive usage, and naming conventions locally with graphql-schema-linter or an ESLint GraphQL rule set. It is fast and catches typos before they ever reach CI.

PR-triggered composition checks compare the proposed subgraph SDL against the registered production supergraph to detect breaking changes, using rover subgraph check. This is the load-bearing gate: it runs on every pull request that touches a schema and is what branch protection blocks on.

Post-merge staging verification runs a full rover supergraph compose against a staging registry followed by integration queries, confirming the merged supergraph actually serves traffic.

The reason these three checkpoints exist as a sequence rather than a single gate is that each catches a different class of error at a different cost. Linting is essentially free and catches the cheapest mistakes — malformed SDL, a missing directive import — so it belongs in the editor and the pre-commit hook where feedback is instant. The PR check is moderately expensive because it talks to the registry and runs a real composition, but it is the only stage that can answer the question that actually matters: does this change break the supergraph other teams depend on? Post-merge staging compose is the most expensive, end-to-end stage, and it exists as a backstop for the cross-subgraph conflicts an incremental PR check cannot see — a change to subgraph A that only breaks composition in combination with an unrelated, already-merged change to subgraph B. Skipping any one stage does not just lose coverage; it pushes that error class to a later, costlier point in the pipeline. The discipline is to fail as early and as cheaply as the error class allows.

A subtle but important property of federated validation is that “breaking” is defined relative to live client traffic, not to the schema in the abstract. Removing a field that no client queries is, operationally, additive — nobody notices. Removing a field that one mobile client version still queries is an outage for those users. This is why a mature gate integrates production usage metrics from the registry: it lets the pipeline distinguish a theoretical breaking change from a client-impacting one, and reserve hard failures for the latter while soft-warning on the former behind a deprecation window.

The validation scope should mirror your service topology. Properly defining subgraph boundaries for microservices dictates which pipelines run which checks, so a PR touching one subgraph validates only its dependencies rather than forcing an expensive full rebuild on every unrelated change.

Schema validation checkpoints across the CI/CD lifecycle A schema change passes pre-commit linting, then a PR-time subgraph check diffs against the production supergraph, then a post-merge supergraph compose against staging, before publishing to the registry. SDL change developer pre-commit lint SDL PR check subgraph check vs production post-merge staging compose Registry publish

The reason a federated pipeline needs several checkpoints rather than one is that each one can only see part of the picture, and they get progressively more expensive as they see more. A pre-commit hook sees a single file. A pull-request check sees your subgraph against every other published subgraph. A pre-deploy gate sees the composed artifact your routers will actually load. Running only the last one works, but it moves every mistake to the slowest and most public place to find it; running only the first is fast and catches almost nothing that matters in a federated graph.

Think of it as a cost gradient. Each checkpoint should catch everything it possibly can, so the next one — which costs an order of magnitude more in wall-clock time and in blast radius — never has to. That ordering is also why the cheap checks should be duplicated in the expensive ones rather than skipped: the pull-request check re-parses your SDL even though the hook already did, because there is no guarantee the hook ran at all.

Four checkpoints, in increasing order of what a miss costs A pre-commit hook costs seconds and sees one file. A pull request check costs a minute and sees the whole registry. A pre-deploy gate costs a pipeline stage and sees the composed artifact. A post-deploy smoke test costs a rollback and sees real traffic. Each checkpoint exists so the next one never fires pre-commit hook seconds sees: one SDL file catches: syntax, missing @link imports, naming rules pull request about a minute sees: every published subgraph + real traffic catches: merge conflicts, unreachable fields, client breakage pre-deploy gate a pipeline stage sees: the exact composed artifact catches: drift between what was checked and what ships post-deploy sees: production traffic catches: everything the first three missed — at the cost of a rollback

Directive & Config Spec Table

Key / Flag Where Valid values Composition-time vs runtime
rover subgraph check PR step graph ref + --name + --schema Composition-time: diffs proposed SDL against the registered supergraph
rover supergraph compose post-merge / local --config supergraph.yaml Composition-time: produces the merged supergraph SDL
federation_version supergraph.yaml e.g. =2.9.0 Composition-time: pins the spec and diagnostic set
APOLLO_GRAPH_REF env graph-id@variant Selects the variant the check diffs against
--background / --format json check flags flag / json,plain Controls output shape consumed by CI parsing
@deprecated(reason:) SDL string reason Composition-time validation; runtime returns the field with a deprecation hint

Step-by-Step Implementation

1. Install and authenticate Rover

# Install Rover (Linux/macOS) — not an npm package
curl -sSL https://rover.apollo.dev/nix/latest | sh

# Windows PowerShell
iwr 'https://rover.apollo.dev/win/latest' | iex

2. Add the PR composition check

This GitHub Actions workflow caches the Rover binary, runs a check against the schema registry, and blocks the merge on breaking changes.

name: GraphQL Schema Validation
on:
  pull_request:
    paths:
      - 'subgraphs/**'
      - 'schema.graphql'

jobs:
  validate-schema:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Cache Rover Binary
        uses: actions/cache@v4
        with:
          path: ~/.rover
          key: ${{ runner.os }}-rover-${{ hashFiles('supergraph.yaml') }}
          restore-keys: ${{ runner.os }}-rover-

      - name: Install Rover
        run: curl -sSL https://rover.apollo.dev/nix/latest | sh

      - name: Add Rover to PATH
        run: echo "$HOME/.rover/bin" >> $GITHUB_PATH

      - name: Check Subgraph Against Production
        run: |
          rover subgraph check "$APOLLO_GRAPH_REF" \
            --name my-subgraph \
            --schema ./schema.graphql \
            --output json > check_results.json
        env:
          APOLLO_KEY: ${{ secrets.APOLLO_GRAPH_API_KEY }}
          APOLLO_GRAPH_REF: ${{ vars.APOLLO_GRAPH_REF }}

      - name: Fail on Breaking Changes
        run: |
          if jq -e '[.data.changes[] | select(.severity == "FAILURE")] | length > 0' check_results.json > /dev/null; then
            echo "::error::Breaking changes detected. Review check_results.json for details."
            exit 1
          fi

3. Enforce contract rules

Federation v2 introduces routing-critical directives — @key, @override, @shareable, @inaccessible — that must be validated during composition. Three rules carry most of the weight. Ensure every @key field exists and is resolvable, because a missing key field causes silent routing failures at runtime. Flag any type defined in multiple subgraphs without @shareable, since that fails composition with INVALID_FIELD_SHARING. And require a reason on every @deprecated, blocking removal until the deprecation window expires and usage metrics confirm zero active references. Align these thresholds with your type ownership and shared schema contracts so cross-team dependency violations are caught at the gate rather than in production.

4. Add a local SDL diff fallback

For air-gapped or registry-restricted environments, a lightweight diff catches unauthorised field removals before invoking external tools.

import { parse } from 'graphql';
import fs from 'fs';

function extractTypeMap(sdl: string): Record<string, string[]> {
  const map: Record<string, string[]> = {};
  for (const def of parse(sdl).definitions) {
    if (def.kind === 'ObjectTypeDefinition' && def.fields) {
      map[def.name.value] = def.fields.map((f) => f.name.value);
    }
  }
  return map;
}

function detectBreakingChanges(currentSDL: string, proposedSDL: string) {
  const current = extractTypeMap(currentSDL);
  const proposed = extractTypeMap(proposedSDL);
  const breaking: { type: string; removedFields: string[] }[] = [];
  for (const [type, fields] of Object.entries(current)) {
    const removed = fields.filter((f) => !(proposed[type] ?? []).includes(f));
    if (removed.length) breaking.push({ type, removedFields: removed });
  }
  return breaking;
}

const violations = detectBreakingChanges(
  fs.readFileSync('./current.graphql', 'utf8'),
  fs.readFileSync('./proposed.graphql', 'utf8'),
);
if (violations.length) {
  console.error('BREAKING CHANGES:', JSON.stringify(violations, null, 2));
  process.exit(1);
}
console.log('Schema diff validation passed.');

Composition Pipeline Integration

For multi-subgraph repositories, parallelise validation so CI throughput scales with the number of services rather than serialising on them.

SUBGRAPHS := auth users inventory payments

.PHONY: validate-all $(SUBGRAPHS:%=validate-%)

validate-all:
	@echo "Running parallel subgraph validation..."
	@$(MAKE) -j$(shell nproc) $(SUBGRAPHS:%=validate-%)
	@echo "Running supergraph composition..."
	@rover supergraph compose --config supergraph.yaml --output composed.graphql

validate-%:
	@rover subgraph check "$$APOLLO_GRAPH_REF" \
		--name $* \
		--schema subgraphs/$*/schema.graphql \
		--output json | \
		jq -e '[.data.changes[] | select(.severity == "FAILURE")] | length == 0' \
		|| (echo "::error::$* contains breaking changes" && exit 1)

Once checks pass, publishing to the registry promotes the schema for managed federation; that publish-and-approve flow is covered in schema registry and managed federation.

5. Promote validated schemas to the registry

A passing check is a gate, not a publish. Once the PR merges, the validated subgraph must be published so the router can pick it up. In managed federation the router polls the registry and hot-reloads the supergraph without a redeploy, which is why the publish step is the actual moment a schema goes live for routing.

name: Publish Subgraph
on:
  push:
    branches: [main]
    paths: ['subgraphs/**']
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Rover
        run: |
          curl -sSL https://rover.apollo.dev/nix/latest | sh
          echo "$HOME/.rover/bin" >> $GITHUB_PATH
      - name: Publish to registry
        env:
          APOLLO_KEY: ${{ secrets.APOLLO_GRAPH_API_KEY }}
          APOLLO_GRAPH_REF: ${{ vars.APOLLO_GRAPH_REF }}
        run: |
          rover subgraph publish "$APOLLO_GRAPH_REF" \
            --name my-subgraph \
            --schema ./schema.graphql \
            --routing-url https://my-subgraph.internal/graphql

The full publish-and-approve workflow, including schema proposals and managed federation polling, is covered in schema registry and managed federation.

Performance & Scale Considerations

Full supergraph composition scales poorly in large monorepos, so reserve rover supergraph compose for post-merge or nightly runs and keep PR latency low with incremental rover subgraph check. Cache the Rover binary and the supergraph definition keyed by commit SHA to skip redundant registry calls. Be aware of the trade-off: incremental diffing is fast but can miss a cross-subgraph routing conflict that only a full compose surfaces, which is exactly why staging verification exists as a backstop. Decide where to fail fast and where to soft-warn: type narrowing, @key removal, non-nullable field changes, and directive stripping should hard-fail, while field deprecation, optional-argument removal, and enum-value addition can soft-warn behind a mandatory migration window of, say, 14 days.

A pipeline that gates schema changes is only as useful as its signal-to-noise ratio, and the usual failure is not a missing check but a check nobody trusts. Two numbers tell you whether yours is healthy: how often it 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.

Two ways a schema gate can be unhealthy A healthy gate fails rarely and is almost never overridden. A noisy gate fails often and is usually overridden, which trains the team to bypass it. A gate that never fails at all is equally suspect, because it is probably scoped too narrowly to catch anything. Measure the gate, not just the graph healthy fails on ~4% of pull requests overridden almost never people read the error noisy fails on ~30% of pull requests overridden most of the time people learn the override inert has never failed probably scoped to one subgraph, or not actually required nobody notices it at all Review these numbers quarterly. A gate is a piece of process, and process decays silently.

The remedy for a noisy gate is almost never to loosen it globally. It is to separate the findings that must block from the findings that should only inform — composition failures in the first group, lint findings and low-traffic deprecation warnings in the second — and to report them as two distinct statuses so the blocking one keeps its meaning. An inert gate needs the opposite treatment: widen its scope until it starts catching things, or delete it and stop pretending it is protecting you.

Failure Modes & Debugging

error[E029]: Breaking changes detected from rover subgraph check. The proposed SDL removes or narrows a field that the registered supergraph still exposes. Parse check_results.json with jq, confirm whether the field has live traffic, and either restore it or schedule a deprecation window before removal.

INVALID_FIELD_SHARING during compose — Field "User.email" is defined in multiple subgraphs but is not marked as @shareable. Two subgraphs contribute the same field. Mark it @shareable in each, or consolidate ownership — see resolving schema conflicts in Apollo Federation.

Check passes locally but fails in CI. Almost always a variant mismatch: the local run diffed against @dev while CI uses @production. Always pass an explicit APOLLO_GRAPH_REF per environment and never rely on a default variant.

Pipeline times out on composition. Either an oversized monorepo composing every subgraph on each PR, or network egress to Apollo Studio is blocked. Switch PRs to incremental checks and confirm the runner can reach the registry endpoint.

Frequently Asked Questions

How do I prevent CI/CD validation from becoming a deployment bottleneck?

Run incremental rover subgraph check on PRs, cache the Rover binary and supergraph definitions, and parallelise per-subgraph checks. Reserve full rover supergraph compose for staging or nightly builds rather than blocking every pull request on it.

Should validation block merges on all breaking changes?

Only block on changes that impact active client queries. Use production traffic metrics to separate theoretical breaks from real ones, soft-warn on deprecations behind an enforced migration window, and hard-fail on type narrowing or @key removal.

How does schema validation interact with Apollo Federation v2 directives?

The toolchain must parse and verify @key, @override, @shareable, and @inaccessible during composition. Pin federation_version: =2.x.x in supergraph.yaml so rover subgraph check enforces strict directive parsing and catches routing conflicts before deploy.

Where do schema checks end and managed federation begin?

Checks gate the change at the PR; once merged, publishing the validated subgraph to the registry is what hot-reloads the router. That publish-and-approve handoff is detailed in schema registry and managed federation.

Should the same pipeline both check and publish?

One pipeline definition, two jobs, with different triggers and different credentials. The check job runs on pull requests with a check-only key; the publish job runs on the default branch with a publish key. Keeping them in one file makes the ordering obvious to whoever reads it next, while keeping them as separate jobs means a misconfigured trigger cannot turn a pull-request run into a production write.

How do I validate a change that spans two subgraphs?

Check each subgraph separately against the registry, then compose the two proposed schemas together locally with rover supergraph compose using local file references. The second step is what catches the interaction, and it is the only way to see the combined result before either half merges. Land them in a deliberate order — additive side first — so that neither intermediate state breaks composition.

What belongs in the pipeline that is not a schema check at all?

Two things, both cheap and both frequently missing. First, assert that the routing URL in the publish command actually resolves from the environment the router runs in — a schema can be perfectly valid and still point at nothing. Second, snapshot the composed supergraph as a build artifact on every successful publish, so a rollback is a file you already have rather than a rebuild you have to trust. Neither is a schema concern strictly speaking, and both turn a bad day into a five-minute fix.

Is it worth validating on a schedule as well as on change?

Yes, and it is cheap. A nightly composition of every registered subgraph catches drift you did not cause: 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 touching, and those are exactly where stale state accumulates.