How to validate font format in ci/cd pipelines
- Step 1Enumerate font files — Glob `**/*.{ttf,otf,woff,woff2}` under your fonts directories. Capture the path and the declared extension for each.
- Step 2Read 4 bytes per file — Open each file and read the first 4 bytes into a Buffer. Compute the big-endian uint32 and match it against the magic table (`0x00010000`, `OTTO`, `wOFF`, `wOF2`, `ttcf`, `true`).
- Step 3Compare magic to extension — Map the magic to a canonical format, then assert the file extension equals it (lower-cased). Treat the Apple `true` magic as `ttf`. Collect every mismatch.
- Step 4Assert no TTC anywhere — A `ttcf` magic in a `.ttf`/`.otf`/`.woff`/`.woff2` slot is always a bug. Fail the build on any collection found where a single face was expected.
- Step 5Fail the job on any discrepancy — Exit non-zero with a list of `path: declared X, actual Y`. A clear message makes the fix (rename or re-fetch) obvious to whoever opened the PR.
- Step 6Wire into GitHub Actions — Add a `node scripts/check-fonts.mjs` step after checkout. It needs no build, no install of the font libraries, and runs in well under a second.
Magic table to encode in your check
The exact values the JAD detector matches. Encode these in your CI script so local-tool and pipeline results never diverge.
| uint32 (big-endian) | ASCII | Canonical format | CI action |
|---|---|---|---|
0x00010000 | (none) | ttf | OK if extension is .ttf |
0x4F54544F | OTTO | otf | OK if extension is .otf |
0x774F4646 | wOFF | woff | OK if extension is .woff |
0x774F4632 | wOF2 | woff2 | OK if extension is .woff2 |
0x74746366 | ttcf | ttc | Always fail — collection, not a single face |
0x74727565 | true | ttf | Treat as ttf; OK if extension is .ttf |
| anything else | varies | unknown | Fail — not a recognised font (corrupt or mislabelled) |
Failure classes a format gate catches
The runtime symptom each class produces, and the magic-byte signature that catches it before merge.
| Failure class | Runtime symptom | Caught by |
|---|---|---|
WOFF2 mislabelled as .woff | Old browsers feed Brotli bytes to a zlib decoder → garbage, font invisible | magic wOF2 ≠ .woff |
CFF OTF mislabelled as .ttf | Usually fine, but installers/tooling that key off extension misbehave | magic OTTO ≠ .ttf |
| Stray TTC commit | Most browsers refuse to load the collection at all | magic ttcf present |
| Truncated download | Buffer too short for a magic, or magic unrecognised | format unknown / file < 4 bytes |
| Wrong asset (ZIP/PNG renamed) | Browser can't parse it as a font | magic doesn't match any font value |
Cookbook
A self-contained Node check (no dependencies), the GitHub Actions step that runs it, and the assertions to make. The detection is the same logic the JAD tool ships — see /font-tools/font-format-identifier to spot-check a single file interactively.
Dependency-free Node format check
ExampleReads 4 bytes per file, maps magic to format, and fails on any extension mismatch, TTC, or unknown. No opentype.js, no WASM.
// scripts/check-fonts.mjs
import { readdirSync, openSync, readSync, closeSync } from "node:fs";
import { join, extname } from "node:path";
const MAGIC = {
0x00010000: "ttf", 0x4f54544f: "otf",
0x774f4646: "woff", 0x774f4632: "woff2",
0x74746366: "ttc", 0x74727565: "ttf", // 'true' → ttf
};
function magicOf(path) {
const fd = openSync(path, "r");
const buf = Buffer.alloc(4);
const n = readSync(fd, buf, 0, 4, 0);
closeSync(fd);
if (n < 4) return "unknown";
return MAGIC[buf.readUInt32BE(0)] ?? "unknown";
}
function walk(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]);
}
let failed = 0;
for (const f of walk("public/fonts")) {
const ext = extname(f).slice(1).toLowerCase();
if (!["ttf", "otf", "woff", "woff2"].includes(ext)) continue;
const actual = magicOf(f);
if (actual === "ttc") { console.error(`TTC committed: ${f}`); failed++; }
else if (actual !== ext) { console.error(`${f}: declared ${ext}, actual ${actual}`); failed++; }
}
process.exit(failed ? 1 : 0);GitHub Actions step
ExampleNo install of font libraries needed — the check is pure fs. Runs after checkout in well under a second.
# .github/workflows/ci.yml - name: Validate font formats run: node scripts/check-fonts.mjs
Failing run output
ExampleWhat the gate prints when someone commits a mislabelled WOFF2 and a stray collection.
$ node scripts/check-fonts.mjs public/fonts/Brand-Regular.woff: declared woff, actual woff2 TTC committed: public/fonts/PingFang.ttf Error: Process completed with exit code 1.
Asserting the WOFF/WOFF2 distinction specifically
ExampleThe most damaging real mismatch — a Brotli WOFF2 behind a .woff name fails only in older browsers. Make it an explicit, loud failure.
if (ext === "woff" && actual === "woff2")
console.error(`${f}: WOFF2 bytes behind .woff — breaks zlib-only browsers`);
if (ext === "woff2" && actual === "woff")
console.error(`${f}: WOFF (zlib) bytes behind .woff2 — wrong @font-face format()`);Spot-check a single suspicious file interactively
ExampleBefore scripting, confirm one file's real format by hand in the browser tool — the JSON it emits matches the magic table your script uses.
Drop the file at /font-tools/font-format-identifier →
{
"format": "woff2",
"magic": "0x774F4632",
"extension_matches_format": false // it was named .woff
}Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
File shorter than 4 bytes
unknownA download that died early may be a few bytes. readSync returns fewer than 4 bytes and your check should treat it as unknown and fail. The JAD tool does the same — its detector returns "Buffer is too short to identify" under 4 bytes.
Apple 'true' magic flagged as a mismatch
Bug if not handledIf you forget the 0x74727565 → ttf mapping, a legacy Mac TrueType font fails your gate as unknown. Include the true magic in your table (the JAD detector does) so genuine TrueType fonts pass.
EOT or SVG fonts in the repo
Skipped by extensionThe example globs only ttf/otf/woff/woff2 — matching the JAD tool's accepted set. .eot/.svg fonts won't be checked. If your legacy stack still ships them, add a separate rule; they have their own header conventions and are effectively dead on the modern web.
Big-endian vs little-endian read
Common mistakesfnt magic is big-endian. Use buf.readUInt32BE(0), not readUInt32LE. Reading little-endian flips the bytes and every comparison fails — your gate would reject every valid font.
Symlinked or LFS-pointer fonts
False positive riskIf fonts are tracked with Git LFS and the runner didn't pull them, the file on disk is a tiny text pointer, not the font. Its magic won't match and the gate fails (correctly — the real font is missing). Ensure lfs: true on checkout before the font check.
Case-different extension (.WOFF2)
Handle with toLowerCaseLower-case the extension before comparing, as the example does. Otherwise Brand.WOFF2 is compared against woff2 and fails spuriously. The JAD tool lower-cases both sides too.
Vendored fonts inside node_modules
Scope itDon't walk node_modules — third-party packages may legitimately ship odd formats, and you don't control them. Restrict the walk to your own public/fonts / src/fonts directories to avoid noise.
A valid magic but a corrupt body
Not caught hereA 4-byte check verifies the header only, not the glyph data. A font with correct magic but corrupt tables passes. For deeper assurance, add a SHA-256 comparison against known-good upstream hashes — see /font-tools/font-fingerprinter — as a second CI layer.
Frequently asked questions
Do I need any npm packages for the CI check?
No. Reading 4 bytes and comparing a uint32 uses only Node's built-in fs. You don't need opentype.js or the WOFF2 WASM codec — those are only required to actually open or convert fonts, not to identify them.
Why check format if builds already pass?
Because mislabelled fonts don't fail at build time — they fail at runtime in the browser, often only on specific engines (a Brotli WOFF2 behind a .woff name breaks zlib-only browsers). The byte check turns a silent runtime failure into a loud merge-time failure.
Which mismatch is the most dangerous?
WOFF2 bytes behind a .woff name. Older browsers route .woff/format("woff") to a zlib decoder, feed it Brotli, and get garbage — the font silently disappears for a slice of users. Assert this case explicitly.
How do I block accidental TTC commits?
Fail on the ttcf magic (0x74746366) anywhere. A collection is never a valid single-face web font; most browsers refuse it entirely.
Will this slow the pipeline down?
Negligibly. Each file is one 4-byte read; the directory walk dominates. Even 100+ fonts add well under a second.
Is the CI logic the same as the JAD tool?
Yes — both match the same magic table (0x00010000, OTTO, wOFF, wOF2, ttcf, true). Use /font-tools/font-format-identifier to spot-check one file by hand; the result will agree with your script.
Should I read big-endian or little-endian?
Big-endian. sfnt magic is stored big-endian, so use readUInt32BE. Little-endian flips the bytes and breaks every match.
Can I validate fonts fetched from a CDN at build time?
Yes — the same byte check works on any file regardless of origin. Useful when vendoring Google Fonts downloads or third-party design-system packages.
What about deeper corruption the magic can't see?
Layer a hash check on top: compare each font's SHA-256 against a known-good value with /font-tools/font-fingerprinter. The format gate catches mislabels; the hash catches silent corruption.
Do I need to handle the Apple 'true' magic?
Yes, if your repo has any legacy Mac TrueType fonts. Map 0x74727565 to ttf so they pass; otherwise they read as unknown and fail.
Should the check walk node_modules?
No. Scope it to your own font directories. Third-party packages may ship formats you don't control, which would create noise without protecting your build.
What exit code conventions should I use?
Exit non-zero on any mismatch, TTC, or unknown so the CI job fails. Print one path: declared X, actual Y line per problem so the fix is obvious in the PR.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.