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.

The five-check manifest gate A narrowing funnel of five validation stages: referenced files exist, no duplicate content hashes, no unreferenced output files, consistent hash length, and integrity digests that match the bytes on disk. Passing all five marks the manifest safe to publish. THE FIVE-CHECK MANIFEST GATE 1 — every referenced file exists on disk 2 — no two files share a content hash 3 — no emitted file is left unreferenced 4 — hash length is identical everywhere 5 — every digest matches its bytes SAFE TO PUBLISH Ordering matters: the digest pass reads every byte, so let the cheap structural checks fail first.
Five invariants in increasing cost order; a manifest that survives all five describes a file set that actually exists and hashes to what it claims.
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.

Manifest coverage after a build A single bar split into three segments: six dangling manifest references, four hundred and eighty-two files that are both referenced and present, and three orphaned files on disk that no manifest entry names. MANIFEST COVERAGE AFTER A BUILD 6 dangling 482 referenced and present 3 orphans gate must fail the healthy set warn or fail named, missing every key resolves dead uploads Dangling references break the page immediately; orphans only waste storage — treat them differently.
Three regions, two severities: a dangling reference is an outage, an orphan is waste. A gate that treats them identically will be switched off by the first team that hits a legitimate orphan.

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.

Where the gate sits in the job Five CI steps run left to right: checkout, setup Node, build, validate the manifest, and upload. The validate step branches downward to a job failure that skips the upload entirely. THE GATE RUNS BEFORE ANYTHING LEAVES CI pass checkout git ref pinned setup node npm cache build emits manifest validate five checks upload s3 sync, then cut fail exit 1, job stops nothing uploaded Same job, not a separate one — a downstream job could still run against a partially written bucket.
Placing the gate inside the deploy job means a failed manifest never reaches the object store, so there is nothing to clean up afterwards.
# .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.