Validating Design Tokens Against JSON Schema in CI
Part of JSON Schema Validation for Tokens. This page covers the exact task of wiring AJV schema validation into a CI pipeline so that malformed token payloads are rejected at the pull-request gate — before they reach any CSS compiler, Stylelint run, or downstream consumer.
Prerequisites
- Token file format: Design tokens exported as JSON (from Style Dictionary, Figma Tokens plugin, or Cobalt) using a consistent shape — each token has at least
valueandtypekeys. - Node.js ≥ 18: Required by AJV v8 and the
ajv-clipackage. Pin the version in your CI configuration. - AJV v8+: Versions below 8 do not support JSON Schema Draft 2020-12. Install
ajv@^8as a dev dependency; do not rely on a transitive version. - GitHub Actions (or equivalent): These steps use GitHub Actions YAML. The Node.js invocation is runner-agnostic; adapt the trigger syntax for GitLab CI or CircleCI.
package.jsonscripts block: The validation command must be invocable asnpm run validate:tokensso engineers can run the same check locally before pushing.- Token directory structure: Token source files live under a known path (e.g.,
tokens/) relative to the repository root. Wildcard enumeration works but slows cold starts — explicit file lists are preferable for sub-15-second runs.
Step-by-Step Implementation
1. Define a strict JSON Schema (Draft 2020-12)
Write a schema that mirrors your token hierarchy, using $ref pointers for shared value types and additionalProperties: false everywhere to reject undocumented keys. Store it at schema/tokens.schema.json.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"color": {
"type": "object",
"patternProperties": {
"^[a-z0-9-]+$": {
"type": "object",
"properties": {
"value": {
"type": "string",
"pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
},
"type": { "const": "color" }
},
"required": ["value", "type"],
"additionalProperties": false
}
}
},
"spacing": {
"type": "object",
"patternProperties": {
"^[a-z0-9-]+$": {
"type": "object",
"properties": {
"value": { "type": "string" },
"type": { "const": "spacing" }
},
"required": ["value", "type"],
"additionalProperties": false
}
}
}
},
"additionalProperties": false
}
Why this works: patternProperties with a strict key regex enforces your naming convention at parse time rather than in application code. additionalProperties: false at every level means any key introduced by a Figma export script that deviates from the spec causes an immediate, actionable error — not a silent downstream failure.
2. Install a performant validator
npm install --save-dev ajv@^8 ajv-formats@^3
Why this works: AJV v8 is the fastest JSON Schema validator in the Node ecosystem and the only widely-used one with complete Draft 2020-12 support. ajv-formats adds the "format" keyword for color strings and URLs if your schema uses them.
3. Write the validation script
Create scripts/validate-tokens.js. The script collects errors across all token files and exits non-zero only after processing every file, so a single run surfaces the full error set.
const Ajv = require("ajv/dist/2020"); // Draft 2020-12 entry point
const addFormats = require("ajv-formats");
const fs = require("fs");
const path = require("path");
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
const schemaPath = path.resolve(__dirname, "../schema/tokens.schema.json");
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
const validate = ajv.compile(schema);
const tokenFiles = [
"./tokens/core.json",
"./tokens/themes.json",
"./tokens/semantic.json"
];
let hasErrors = false;
for (const file of tokenFiles) {
const data = JSON.parse(fs.readFileSync(path.resolve(file), "utf8"));
const valid = validate(data);
if (!valid) {
console.error(`\n[FAIL] ${file}`);
for (const err of validate.errors) {
console.error(` ${err.instancePath || "/"} → ${err.message}`);
}
hasErrors = true;
} else {
console.log(`[OK] ${file}`);
}
}
process.exit(hasErrors ? 1 : 0);
Why this works: allErrors: true collects every violation in a single pass rather than short-circuiting at the first error. That is essential for triage in pull-request comment bots that parse stdout — a partial error list forces multiple CI re-runs to find all problems.
4. Register the script in package.json
{
"scripts": {
"validate:tokens": "node scripts/validate-tokens.js"
}
}
Why this works: Registering the command here lets engineers run npm run validate:tokens locally with the same code path the CI runner uses. Divergence between local and CI invocations is the most common source of “passes locally, fails in CI” reports.
5. Configure the CI workflow
Save this to .github/workflows/validate-tokens.yml. The job runs on both push and pull-request events so the gate fires before any review is requested and again when the branch is updated.
name: Token Schema Validation
on:
pull_request:
paths:
- "tokens/**"
- "schema/**"
push:
branches:
- main
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- run: npm run validate:tokens
Why this works: The paths filter limits CI runs to commits that actually touch token or schema files. On a large monorepo, this avoids triggering the job on every unrelated commit. actions/setup-node with cache: 'npm' keeps cold-start time under five seconds on the ubuntu-latest runner.
6. Add a pre-commit hook for fast local feedback
Install lint-staged and husky to run the validator before each commit. This catches syntax errors before they trigger a full CI run, which is especially useful for teams who push white-label token overrides from multiple brand forks.
{
"lint-staged": {
"tokens/**/*.json": "node scripts/validate-tokens.js"
}
}
Why this works: A pre-commit hook runs in the engineer’s environment, not on CI infrastructure. It eliminates the 60–90 second feedback loop for obvious mistakes (missing type key, wrong hex format) that would otherwise consume CI minutes and block the PR queue.
Verification
After wiring up the workflow, confirm the gate is operating correctly with these checks:
1. Inject a deliberate violation and push to a PR branch:
# In tokens/core.json, change a valid hex to a bare integer:
# "value": "#ff5733" → "value": 16734003
git add tokens/core.json && git commit -m "test: schema gate"
git push origin feature/test-schema-gate
Check the Actions tab. The validate job should fail with output like:
[FAIL] ./tokens/core.json
/color/brand-primary/value → must be string
2. Confirm the PR is blocked:
The merge button should show “Some checks were not successful” and the validate / validate check should be listed as failed. If the branch protection rule requiring the check is not configured, add it under Settings → Branches → Branch protection rules → Require status checks to pass before merging and select validate / validate.
3. Fix the violation and re-push:
# Revert the change
git revert HEAD --no-edit
git push origin feature/test-schema-gate
The CI job should re-run and emit [OK] for each file, unblocking the PR.
4. Check the detecting orphaned and unused tokens workflow (if configured) to verify the schema validator and the automated token audit run in the same pipeline without race conditions. Both jobs should complete on the same commit SHA.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
Cannot find module 'ajv/dist/2020' |
AJV v7 or below is installed — the /dist/2020 entry point does not exist |
Run npm install --save-dev ajv@^8; check package-lock.json for a hoisted older version |
Validator exits 0 but tokens contain obvious errors |
Schema additionalProperties is not set to false, so extra keys are silently accepted |
Add "additionalProperties": false to every object definition in the schema |
| CI job runs on every push regardless of changed files | The paths filter is missing from the workflow trigger block |
Add paths: ["tokens/**", "schema/**"] under both pull_request and push triggers |
Hex color validation passes #gggggg (invalid hex chars) |
The regex pattern allows [0-9a-fA-F] but your JSON uses uppercase G due to an export script bug |
Fix the export script; add a stricter regex: `^#([0-9a-fA-F]{3} |
Spacing values fail validation with must be string |
Figma Tokens plugin exports numeric spacing values without quotes in JSON | Add a normalization step before validation: JSON.stringify(data).replace(/"value":\s*(\d+)/g, '"value":"$1rem"') — or fix the export config |
Migration Note
Migrating a legacy token repository that has never been schema-validated requires a phased approach to avoid blocking every open PR on day one.
Phase 1 — Audit without blocking. Run the validator in dry-run mode: replace process.exit(hasErrors ? 1 : 0) with process.exit(0) temporarily, and pipe output to a file. This surfaces all violations without failing builds. Review the output to categorize errors by type (missing type key, wrong value format, undocumented properties).
Phase 2 — Tighten incrementally. Start with required: ["value"] only, then add "type" after the first audit pass. Enable additionalProperties: false last, after confirming the schema covers every key your export scripts produce.
Phase 3 — Enforce on new PRs. Re-enable exit 1. At this point, existing branches that diverged before Phase 1 may need a rebase. Document the breaking-change boundary in CHANGELOG.md and tag the schema version (e.g., schema@1.0.0) so downstream consumers of the token package know when the contract tightened.
Migration checklist:
Related
- JSON Schema Validation for Tokens — parent reference covering the full validation strategy, toolchain comparison, and governance model.
- Detecting Orphaned and Unused Tokens in CI — pair this schema gate with an orphan-detection job to catch both invalid and dead tokens in the same pipeline.
- Overriding Base Tokens for White-Label Clients — how to structure per-brand token override files so they satisfy the same shared schema without duplicating the full token graph.