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.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
contractVersionfield 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.
- 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. - From the baseline, identify the intersection — tokens that every brand currently provides. Promote only those to
"required": trueincontract.v1.0.0. - 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. - 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.
Related
- Theme Contract Versioning — parent section covering the broader governance model for brand theme contracts
- Automating Token Changelogs with Semantic Release — how to automate contract version bumps from commit messages using semantic-release
- JSON Schema Validation for Tokens — structural validation of individual token files, which pairs with contract conformance for full token governance