Versioning Theme Contracts Across Brands

Part of Theme Contract Versioning. This page walks through defining a machine-readable theme contract — the required semantic tokens with their types and constraints — semver-bumping it when the surface changes, and running a CI conformance test that diffs every brand theme against the current contract version to catch violations before they reach production.

Contract vs Brand Conformance Diff Sequence diagram showing contract.v1.json flowing into a conformance checker that diffs three brand themes and emits pass, warning, or fail signals per brand. contract.v1.json required tokens + types Conformance Checker diff per brand brand-alpha.json 25/25 tokens present brand-beta.json 23/25 tokens present brand-gamma.json wrong type on 1 token PASS brand-alpha WARN (minor) brand-beta: 2 missing FAIL (major) brand-gamma: type error
Contract conformance checker diffing three brand themes against contract.v1.json: one passes, one produces a minor warning for missing optional tokens, one fails hard on a type mismatch.

Prerequisites

  • Token files for each brand are stored as flat or nested JSON (W3C Design Token Community Group format or a custom schema — the checker works with either).
  • Node.js 20+ available in CI.
  • Brands live in a mono-repo under themes/<brand>/tokens.json, or are published as versioned npm packages that CI installs before running checks.
  • Semantic versioning of the contract itself is handled by automating token changelogs with semantic-release, so the contract file carries a contractVersion field that maps to a git tag.
  • Each brand theme file declares which contract version it was built against ("buildsAgainst": "1.2.0").

Step 1: Author contract.v1.json

Define the surface — the complete list of semantic tokens that every brand must supply, along with the expected W3C type for each.

{
  "contractVersion": "1.0.0",
  "description": "Minimum required semantic tokens for all brand themes.",
  "tokens": [
    { "name": "color.action.primary",    "type": "color",     "required": true  },
    { "name": "color.action.secondary",  "type": "color",     "required": true  },
    { "name": "color.text.primary",      "type": "color",     "required": true  },
    { "name": "color.text.muted",        "type": "color",     "required": true  },
    { "name": "color.surface.default",   "type": "color",     "required": true  },
    { "name": "color.border.default",    "type": "color",     "required": true  },
    { "name": "color.feedback.error",    "type": "color",     "required": true  },
    { "name": "color.feedback.success",  "type": "color",     "required": true  },
    { "name": "spacing.scale.base",      "type": "dimension", "required": true  },
    { "name": "typography.family.body",  "type": "fontFamily","required": true  },
    { "name": "typography.weight.bold",  "type": "fontWeight","required": true  },
    { "name": "border.radius.sm",        "type": "dimension", "required": true  },
    { "name": "border.radius.md",        "type": "dimension", "required": true  },
    { "name": "elevation.shadow.sm",     "type": "shadow",    "required": true  },
    { "name": "motion.duration.short",   "type": "duration",  "required": false }
  ]
}

Why this works: Separating required from optional lets the checker classify misses as major violations (blocking) versus minor ones (warning-only). Naming tokens with dot-path notation matches how Style Dictionary resolves nested objects, so you can resolve color.action.primary in either a flat { "color.action.primary": { "$value": "#2563eb" } } file or a nested tree without a bespoke parser.

Step 2: Author a brand theme file

Each brand ships its own token file and declares the contract version it claims to satisfy.

{
  "buildsAgainst": "1.0.0",
  "brand": "brand-alpha",
  "tokens": {
    "color.action.primary":   { "$value": "#0891b2", "$type": "color" },
    "color.action.secondary": { "$value": "#06b6d4", "$type": "color" },
    "color.text.primary":     { "$value": "#0f172a", "$type": "color" },
    "color.text.muted":       { "$value": "#475569", "$type": "color" },
    "color.surface.default":  { "$value": "#ffffff", "$type": "color" },
    "color.border.default":   { "$value": "#e2e8f0", "$type": "color" },
    "color.feedback.error":   { "$value": "#ef4444", "$type": "color" },
    "color.feedback.success": { "$value": "#16a34a", "$type": "color" },
    "spacing.scale.base":     { "$value": "4px",     "$type": "dimension" },
    "typography.family.body": { "$value": "Inter, sans-serif", "$type": "fontFamily" },
    "typography.weight.bold": { "$value": 700,        "$type": "fontWeight" },
    "border.radius.sm":       { "$value": "4px",      "$type": "dimension" },
    "border.radius.md":       { "$value": "8px",      "$type": "dimension" },
    "elevation.shadow.sm":    { "$value": "0 1px 3px rgba(0,0,0,0.12)", "$type": "shadow" },
    "motion.duration.short":  { "$value": "150ms",    "$type": "duration" }
  }
}

