Automating Font Subsetting in CI/CD
Hand-running pyftsubset once and committing a 90 KB subset works until your copy changes, a new icon glyph appears, or someone adds a second language and silently re-bloats the font. The durable fix is to generate subsets in CI on every build, derived from the glyphs the site actually uses, with a hard size budget that fails the pipeline on regression. This guide builds that pipeline and is part of Unicode-Range & Subset Loading, within the broader Font Loading & Delivery Strategies blueprint.
Problem Statement
A static subset rots. The set of glyphs a site renders drifts every time content, components, or locales change, so a subset that was minimal at commit time grows stale: either it ships glyphs nobody renders (wasted KB on the LCP path) or it is missing glyphs that now appear (FOUT, tofu boxes, or fallback to a metric-mismatched system font). Manually re-running the subsetter is forgotten exactly when it matters. The fix is to make glyph detection and subsetting a deterministic build step, with unicode-range subset loading emitted automatically and a budget gate that blocks merges when a subset exceeds the target.
Prerequisites
- Source font files in TTF or OTF (the master you subset from), checked into the repo or fetched in CI.
- Python with
fonttools[woff]installed (pip install "fonttools[woff]") — providespyftsubsetand the Brotli backend WOFF2 needs. - Node with
glyphhangeravailable (npm i -D glyphhanger), plus a headless Chromium (Puppeteer) so glyphhanger can crawl rendered pages, not just static HTML. - A built site or a list of URLs/HTML files glyphhanger can scan for the actual character set.
- A per-subset KB budget agreed up front (target < 50 KB per subset WOFF2 for the Latin path).
Implementation
The pipeline has three stages: (1) detect the glyphs the site renders, (2) subset the master font to WOFF2 with a unicode-range, and (3) gate on size. Here is the full script.
scripts/subset-fonts.sh — glyph detection plus WOFF2 subsetting
#!/usr/bin/env bash
set -euo pipefail
SRC="src/fonts/Inter.ttf" # master font to subset from
OUT="dist/fonts" # where hashed subsets land
SITE="dist/**/*.html" # built HTML to scan for used glyphs
BUDGET_KB=50 # per-subset ceiling (woff2)
mkdir -p "$OUT"
# 1. Detect the exact glyphs the site renders, as a U+ codepoint list.
# --formats=none => don't subset here; just emit the character set.
USED=$(npx glyphhanger "$SITE" --formats=none --json \
| npx glyphhanger-to-range) # -> "U+20-7E,U+A9,U+2014,..."
if [ -z "$USED" ]; then
echo "No glyphs detected — refusing to emit an empty subset." >&2
exit 1
fi
# 2. Subset the master to WOFF2, keeping only detected codepoints,
# and stamp the matching unicode-range into the output for the CSS.
pyftsubset "$SRC" \
--unicodes="$USED" \
--layout-features='kern,liga,calt' \
--flavor=woff2 \
--desubroutinize \
--output-file="$OUT/inter-subset.woff2"
# 3. Content-hash the filename so it can be cached immutably.
HASH=$(sha256sum "$OUT/inter-subset.woff2" | cut -c1-8)
mv "$OUT/inter-subset.woff2" "$OUT/inter-subset.$HASH.woff2"
# Persist the range + filename for the CSS generation step.
echo "$USED" > "$OUT/inter-subset.range.txt"
echo "$HASH" > "$OUT/inter-subset.hash.txt"
echo "Wrote $OUT/inter-subset.$HASH.woff2"
Annotated, line by line:
set -euo pipefail— fail the whole script on any non-zero exit, undefined variable, or broken pipe stage, so a silent glyphhanger failure cannot ship an empty font.glyphhanger "$SITE" --formats=none --json— glyphhanger renders the matched HTML in headless Chromium and reports every codepoint actually drawn, including glyphs injected by JS or CSScontent.--formats=nonemeans "report only, don't subset," so this stage just produces the character set.glyphhanger-to-range— converts glyphhanger's character list into a compactU+xx-yy,U+zzrange string, exactly the format bothpyftsubset --unicodesand the CSSunicode-rangedescriptor expect.- empty-set guard — if detection returns nothing (bad selector, build artifact missing), we abort rather than emit a zero-glyph font that would render every character as fallback.
pyftsubset --unicodes="$USED"— strips the master font down to only the detected codepoints, discarding the rest of the glyph table.--layout-features='kern,liga,calt'— keep kerning, standard ligatures, and contextual alternates; by defaultpyftsubsetdrops layout features it thinks are unused, which can break common ligatures.--flavor=woff2— emit WOFF2 (Brotli-compressed), roughly 30% smaller than WOFF.--desubroutinize— flatten CFF subroutines; this sometimes improves WOFF2 compression on subset CFF fonts.- content hash +
mv— rename tointer-subset.<hash>.woff2so the file can carryCache-Control: immutableand bust automatically when glyphs change. .range.txt/.hash.txt— hand the unicode-range and hash to the CSS generation step so the@font-facesrcURL andunicode-rangedescriptor match the emitted file exactly.
Wire it into npm scripts and run it after the site is built:
package.json — build wiring
{
"scripts": {
"build:site": "eleventy",
"build:fonts": "bash scripts/subset-fonts.sh",
"build": "npm run build:site && npm run build:fonts"
}
}
Fonts are subset after the HTML is generated, because glyphhanger must scan the rendered output to know which glyphs are actually used.
And gate it in GitHub Actions:
.github/workflows/build.yml — CI stage
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install "fonttools[woff]"
- run: npm ci
- run: npm run build # build:site then build:fonts
- run: bash scripts/check-font-budget.sh # fails build if over budget
Defensive Variant: Fail the Build Over Budget
The gate is what makes this safe. Without it, a glyph explosion (someone adds an emoji range, or pulls in a CJK locale) would quietly push the LCP-path font past budget and nobody would notice until field metrics regressed. This script fails the build when any subset exceeds the ceiling.
scripts/check-font-budget.sh — hard size gate
#!/usr/bin/env bash
set -euo pipefail
BUDGET_KB=50
status=0
for f in dist/fonts/*.woff2; do
bytes=$(wc -c < "$f")
kb=$(( bytes / 1024 ))
if [ "$kb" -gt "$BUDGET_KB" ]; then
echo "FAIL: $f is ${kb}KB (budget ${BUDGET_KB}KB)" >&2
status=1
else
echo "ok: $f is ${kb}KB"
fi
done
exit "$status"
Each emitted WOFF2 is measured; any file over BUDGET_KB flips status to 1 and the script exits non-zero, which fails the CI job and blocks the merge. Tune the budget per subset class — a Latin critical subset deserves a tighter ceiling than an optional symbols range. Because the previous step content-hashed the filenames, a regression also shows up as a changed hash in the diff, making the cause easy to spot in review.
Verification
Confirm the pipeline produced a smaller, correct font:
- KB before/after —
ls -l src/fonts/Inter.ttf dist/fonts/inter-subset.*.woff2should show the subset is a fraction of the master. Expect 60–90% reduction for an English-only Latin subset. - Glyph coverage —
pyftsubset --helpaside, dump the subset's character map withpython -c "from fontTools.ttLib import TTFont; print(sorted(TTFont('dist/fonts/inter-subset.HASH.woff2').getBestCmap().keys()))"and confirm it contains the codepoints your pages render and omits the rest. - unicode-range match — open the generated
@font-faceand confirm itsunicode-rangedescriptor equals the range ininter-subset.range.txt; a mismatch means the browser may download the file for characters it cannot render, or skip it for characters it can. - DevTools — load the built site, filter Network by
Font, and verify the single subset request transfers the expected KB and is initiated by your@font-facerule. - CI gate — temporarily lower
BUDGET_KBand confirm the job fails, proving the gate is actually wired in and not a no-op.
Common Pitfalls
- Scanning raw source HTML instead of rendered output — glyphs injected by JS, CSS
content, or a CMS never appear, so the subset is missing characters that show up as fallback in production. - Dropping layout features by omitting
--layout-features—pyftsubsetprunes aggressively and can strip ligatures and kerning your design relies on. - Forgetting
fonttools[woff](the Brotli/WOFF2 extra) —--flavor=woff2then fails or silently emits a worse format. - Committing the subset output to git and letting CI also generate it — the two drift; treat subsets as build artifacts, not source.
- Setting one global budget for every subset — an optional symbols or CJK range will blow a 50 KB Latin budget; budget per subset class instead.
- No empty-set guard — a broken glyphhanger run emits a zero-glyph font that renders the whole site in a fallback face with no obvious error.
FAQ
Why use glyphhanger instead of hand-writing the unicode-range? A hand-written range goes stale the moment content changes and either ships unused glyphs or misses new ones. glyphhanger renders your pages in headless Chromium and reports the exact codepoints drawn — including JS- and CSS-injected glyphs — so the subset always matches reality on every build.
How do I subset a variable font in this pipeline?
Point pyftsubset at the variable master and add --no-prune-unicode-ranges plus, if you want to pin axes, --instancer (or run fonttools varLib.instancer first). The --flavor=woff2 and --unicodes flags work identically; the output stays a variable WOFF2 with only the detected glyphs.
Should the budget gate run on pull requests or only on the main branch? Run it on pull requests so a regression is blocked before merge, when the cause is still attributable to one diff. Running it only on main means you discover the bloat after it has already landed and shipped.