Validating Styles in CI with the GL Style Spec
Validate a MapLibre style in two layers: first run structural validation with validate() from @maplibre/maplibre-gl-style-spec to catch malformed JSON, bad property types, and illegal expressions, then run a second check that every source-layer and every ["get", key] in the style actually exists in the tiles you are shipping — because the spec validator has no idea what your tiles contain.
When to use this
Wire both checks into any CI pipeline that ships a style document to a CDN or tile server. A style is code: a renamed layer, a typo in an attribute key, or a stray trailing comma passes review unnoticed and blanks the map for every client. The two checks are cheap enough to run on every pull request and are the cheapest place in the pipeline to catch a break — far cheaper than a rollback after users report a grey map.
Specification detail: what validate() does and does not check
validate() takes a style as a string or object and returns an array of error objects — empty means the style is structurally valid. Each error has a message and, where applicable, a line.
validate(style: string | object, styleSpec?: object) => Array<{ message: string, line?: number, identifier?: string }>
Check performed by validate() |
Caught? |
|---|---|
version present and equal to 8 |
Yes |
| Unknown or misspelled top-level / paint / layout properties | Yes |
| Wrong value type (string where number expected, etc.) | Yes |
Malformed expressions (bad operator, wrong arg count, missing match fallback) |
Yes |
Layer references a source id not declared in sources |
Yes |
A source-layer name exists in the actual tiles |
No |
A ["get", "key"] attribute exists in the tiles |
No |
The sources tile URLs are reachable |
No |
The critical gap is the bottom three rows. validate() checks the style against the schema in isolation; it cannot know that your tiles expose a layer called transportation or an attribute called pop_density. A style can be fully spec-valid and still render nothing because it references source-layer: "roads" while the tiles ship transportation. That schema-agreement check is the second layer, and it is the one that actually catches production breaks.
Production command block
Layer one — structural validation
// scripts/validate-style.mjs
import { readFileSync } from 'node:fs';
import { validate } from '@maplibre/maplibre-gl-style-spec';
const stylePath = process.argv[2] ?? 'styles/production.json';
const errors = validate(readFileSync(stylePath, 'utf8'));
if (errors.length > 0) {
for (const e of errors) {
console.error(`SPEC ${stylePath}:${e.line ?? '?'} ${e.message}`);
}
process.exit(1);
}
console.log(`OK ${stylePath} is spec-valid`);
Install with npm i -D @maplibre/maplibre-gl-style-spec. The package ships the v8 spec, so you do not fetch a schema at runtime.
Layer two — schema agreement against the tiles
First extract a manifest of what the tiles actually contain. Tippecanoe writes vector_layers (layer names plus per-layer attribute keys) into the tileset metadata; pull it once at build time:
# From an .mbtiles build
sqlite3 tiles/city.mbtiles \
"SELECT json_extract(value,'$.vector_layers') FROM metadata WHERE name='json'" \
> tile_schema.json
# Or from a .pmtiles build
pmtiles show tiles/city.pmtiles --header-json | jq '.vector_layers' > tile_schema.json
Then diff every reference in the style against that manifest:
// scripts/check-schema-agreement.mjs
import { readFileSync } from 'node:fs';
const style = JSON.parse(readFileSync(process.argv[2] ?? 'styles/production.json', 'utf8'));
const layers = JSON.parse(readFileSync('tile_schema.json', 'utf8'));
// Build { sourceLayerName: Set(attributeKeys) }
const schema = new Map(
layers.map((l) => [l.id, new Set(Object.keys(l.fields ?? {}))])
);
// Collect every ["get", "key"] used in a layer's paint/layout/filter
function getKeys(node, out = new Set()) {
if (Array.isArray(node)) {
if (node[0] === 'get' && typeof node[1] === 'string') out.add(node[1]);
for (const child of node) getKeys(child, out);
} else if (node && typeof node === 'object') {
for (const v of Object.values(node)) getKeys(v, out);
}
return out;
}
const problems = [];
for (const layer of style.layers) {
const sl = layer['source-layer'];
if (!sl) continue; // background / raster layers have no source-layer
if (!schema.has(sl)) {
problems.push(`layer '${layer.id}': source-layer '${sl}' not in tiles`);
continue;
}
const known = schema.get(sl);
for (const key of getKeys({ ...layer.paint, ...layer.layout, f: layer.filter })) {
if (!known.has(key)) {
problems.push(`layer '${layer.id}': get('${key}') not in source-layer '${sl}'`);
}
}
}
if (problems.length) {
problems.forEach((p) => console.error(`SCHEMA ${p}`));
process.exit(1);
}
console.log('OK style agrees with tile schema');
Wire both into package.json so a single command runs the full gate:
{
"scripts": {
"validate:style": "node scripts/validate-style.mjs styles/production.json && node scripts/check-schema-agreement.mjs styles/production.json"
}
}
And run it as a required CI step:
# .github/workflows/style-validate.yml
name: Validate style
on:
pull_request:
paths: ["styles/**", "tiles/**"]
jobs:
validate:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npm run validate:style
Interaction effects
Schema manifest extraction is the linchpin. The second check is only as good as tile_schema.json. Generate it from the exact tiles being deployed, in the same CI/CD tile build automation job that produces the tiles, and pass it forward as a build artifact. A schema file that drifts from the tiles gives false confidence — see the stale-fixture mistake below.
This is the automated backstop for renamed layers. The single most common silent break is a source-layer rename in the tile build that the style is never updated to match. The schema-agreement check turns that into a hard CI failure instead of a grey map, which is exactly the failure mode described in syncing MapLibre filters with renamed source layers. Filters are just another place ["get", key] appears, and the key-collection walk above already covers layer.filter.
It is a deploy gate, not just a linter. Make the CI job a required status check so a failing validation blocks merge, and run the same npm run validate:style in the deploy job against the versioned style before it is uploaded. Pair it with the multi-source concerns in MapLibre GL JSON structure — each source in a multi-source style needs its own schema manifest, so extend tile_schema.json to a per-source map when you have more than one tileset.
Performance impact
Both checks are pure JSON work with no network calls, so they run in well under a second on a typical style even in the tens of layers. There is no reason to sample or skip them: run the full gate on every pull request. The only measurable cost is the one-time schema extraction during the tile build, which is a single metadata read and negligible next to tile generation itself.
Common mistakes
1. Trusting validate() alone while a renamed layer blanks the map
Symptom: CI is green, the style is “valid,” but a region of the map renders empty in production.
Cause: validate() passed because the style is structurally fine — but a layer references source-layer: "roads" and the tiles now ship transportation. The spec validator never checks tile contents.
Fix: Run the schema-agreement check as a second, required step. It reports source-layer 'roads' not in tiles and fails the build before deploy.
2. Not failing the build on warnings
Symptom: Errors scroll past in the CI log but the job still reports success and the deploy proceeds.
Cause: The script prints errors but never sets a nonzero exit code, or a try/catch swallows the failure.
Fix: Ensure every failure path ends in process.exit(1), as both scripts above do. Verify locally with npm run validate:style; echo $? — a broken style must print a nonzero code.
3. Stale schema fixture
Symptom: Schema agreement passes in CI but the map is still broken after deploy, or a legitimately renamed attribute is falsely flagged.
Cause: tile_schema.json was committed by hand or generated from an old tile build and no longer matches the tiles being shipped.
Fix: Never commit the schema manually. Regenerate it from the current tiles in the same pipeline run and pass it as a build artifact:
pmtiles show tiles/city.pmtiles --header-json | jq '.vector_layers' > tile_schema.json
If the tiles and style are built in separate jobs, publish tile_schema.json from the tile job and consume it in the validation job so the two can never drift.
Parent: Style Validation Workflows
Related
- Syncing MapLibre Filters with Renamed Source Layers — the exact break the schema-agreement check is built to catch, with the rename workflow that prevents it.
- CI/CD Tile Build Automation — the build job that produces the tiles and the
tile_schema.jsonmanifest this gate consumes. - MapLibre GL JSON Structure — the top-level keys and layer shape that
validate()enforces, and how multi-source styles expand the schema manifest.