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.

Schema validation gate in a token PR pipeline A sequence diagram showing a token pull request flowing through JSON schema validation before merging; a failing PR is blocked and a passing PR is allowed to merge. Engineer CI Runner Git Branch Open token PR tokens/core.json CI triggered pull_request event JSON Schema ajv validate-tokens Draft 2020-12 · allErrors:true FAIL exit 1 · PR blocked invalid PASS exit 0 · continue valid Merge to main Fix & re-push
Token PR pipeline: AJV schema validation gates the merge path. Invalid tokens block the PR; valid tokens continue to merge.

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 value and type keys.
  • Node.js ≥ 18: Required by AJV v8 and the ajv-cli package. Pin the version in your CI configuration.
  • AJV v8+: Versions below 8 do not support JSON Schema Draft 2020-12. Install ajv@^8 as 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.json scripts block: The validation command must be invocable as npm run validate:tokens so 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: