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.

The five stages of a federated composition check Rover fetches the baseline supergraph, injects the proposed subgraph SDL, diffs the two supergraphs, classifies each change as failure, warning, or info, then enforces the merge gate. 1. fetch baseline 2. inject proposed SDL 3. diff supergraphs 4. classify severity 5. gate block / allow

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.

Two validation stages, two different amounts of knowledge Local validation sees only one subgraph and catches syntax and directive errors instantly. Registry validation sees every published subgraph plus real client traffic, and is the only stage that can catch merge conflicts, satisfiability gaps, and client breakage. What each stage can possibly know local — sees one subgraph runs in under a second, offline syntax errors unimported directives local naming conventions cannot see any other subgraph registry — sees the whole graph needs credentials and a network call merge conflicts with other subgraphs unreachable fields across the graph operations real clients would break the only stage that can block a merge

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 green checks can still merge into a red graph Pull request A checks green against the graph before B exists. B checks green before A merges. After both merge, the composed graph contains two conflicting field definitions and composition fails on the default branch. A check is a snapshot, and snapshots go stale PR A check green merged PR B check green merged default branch: FIELD_TYPE_MISMATCH t0 later The cure is a re-check on the merge queue, not stricter reviews — no reviewer can see the future.

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_REF with the right @variant. A check that diffs @staging while 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 WARNING as 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.