Why this works: The buildsAgainst field is the key contract-coupling declaration. When you bump the contract to 1.1.0, brands that still declare "buildsAgainst": "1.0.0" trigger a semver comparison in the checker and get a warning to re-audit against the new additions before the next major deadline.

Step 3: Write the conformance checker in Node

The checker loads a contract file and a brand theme, walks the required token list, and emits structured results.

// tools/check-conformance.mjs
import { readFileSync } from "fs";
import { resolve } from "path";

const VALID_TYPES = new Set([
  "color", "dimension", "fontFamily", "fontWeight",
  "shadow", "duration", "number", "string"
]);

export function checkConformance(contractPath, themePath) {
  const contract = JSON.parse(readFileSync(contractPath, "utf8"));
  const theme    = JSON.parse(readFileSync(themePath, "utf8"));
  const tokens   = theme.tokens ?? {};

  const results = {
    brand:           theme.brand ?? themePath,
    contractVersion: contract.contractVersion,
    buildsAgainst:   theme.buildsAgainst ?? "unknown",
    major: [],
    minor: [],
    pass:  [],
  };

  for (const entry of contract.tokens) {
    const tok = tokens[entry.name];

    if (!tok) {
      const bucket = entry.required ? "major" : "minor";
      results[bucket].push({
        token:  entry.name,
        reason: entry.required ? "MISSING_REQUIRED" : "MISSING_OPTIONAL",
      });
      continue;
    }

    if (tok.$type !== entry.type) {
      results.major.push({
        token:    entry.name,
        reason:   "TYPE_MISMATCH",
        expected: entry.type,
        actual:   tok.$type ?? "(undefined)",
      });
      continue;
    }

    results.pass.push(entry.name);
  }

  return results;
}

// CLI entry point
if (process.argv[1] === resolve(import.meta.url.slice(7))) {
  const [,, contractArg, themeArg] = process.argv;
  if (!contractArg || !themeArg) {
    console.error("Usage: node check-conformance.mjs <contract> <theme>");
    process.exit(2);
  }
  const result = checkConformance(contractArg, themeArg);
  console.log(JSON.stringify(result, null, 2));
  const exitCode = result.major.length > 0 ? 1 : 0;
  process.exit(exitCode);
}

Why this works: Distinguishing major from minor violations in the result object lets downstream CI steps decide whether to fail the build immediately or emit an annotation and continue. The checker exits 1 only on major violations, so a brand missing only optional tokens does not block the pipeline but still surfaces as a visible warning.

Step 4: Wire into a CI matrix over all brands

A matrix strategy runs the conformance checker against every brand in parallel, with per-brand exit codes that roll up into a single pipeline status.

# .github/workflows/token-conformance.yml
name: Token Contract Conformance

on:
  push:
    paths:
      - "contract/**"
      - "themes/**"
  pull_request:
    paths:
      - "contract/**"
      - "themes/**"

