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.
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.
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.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
An
integrityvalue withoutcrossorigin="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 matchingAccess-Control-Allow-Origin— the interaction is covered in How CORS and crossorigin Affect SRI. -
A timestamp in the manifest destroys the diff. Adding
generatedAtfeels 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. -
localeCompareis 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@v4rejects a duplicate artifact name within a run. Artifacts became immutable in v4, so a matrix job that uploadssri-manifestfrom 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.
Related
Permalink to "Related"- Failing CI on SRI Hash Drift — turning an unexpected manifest diff into a red build instead of a silent deploy
- Verifying Deployed Assets Against a Hash Manifest — the post-deploy job that re-fetches every URL and compares digests at scale
- Using the Import Map integrity Key — feeding manifest values into an import map so bare module specifiers carry integrity too