Generating an SRI Manifest in GitHub Actions

Permalink to "Generating an SRI Manifest in GitHub Actions"

Part of CI/CD Integrity Gates, this page shows how a release workflow turns a build directory into a machine-readable JSON manifest of path, SHA-384 integrity value and byte size — and what to do with that manifest once it exists.

Quick Reference

Permalink to "Quick Reference"
Element Value Notes
Algorithm sha384 One algorithm for the whole manifest; digest base64-encoded
Hashed extensions .js, .mjs, .css, .wasm Everything a browser can enforce integrity on
Always excluded *.map, the manifest file itself Source maps are debug data; self-inclusion is unreproducible
Entry shape { "integrity": "sha384-…", "bytes": 128414 } Keyed by POSIX-style path relative to the build root
Ordering Code point sort on the path key Locale-independent, so diffs stay minimal
Upload actions/upload-artifact@v4 Name must be unique within the run; artifacts are immutable
Release attachment gh release upload <tag> <file> --clobber Needs permissions: contents: write
Browser support Enforced on <script> and <link> Manifest entries for other file types are audit data only

The mental model

Permalink to "The mental model"

A build produces two things that matter to integrity: the bytes, and the truth about the bytes. Fingerprinted filenames such as app-4f9c1b.js encode the second thing weakly — the hash is in the name, but nothing downstream can verify it without recomputing something, and nothing tells a server which digest belongs to which URL. An SRI manifest closes that gap by writing the truth down once, at the only moment where it is unambiguous: immediately after the build, in the same job, from the same bytes that are about to ship.

Everything else in a release pipeline then becomes a lookup rather than a computation. The server that renders HTML reads the manifest to fill in integrity attributes. The deploy verification step reads it to confirm that what landed on the CDN is byte-identical to what the build produced. A reviewer reads it to see exactly which assets changed between two releases. Because every consumer draws on one file, they cannot disagree with each other, which is the failure mode you get when three separate tools each recompute hashes at three different points in time.

The digest itself is standard SRI: a raw hash, base64-encoded, prefixed with the algorithm name and a hyphen. If you are weighing algorithms, How to Calculate SHA-256 vs SHA-384 for SRI covers the trade-off; the encoding rules that trip up hand-rolled scripts are in Base64 Encoding Rules for SRI Hashes. The manifest adds no cryptography of its own — it is a transport format for values the browser already knows how to check.

Manifest generation pipeline The build step produces a dist directory, which is walked to collect JavaScript, CSS and WebAssembly files, hashed with SHA-384 and base64-encoded, sorted by path, then written to sri-manifest.json, uploaded with the upload-artifact action and attached to the release tag. build step npm run build walk dist/ .js .css .wasm hash each file SHA-384, base64 sort by path code point order write sri-manifest.json path, integrity, bytes actions/upload-artifact retained for review gh release upload attached to the tag

Canonical example: the workflow and the script it calls

Permalink to "Canonical example: the workflow and the script it calls"

The workflow runs on a published release so the tag already exists when the upload step runs. It builds, generates the manifest into the build directory, publishes it as a workflow artifact, and attaches the same file to the release.

# .github/workflows/sri-manifest.yml
name: SRI manifest

on:
  release:
    types: [published]
  workflow_dispatch:

permissions:
  contents: write

jobs:
  manifest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Generate SRI manifest
        run: node scripts/sri-manifest.mjs dist sri-manifest.json

      - name: Upload manifest artifact
        uses: actions/upload-artifact@v4
        with:
          name: sri-manifest-${{ github.sha }}
          path: dist/sri-manifest.json
          if-no-files-found: error
          retention-days: 90

      - name: Attach manifest to the release
        if: github.event_name == 'release'
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh release upload "${{ github.event.release.tag_name }}" dist/sri-manifest.json --clobber

The generator is deliberately dependency-free — it uses node:crypto, node:fs/promises and node:path, so it runs on the runner’s stock Node with no install step of its own.

// scripts/sri-manifest.mjs — usage: node scripts/sri-manifest.mjs <dir> <output-name>
import { createHash } from 'node:crypto';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { extname, join, relative, sep } from 'node:path';