jobs:
  discover-brands:
    runs-on: ubuntu-latest
    outputs:
      brands: ${{ steps.list.outputs.brands }}
    steps:
      - uses: actions/checkout@v4
      - id: list
        run: |
          brands=$(ls themes/ | jq -R -s -c 'split("\n") | map(select(. != ""))')
          echo "brands=$brands" >> "$GITHUB_OUTPUT"

  conformance:
    needs: discover-brands
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        brand: ${{ fromJson(needs.discover-brands.outputs.brands) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - name: Check ${{ matrix.brand }}
        id: check
        run: |
          node tools/check-conformance.mjs \
            contract/contract.v1.json \
            themes/${{ matrix.brand }}/tokens.json \
            | tee conformance-${{ matrix.brand }}.json
      - name: Annotate minor violations
        if: always()
        run: |
          node -e "
            const r = require('./conformance-${{ matrix.brand }}.json');
            if (r.minor.length) {
              r.minor.forEach(v =>
                console.log('::warning::' + r.brand + ': ' + v.token + ' — ' + v.reason)
              );
            }
          "
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: conformance-${{ matrix.brand }}
          path: conformance-${{ matrix.brand }}.json

Why this works: fail-fast: false lets every brand run to completion even when one fails, giving you a complete picture of which brands are broken rather than stopping at the first failure. Uploading artifacts lets you download the full diff JSON for offline analysis without re-running the pipeline.

Step 5: Classify diffs as major or minor and semver-bump the contract

When you add a new required token to the contract, that is a major bump (existing compliant brands break). Adding a new optional token is a minor bump. Tightening a type constraint is major. Relaxing one is minor. Document this in the contract repository with a CHANGELOG.md managed by semantic-release so the bump is automatic from commit messages.

// tools/classify-diff.mjs
// Run: node classify-diff.mjs contract/contract.v1.json contract/contract.v2.json

import { readFileSync } from "fs";

function indexByName(tokens) {
  return Object.fromEntries(tokens.map(t => [t.name, t]));
}

const [,, oldPath, newPath] = process.argv;
const oldContract = JSON.parse(readFileSync(oldPath, "utf8"));
const newContract = JSON.parse(readFileSync(newPath, "utf8"));

const oldIdx = indexByName(oldContract.tokens);
const newIdx = indexByName(newContract.tokens);

const majorChanges = [];
const minorChanges = [];

for (const [name, entry] of Object.entries(newIdx)) {
  if (!oldIdx[name]) {
    const bucket = entry.required ? majorChanges : minorChanges;
    bucket.push({ change: "ADDED", name, required: entry.required });
  } else if (oldIdx[name].type !== entry.type) {
    majorChanges.push({ change: "TYPE_CHANGED", name,
      from: oldIdx[name].type, to: entry.type });
  } else if (!oldIdx[name].required && entry.required) {
    majorChanges.push({ change: "NOW_REQUIRED", name });
  }
}

for (const name of Object.keys(oldIdx)) {
  if (!newIdx[name]) {
    minorChanges.push({ change: "REMOVED", name });
  }
}

const bumpType = majorChanges.length ? "MAJOR" : minorChanges.length ? "MINOR" : "NONE";
console.log(JSON.stringify({ bumpType, majorChanges, minorChanges }, null, 2));
process.exit(majorChanges.length ? 1 : 0);

Why this works: Generating the bump classification programmatically prevents the most common mistake in multi-brand systems: a design-ops engineer silently adding a required token to the contract without realizing it breaks thirty downstream brand builds. The script exits 1 on a major diff, so wiring it into a pre-merge check on the contract repository blocks unsafe contract edits before brands are notified.

Step 6: Publish the contract with semantic-release

Tie the contract version bump to a git tag and a published npm package so brand teams can pin buildsAgainst to a range rather than an exact version.

{
  "name": "@your-org/theme-contract",
  "version": "1.0.0",
  "main": "contract.v1.json",
  "files": ["contract.v1.json", "CHANGELOG.md"],
  "release": {
    "branches": ["main"],
    "plugins": [
      "@semantic-release/commit-analyzer",
      "@semantic-release/release-notes-generator",
      "@semantic-release/changelog",
      "@semantic-release/npm",
      "@semantic-release/git"
    ]
  }
}

The companion .releaserc.yml maps commit prefixes to semver bumps:

# .releaserc.yml
parserOpts:
  noteKeywords:
    - BREAKING CHANGE
    - MAJOR CONTRACT CHANGE
releaseRules:
  - type: feat
    scope: contract-required
    release: major
  - type: feat
    scope: contract-optional
    release: minor
  - type: fix
    scope: contract
    release: patch

Why this works: Encoding the bump type in the commit scope (feat(contract-required): vs feat(contract-optional):) makes the release logic self-documenting and auditable in the git log. Brand teams subscribe to the npm package and receive automated renovate-bot PRs when a new contract version ships, with the changelog linked in the PR body. This turns the JSON schema validation patterns used for individual token files into a contract-level governance layer across the entire brand portfolio.

Step 7: Gate merges on conformance status

Add a required status check to branch protection so no brand theme change ships if the contract conformance job fails.

# In your repo's branch protection rules (set via GitHub API or Terraform):
# required_status_checks:
#   strict: true
#   contexts:
#     - "conformance / brand-alpha"
#     - "conformance / brand-beta"
#     - "conformance / brand-gamma"

# Alternatively, use a merge queue and a summary job:
  conformance-summary:
    needs: conformance
    runs-on: ubuntu-latest
    if: always()
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: conformance-*
          merge-multiple: true
      - name: Fail if any brand has major violations
        run: |
          node -e "
            const fs = require('fs');
            const files = fs.readdirSync('.').filter(f => f.startsWith('conformance-'));
            let failed = false;
            for (const f of files) {
              const r = JSON.parse(fs.readFileSync(f, 'utf8'));
              if (r.major.length > 0) {
                console.error('FAIL ' + r.brand + ': ' + r.major.length + ' major violation(s)');
                r.major.forEach(v => console.error('  ' + v.token + ' — ' + v.reason));
                failed = true;
              }
            }
            process.exit(failed ? 1 : 0);
          "

Why this works: The summary job acts as a single required check regardless of how many brands are in the matrix, so you do not need to update branch protection rules every time a new brand is onboarded. Adding a brand means adding a directory under themes/; the discover-brands job picks it up automatically on the next run.

Verification

A passing CI run for a fully conformant brand produces output like this in the conformance job log:

{
  "brand": "brand-alpha",
  "contractVersion": "1.0.0",
  "buildsAgainst": "1.0.0",
  "major": [],
  "minor": [],
  "pass": [
    "color.action.primary",
    "color.action.secondary",
    "color.text.primary",
    "color.text.muted",
    "color.surface.default",
    "color.border.default",
    "color.feedback.error",
    "color.feedback.success",
    "spacing.scale.base",
    "typography.family.body",
    "typography.weight.bold",
    "border.radius.sm",
    "border.radius.md",
    "elevation.shadow.sm",
    "motion.duration.short"
  ]
}

A failing brand — one that omits a required token — produces:

{
  "brand": "brand-gamma",
  "contractVersion": "1.0.0",
  "buildsAgainst": "1.0.0",
  "major": [
    {
      "token": "elevation.shadow.sm",
      "reason": "TYPE_MISMATCH",
      "expected": "shadow",
      "actual": "string"
    }
  ],
  "minor": [],
  "pass": [ ... ]
}
Error: Process completed with exit code 1.

The TYPE_MISMATCH entry tells the brand engineer exactly which token is wrong and what type the contract expects, cutting the time to fix from “grep through the theme file” to a single targeted edit.

Troubleshooting

Symptom Likely Cause Fix
Every brand fails with MISSING_REQUIRED on a token that exists in the file Token names in theme use camelCase or slash notation (color/action/primary) instead of dot-path Normalize token key format in the theme file or add an aliasing step in the checker before the lookup
TYPE_MISMATCH on fontWeight even though the value is numeric Theme file stores weight as a string ("$value": "700") instead of a number Change the token value to a bare JSON number: "$value": 700
New brand is not picked up by the matrix ls themes/ returns the brand directory but it contains no tokens.json at the expected path Ensure the brand directory contains tokens.json at the root, or adjust the matrix discovery glob
conformance-summary job fails even when individual brand jobs pass Artifact download path resolves to the runner’s working directory but filenames collide Use merge-multiple: true in the download step and prefix artifact names with conformance- to avoid name collisions
Minor violations are suppressed and never visible The annotation step runs only on the checker step’s success, missing the case where it exits 0 with minor violations Set if: always() on the annotation step so it runs regardless of the checker’s exit code

Migration Note

If you are introducing a contract to an existing multi-brand setup where brand themes were authored without any governing schema, start with an observatory-only pass before enforcing failures.

  1. Set every token in the initial contract to "required": false. Run the checker across all brands in warning-only mode to generate a baseline report of which tokens each brand already has.
  2. From the baseline, identify the intersection — tokens that every brand currently provides. Promote only those to "required": true in contract.v1.0.0.
  3. Tokens present in some but not all brands become required in contract.v2.0.0, with a migration deadline communicated to brand teams alongside the changelog.
  4. Tokens that no brand currently provides are new additions, authored against the contract first and then implemented by brands one at a time.

This phased approach avoids the common failure mode of writing a contract against the most complete brand and then discovering that ten other brands are immediately non-compliant with no runway to fix them. The checker’s minor bucket gives brand teams a visible backlog to work through without blocking their releases.