Validating Asset Manifests in CI
A manifest is trusted by every downstream system and checked by none of them. The bundler writes it, the upload step copies it, the template reads it — and the first thing that notices a dangling reference is a browser, in production, after the deploy has already finished. A validation gate closes that hole: a script that reads the manifest and the build directory together, asserts five invariants, and exits non-zero before anything reaches the CDN.
The Five Invariants
Each check catches a distinct class of bug, and each one has been the root cause of a real outage. Run them in this order — the cheap structural checks fail fast, and digest verification, the only one that reads every byte on disk, runs last.
| Check | Failure it catches | Typical cause |
|---|---|---|
| Referenced files exist | Dangling reference → 404 on every page | Partial upload, wrong outDir, cleaned directory |
| No duplicate hashes | Two different files claiming one identity | Truncated hash length, copied file, collision |
| No orphaned outputs | Dead bytes uploaded forever | Plugin writes a file the manifest never records |
| Consistent hash length | Broken CDN path regex, weak collision margin | Mixed [hash:8] and [hash:16] templates |
| Digests match bytes | Browser blocks the script | Post-build rewrite after digest computation |
What “Orphaned” and “Dangling” Actually Mean
The build directory and the manifest are two sets. Validation is set arithmetic over them, and each of the three resulting regions has a different severity.
Legitimate orphans do exist — source maps you deliberately keep out of the manifest, a robots.txt copied into the output directory, a licence file emitted by a plugin. That is why the validator below takes an ignore list for orphans while treating dangling references as unconditionally fatal.
The Validation Script
Drop this at scripts/validate-manifest.mjs. It takes no dependencies beyond Node 18, reads the manifest and the build directory, and prints every problem before exiting rather than stopping at the first one.
#!/usr/bin/env node
// scripts/validate-manifest.mjs
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
const DIST = process.env.DIST_DIR || 'dist';
const MANIFEST = process.env.MANIFEST_PATH || join(DIST, 'asset-manifest.json');
const URL_PREFIX = process.env.URL_PREFIX || '/static/';
const HASH_RE = /[.-]([a-f0-9]{6,20})\.[a-z0-9]+$/;
const IGNORE_ORPHANS = [
/\.map$/,
/asset-manifest\.json$/,
/^\.vite[\\/]/,
/^robots\.txt$/,
];
const problems = [];
const fail = (check, detail) => problems.push({ check, detail });
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
function toDiskPath(url) {
if (!url.startsWith(URL_PREFIX)) {
fail('prefix', `url "${url}" does not start with ${URL_PREFIX}`);
return null;
}
return join(DIST, url.slice(URL_PREFIX.length).split('/').join(sep));
}
const raw = JSON.parse(readFileSync(MANIFEST, 'utf8'));
const assets = raw.assets ?? raw;
const records = Object.entries(assets).map(([key, value]) => ({
key,
url: typeof value === 'string' ? value : value.url,
integrity: typeof value === 'string' ? null : value.integrity ?? null,
bytes: typeof value === 'string' ? null : value.bytes ?? null,
}));
if (records.length === 0) fail('empty', 'the manifest contains no records');
// ---- 1. every referenced file exists ------------------------------------
const referenced = new Set();
for (const record of records) {
const diskPath = toDiskPath(record.url);
if (diskPath === null) continue;
referenced.add(relative(DIST, diskPath));
try {
statSync(diskPath);
} catch {
fail('dangling', `${record.key} → ${record.url} (no file at ${diskPath})`);
}
}
// ---- 2. no two files share a content hash -------------------------------
const byHash = new Map();
for (const record of records) {
const match = HASH_RE.exec(record.url);
if (!match) continue;
const hash = match[1];
const seen = byHash.get(hash);
if (seen && seen !== record.url) {
fail('duplicate-hash', `hash ${hash} used by ${seen} and ${record.url}`);
}
byHash.set(hash, record.url);
}
// ---- 3. no emitted file is left unreferenced -----------------------------
for (const file of walk(DIST)) {
const rel = relative(DIST, file);
if (referenced.has(rel)) continue;
if (IGNORE_ORPHANS.some((re) => re.test(rel))) continue;
fail('orphan', `${rel} was emitted but no manifest record names it`);
}
// ---- 4. hash length is identical everywhere ------------------------------
const lengths = new Set();
for (const record of records) {
const match = HASH_RE.exec(record.url);
if (match) lengths.add(match[1].length);
}
if (lengths.size > 1) {
fail('hash-length', `mixed hash lengths in one build: ${[...lengths].sort().join(', ')}`);
}
if (lengths.size === 1 && [...lengths][0] < 8) {
fail('hash-length', `hash length ${[...lengths][0]} is below the 8-character floor`);
}
// ---- 5. integrity digests match the bytes on disk ------------------------
for (const record of records) {
if (!record.integrity) continue;
const diskPath = toDiskPath(record.url);
if (diskPath === null) continue;
let bytes;
try {
bytes = readFileSync(diskPath);
} catch {
continue; // already reported as dangling
}
const [algorithm] = record.integrity.split('-');
if (!['sha256', 'sha384', 'sha512'].includes(algorithm)) {
fail('integrity', `${record.key} uses unsupported algorithm "${algorithm}"`);
continue;
}
const actual = `${algorithm}-${createHash(algorithm).update(bytes).digest('base64')}`;
if (actual !== record.integrity) {
fail('integrity', `${record.key} digest mismatch (manifest ${record.integrity}, disk ${actual})`);
}
if (record.bytes !== null && record.bytes !== bytes.length) {
fail('size', `${record.key} records ${record.bytes} bytes but the file is ${bytes.length}`);
}
}
// ---- report --------------------------------------------------------------
if (problems.length === 0) {
console.log(`manifest OK — ${records.length} records verified in ${DIST}`);
process.exit(0);
}
const grouped = new Map();
for (const { check, detail } of problems) {
if (!grouped.has(check)) grouped.set(check, []);
grouped.get(check).push(detail);
}
for (const [check, details] of grouped) {
console.error(`\n${check} (${details.length}):`);
for (const detail of details) console.error(` - ${detail}`);
}
console.error(`\n${problems.length} manifest problem(s) — refusing to publish`);
process.exit(1);
Add it to package.json so it is one command locally and in CI:
{
"scripts": {
"build": "vite build",
"validate:manifest": "node scripts/validate-manifest.mjs",
"ship": "npm run build && npm run validate:manifest"
}
}
Two design choices are worth defending. The script collects all problems and reports them grouped, because a mis-set URL_PREFIX produces one failure per record and a gate that prints only the first turns a two-minute fix into a twenty-minute bisect. And it verifies bytes alongside the digest, which costs nothing extra since the file is already in memory and catches a manifest whose size field was computed before a compression step rewrote the file.
Wiring It Into GitHub Actions
The gate belongs between build and upload, in the same job, so a failure means nothing was ever published.
# .github/workflows/deploy.yml
name: Build and deploy assets
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
build-and-deploy:
runs-on: ubuntu-24.04
env:
DIST_DIR: dist
URL_PREFIX: /static/
GIT_SHA: ${{ github.sha }}
steps:
- name: Check out the exact commit
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20.11.1'
cache: npm
- name: Install with a frozen lockfile
run: npm ci
- name: Build
run: npm run build
- name: Validate the asset manifest
run: node scripts/validate-manifest.mjs
- name: Assert the build is reproducible
run: |
cp "$DIST_DIR/asset-manifest.json" /tmp/manifest-run1.json
rm -rf "$DIST_DIR"
npm run build
diff /tmp/manifest-run1.json "$DIST_DIR/asset-manifest.json" \
|| { echo "::error::manifest differs between two builds of one commit"; exit 1; }
- name: Upload hashed assets
run: |
aws s3 sync "$DIST_DIR" "s3://$BUCKET/static/" \
--exclude "asset-manifest.json" \
--cache-control "public,max-age=31536000,immutable"
env:
BUCKET: ${{ secrets.ASSET_BUCKET }}
- name: Publish the manifest last
run: |
aws s3 cp "$DIST_DIR/asset-manifest.json" "s3://$BUCKET/manifests/$GIT_SHA.json" \
--cache-control "public,max-age=30"
aws s3 cp "s3://$BUCKET/manifests/$GIT_SHA.json" "s3://$BUCKET/manifests/current.json" \
--cache-control "public,max-age=30"
env:
BUCKET: ${{ secrets.ASSET_BUCKET }}
- name: Smoke-test the live manifest
run: |
curl -sf "https://cdn.example.com/manifests/current.json" -o /tmp/live.json
jq -r '.assets[].url' /tmp/live.json | while read -r u; do
code=$(curl -o /dev/null -s -w '%{http_code}' "https://cdn.example.com$u")
[ "$code" = 200 ] || { echo "::error::$code on $u"; exit 1; }
done
The reproducibility step is the one teams add last and value most. It builds the same commit twice and fails if the manifest differs, which turns an intermittent, hard-to-attribute hash churn problem into a deterministic red build on the pull request that introduced it. The underlying causes are catalogued in the deterministic build outputs guide.
Verifying the Gate Actually Gates
A validation script nobody has seen fail is indistinguishable from one that always passes. Prove it works by breaking the manifest deliberately:
npm run build
# Introduce a dangling reference and confirm the gate rejects it
jq '.assets["main.js"].url = "/static/main.deadbeef.js"' \
dist/asset-manifest.json > /tmp/broken.json
MANIFEST_PATH=/tmp/broken.json node scripts/validate-manifest.mjs; echo "exit: $?"
# Expect: dangling (1): main.js → /static/main.deadbeef.js ... exit: 1
# Corrupt a digest and confirm check 5 rejects it
jq '.assets["main.js"].integrity = "sha384-AAAAAAAAAAAAAAAAAAAAAAAA"' \
dist/asset-manifest.json > /tmp/bad-digest.json
MANIFEST_PATH=/tmp/bad-digest.json node scripts/validate-manifest.mjs; echo "exit: $?"
# Expect: integrity (1): main.js digest mismatch ... exit: 1
# Confirm the untouched manifest still passes
node scripts/validate-manifest.mjs; echo "exit: $?"
# Expect: manifest OK — 482 records verified in dist exit: 0
Keep those three commands as a test of the validator itself. They run in under a second and are the only evidence that a green pipeline means anything.
When to Reconsider the Gate
Downgrade the orphan check to a warning in a repository where plugins legitimately emit files outside the manifest — licence banners, .well-known documents, a legacy favicon.ico. Keep it failing only once the ignore list has stopped growing; a gate that everyone routes around is worse than no gate.
Skip the digest check if you do not ship SRI attributes. It is the only check that reads every byte, and on a build with hundreds of megabytes of media it dominates the runtime. If you skip it, also drop the integrity field from the manifest rather than leaving an unverified one in place — an integrity value nobody checks is a claim nobody has tested.
Move the whole gate to a pre-merge check rather than a pre-deploy one when your build is fast. Failing on the pull request gives the author the feedback while the change is still in their head, and the deploy-time run becomes a cheap second line of defence. In either position the pipeline shape around it is the same one described in the CI/CD asset pipeline section.
Add a sixth check if you generate the manifest through a bundler that computes digests in memory. Verify that the digest algorithm matches what your CSP or SRI policy expects — the details of choosing and applying it are in generating SRI hashes in your build pipeline, and the equivalent check for a Rollup-produced manifest is covered in the Rollup manifest recipe.
Related
- Asset manifest generation — parent guide covering manifest contents, bundler shapes and atomic publishing
- Resolving hashed URLs at request time — the consumer this gate protects
- Choosing a Webpack manifest plugin — which plugin gives the validator digests to check
- Reproducible build output — why the double-build diff belongs in the same job
- esbuild fingerprinting plugins — validating a metafile-derived manifest with the same script