const ROOT = process.argv[2] ?? 'dist';
const OUT = process.argv[3] ?? 'sri-manifest.json';
const HASHED = new Set(['.js', '.mjs', '.css', '.wasm']);

async function walk(dir, acc = []) {
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    const full = join(dir, entry.name);
    if (entry.isDirectory()) await walk(full, acc);
    else if (entry.isFile()) acc.push(full);
  }
  return acc;
}

const entries = [];

for (const full of await walk(ROOT)) {
  // POSIX-style key so Windows runners produce the same manifest as Linux ones.
  const key = relative(ROOT, full).split(sep).join('/');

  if (key === OUT) continue;              // never hash the manifest itself
  if (key.endsWith('.map')) continue;     // source maps are debug output
  if (!HASHED.has(extname(key))) continue;

  const bytes = await readFile(full);
  const digest = createHash('sha384').update(bytes).digest('base64');
  entries.push([key, { integrity: `sha384-${digest}`, bytes: bytes.byteLength }]);
}

// Code point sort: locale-independent, so two runners agree on the order.
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));

const manifest = {
  algorithm: 'sha384',
  commit: process.env.GITHUB_SHA ?? 'local',
  files: Object.fromEntries(entries),
};

await writeFile(join(ROOT, OUT), `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`${OUT}: ${entries.length} files hashed`);

Three details in that script carry all the reproducibility. Path keys are normalised to forward slashes so the manifest does not change shape when the job moves to a Windows runner. The sort compares raw strings rather than calling localeCompare, whose result depends on the runner’s locale data and would silently reorder entries between images. And the only volatile field is commit, which changes for a reason — deliberately leaving out a wall-clock timestamp means two builds of the same source produce byte-identical manifests, and a diff between releases shows only what actually moved.

The filter is the other half of correctness. Source maps are excluded because they are debug artifacts that a browser never subjects to an integrity check, and because many pipelines strip or relocate them at deploy time, which would turn a legitimate deploy into a manifest mismatch. The manifest is excluded from itself because including it is not merely wrong, it is impossible: writing the entry changes the bytes the entry describes.

File inclusion decision tree For each file in the build directory: files ending in .map are skipped, the manifest file itself is skipped, files without a .js, .css or .wasm extension are skipped, and everything else is hashed with SHA-384 and added as a manifest entry. for each file in dist/ filename ends in .map? yes skip: source maps excluded no is it sri-manifest.json? yes skip: never hash the manifest no extension .js, .css, .wasm? no skip: not a hashable asset yes compute SHA-384, add entry

A run against a typical build produces something like this:

{
  "algorithm": "sha384",
  "commit": "9f1c2ae5c0d34b7f8a11de6042c9b5e7a3f6812d",
  "files": {
    "assets/app-4f9c1b.js": {
      "integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC",
      "bytes": 128414
    },
    "assets/styles-7ad0e2.css": {
      "integrity": "sha384-Zenh87qX5JKa8SdBUqTIWvMPT7VRCB5oOx7MYqRRhpvTk0RcHDrxjc/UOFMOWJgw",
      "bytes": 19022
    },
    "assets/parser-1c88f0.wasm": {
      "integrity": "sha384-Q0MFy8Ap+K1SUZBIT4G8dh3Xj5W8ZLd7fJvGrJHi9bIuTOhw9zsKMfDl8XcJmKB8",
      "bytes": 402118
    }
  }
}

The byte count earns its place for two reasons. It is the cheapest possible pre-check during deploy verification: a HEAD request returning a Content-Length that disagrees with the recorded size tells you the object is wrong without downloading and hashing it, which matters when a manifest covers hundreds of files. It also makes release diffs legible to humans, because a reviewer reading two manifests side by side can see that a chunk grew by 40 KB and ask why, where two changed base64 strings say nothing at all. Keep the field advisory: the digest is the authority, and a size match never substitutes for a hash match.

Variants

Permalink to "Variants"

Consume it server-side to inject integrity attributes

Permalink to "Consume it server-side to inject integrity attributes"

The most valuable consumer is the process that renders HTML. Load the manifest once at boot, look each asset up by its manifest key, and fail closed when a key is missing rather than emitting a bare tag.

// server/assets.mjs
import { readFileSync } from 'node:fs';

const manifest = JSON.parse(readFileSync('./dist/sri-manifest.json', 'utf8'));

export function scriptTag(key) {
  const entry = manifest.files[key];
  if (!entry) throw new Error(`no manifest entry for ${key}`);
  return `<script src="/${key}" integrity="${entry.integrity}" crossorigin="anonymous" defer></script>`;
}

The rendered output carries both attributes the browser needs:

<script src="/assets/app-4f9c1b.js" integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" crossorigin="anonymous" defer></script>

The same lookup feeds a Link: <…>; rel=preload; as=script response header. The preload specification does define an integrity parameter for that header, but browser support for honouring it has been inconsistent, so treat header-level integrity as an extra and keep the attribute on the element as the enforcement point.

Verify a deployment against the manifest

Permalink to "Verify a deployment against the manifest"

The second consumer is a job that re-fetches what actually landed and compares digests. Feed it the manifest from the release and a base URL; any mismatch is a deploy that does not match the build.

#!/usr/bin/env bash
# scripts/verify-deploy.sh https://cdn.example.com dist/sri-manifest.json
set -euo pipefail
base="$1"; manifest="$2"; fail=0

while read -r path expected; do
  actual="sha384-$(curl -fsSL "$base/$path" | openssl dgst -sha384 -binary | openssl base64 -A)"
  if [ "$actual" != "$expected" ]; then
    printf 'MISMATCH %s\n  expected %s\n  actual   %s\n' "$path" "$expected" "$actual"
    fail=1
  fi
done < <(jq -r '.files | to_entries[] | "\(.key) \(.value.integrity)"' "$manifest")

[ "$fail" -eq 0 ] && echo "all files match the manifest"
exit "$fail"

The openssl dgst -sha384 -binary | openssl base64 -A pair is the same computation the Node script performs; Generating SRI Hashes with OpenSSL and shasum covers why the -binary and -A flags are both mandatory. Run this after the deploy but before traffic is switched over, so a mismatch rolls the release back rather than reaching users. Reading the manifest from the release asset rather than from the workspace is the important detail: it proves the deployed bytes match the release that was reviewed, not merely the bytes that happen to sit on the runner’s disk right now.

Extend the manifest to modules and WebAssembly

Permalink to "Extend the manifest to modules and WebAssembly"

Lazily loaded chunks and WebAssembly modules belong in the manifest even though nothing writes them into HTML at build time. A runtime loader can read the manifest to attach an integrity value to a chunk it injects, which is the pattern behind SRI for Lazy-Loaded Chunks, and a .wasm entry gives a host application the expected digest to check before instantiating a module, as described in Verifying WebAssembly Module Hashes. If the release also publishes build provenance, the manifest becomes the object that provenance attests to — see Verifying SLSA Build Provenance in CI for wiring that together.

Downstream consumption sequence The deploy verification job downloads the manifest from the release artifact, fetches each deployed asset and recomputes its SHA-384 digest, compares the returned bytes against the recorded value, and either promotes the release so the server injects integrity attributes or exits non-zero on a mismatch. release artifact deploy verify job app server, browser download sri-manifest.json GET asset, recompute hash bytes and sha384 digest promote, inject integrity mismatch: exit 1

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • An integrity value without crossorigin="anonymous" is worse than no attribute at all. For any cross-origin fetch the browser needs a CORS-enabled request to read the response body for hashing; without the attribute the response is opaque, the check cannot run, and the browser blocks the resource outright. Bake the attribute into the helper that renders the tag so it can never be forgotten, and confirm the origin returns a matching Access-Control-Allow-Origin — the interaction is covered in How CORS and crossorigin Affect SRI.

  • A timestamp in the manifest destroys the diff. Adding generatedAt feels like good hygiene, but it changes on every run, so every manifest differs from the last even when no asset moved. Record the commit SHA instead: it changes for a reason, and a reviewer comparing two releases then sees only real asset changes.

  • localeCompare is not a stable sort key. It depends on the ICU data present in the runtime, which differs between runner images and between a full and a small Node build. Two identical builds can emit the same entries in different orders, producing a noisy diff that looks like a change. Compare raw strings.

  • actions/upload-artifact@v4 rejects a duplicate artifact name within a run. Artifacts became immutable in v4, so a matrix job that uploads sri-manifest from every leg fails on the second upload. Include the matrix dimension in the name (sri-manifest-${{ matrix.target }}) or merge the manifests in a later job.

  • Post-build transforms invalidate the manifest silently. Any step that touches bytes after generation — an edge worker rewriting HTML, gzip recompression that changes the stored object, a CDN minifier — produces assets whose digests no longer match. Generate the manifest as the last build action, and gate on drift with the approach in the guide on failing a pipeline when hashes change unexpectedly.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the manifest is complete and well-formed

Permalink to "1. Confirm the manifest is complete and well-formed"
jq -r '.algorithm, (.files | length)' dist/sri-manifest.json

Expected output is the algorithm name followed by the file count, which should match the number of hashable assets in the build:

sha384
37

2. Confirm the manifest is reproducible

Permalink to "2. Confirm the manifest is reproducible"

Run the generator twice against the same build and compare:

node scripts/sri-manifest.mjs dist first.json
node scripts/sri-manifest.mjs dist second.json
diff dist/first.json dist/second.json && echo "reproducible"

diff must print nothing before reproducible. Any output here means a volatile field or an unstable sort slipped in — and it also confirms the second run did not pick up the first run’s manifest as an input.

3. Spot-check one entry against an independent tool

Permalink to "3. Spot-check one entry against an independent tool"
openssl dgst -sha384 -binary dist/assets/app-4f9c1b.js | openssl base64 -A

Prefix the printed string with sha384- and compare it to the integrity value for that key in the manifest. The two must be character-for-character identical, including any trailing = padding.

4. Confirm the release asset landed

Permalink to "4. Confirm the release asset landed"
gh release view v1.4.0 --json assets --jq '.assets[].name'

Expected output includes sri-manifest.json. If the step ran but the asset is absent, the workflow almost certainly lacked permissions: contents: write, which surfaces as an HTTP 403 in the step log rather than a hard failure of the upload command.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Why SHA-384 instead of SHA-256 for a build manifest?

All three SRI algorithms are accepted by browsers, so the choice is about margin rather than compatibility. SHA-384 is a truncated SHA-512 variant, which is faster than SHA-256 on 64-bit hardware and gives a larger security margin for a 21-byte-longer encoded value. Pick one algorithm for the whole manifest so consumers never have to branch on the prefix.

Should the manifest be committed to the repository?

No. It is a build output, and committing it invites merge conflicts on every rebuild. Publish it as a workflow artifact and a release asset instead. If you want change review, have a job download the previous release’s manifest and diff it against the new one, then post the differences on the pull request.

How do I keep the manifest out of its own hash set?

Exclude it by name during the directory walk, or write it outside the directory you hashed. Without that guard the second run of the script in a reused workspace picks up the manifest left by the first run, adds an entry for it, and produces output that can never be reproduced because writing the entry changes the file being hashed.

Does uploading the manifest as an artifact make it tamper-proof?

It does not. An artifact is authenticated storage, not a signature: anyone who can run a workflow with write permission on the repository can publish one. Treat the manifest as trusted only to the degree you trust the workflow that produced it, and pair it with build provenance if you need a verifiable link back to the source commit.

Can the manifest include images and fonts too?

You can hash them, and doing so is useful for deploy verification, but browsers only enforce integrity on script and link elements. An img or a font file fetched by CSS has no attribute to carry the value, so those entries are audit data rather than something the browser will check. Mark the difference in the manifest if consumers might confuse the two.

Permalink to "Related"

Related Articles

Failing CI on SRI Hash Drift
Verifying Deployed Assets Against a Hash Manifest
CI/CD Integrity Gates Asset Hashing & Dynamic Script…