CI/CD Integrity Gates
Permalink to "CI/CD Integrity Gates"This workflow belongs to Asset Hashing & Dynamic Script Injection, and it exists because hand-maintained integrity attributes rot. Someone pastes a hash into a template, ships it, and six weeks later a bundler upgrade changes a single byte of whitespace in the output. The hash no longer matches, the browser blocks the script, and the failure surfaces as a blank page in production rather than a red check in a pull request. The opposite failure is quieter and worse: a developer hits a hash mismatch locally, deletes the integrity attribute to unblock themselves, and the protection is gone from that tag forever with nothing to notice its absence.
Both failures share a root cause. Integrity metadata is being treated as decorative markup that a human edits, instead of as a derived value that the build computes and the pipeline enforces. A CI/CD integrity gate closes that gap by making a single claim checkable on every commit: the digest recorded for every asset this application loads is exactly the digest of the bytes we intend to ship, and any deviation is either an approved change or a build failure. Once that claim is machine-checked, integrity attributes stop being a maintenance burden and start behaving like a lockfile — regenerated automatically, reviewed as a diff, and impossible to silently drop.
This page covers the whole chain: emitting a hash manifest as a build artifact, diffing it against the previous release to detect drift, failing the pipeline when a third-party digest changes without an approved bump, verifying that the deployed origin serves exactly the bytes that were hashed, and signing and archiving the manifest so an auditor can reconstruct what shipped. It also covers where each of those checks belongs, because a gate placed in the wrong stage either blocks the wrong people or proves nothing.
Prerequisites
Permalink to "Prerequisites"Conceptual Foundation: Integrity as a Build Invariant
Permalink to "Conceptual Foundation: Integrity as a Build Invariant"The W3C Subresource Integrity specification defines exactly one thing: how a user agent behaves when it fetches a resource whose element carries an integrity attribute. The attribute holds integrity metadata — one or more <algorithm>-<base64 digest> tokens separated by whitespace — and the user agent compares the digest of the response payload against that metadata before allowing the resource to be used. When several tokens are present, the user agent picks the strongest algorithm it supports and evaluates only those tokens. The specification says nothing whatsoever about how the metadata gets into the document. That is deliberate, and it is precisely the hole a pipeline gate fills.
Because the specification is silent on authoring, integrity metadata has no natural home in the way a lockfile does. Nothing forces it to be current. Nothing forces it to be present. The gate supplies both properties by introducing an intermediate artifact — a hash manifest — that is generated from the build output, compared against a reviewed baseline, and carried forward to the deployed environment for confirmation. The manifest is the object of record; the integrity attributes in the HTML are a projection of it.
A manifest is a small JSON document keyed by public URL path. Each entry records the integrity metadata, the byte length, and whether the asset is first-party (built here, from source in this repository) or third-party (fetched from a vendor origin at runtime). That first-party/third-party split is what makes the gate usable day to day, because the two halves have opposite expectations. A first-party digest is supposed to change on every meaningful commit; failing a build because app.js has a new hash would make the gate useless. A third-party digest is supposed to be immovable; if the bytes at https://cdn.example.com/[email protected]/widget.min.js change, something is wrong even when the change is benign, because that URL was pinned to a specific release.
Four stages of a normal pipeline map cleanly onto four gates, each answering a different question and each with a different appropriate blast radius:
- Pre-commit answers are the checked-in artifacts consistent with the working tree? It is fast, local, and advisory in spirit even when it exits non-zero, because a developer can always bypass a local hook.
- Pull request answers does this change alter a digest that a human should look at? This is where the third-party comparison lives, because a pull request is the only stage where a reviewer is present.
- Post-build answers what exactly did this build produce? It emits and signs the manifest. It cannot fail on drift, because at this point the change has already been reviewed and merged.
- Post-deploy answers does the origin actually serve those bytes? It is the only gate that tests the network path, the CDN configuration and the cache, and it is the only one whose failure should trigger a rollback rather than a code change.
Step 1 — Emit a Hash Manifest as a Build Artifact
Permalink to "Step 1 — Emit a Hash Manifest as a Build Artifact"The manifest generator walks the build output, hashes every file the browser will load with an integrity attribute, and merges in the pinned third-party entries from a committed policy file. Keep it dependency-free: it runs before your test job, in a minimal image, and every dependency it takes is another package that could tamper with the digests it computes.
// scripts/build-sri-manifest.mjs
// Emits sri-manifest.json describing every hashed asset in the build output.
import { createHash } from 'node:crypto';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
const DIST_DIR = 'dist/assets';
const PUBLIC_PREFIX = '/assets';
const APPROVALS = 'sri-approvals.json'; // committed, reviewed by humans
const sri = (bytes) =>
'sha384-' + createHash('sha384').update(bytes).digest('base64');
const files = (await readdir(DIST_DIR))
.filter((name) => /\.(js|mjs|css)$/.test(name))
.sort();
const assets = {};
for (const name of files) {
const bytes = await readFile(path.join(DIST_DIR, name));
assets[`${PUBLIC_PREFIX}/${name}`] = {
integrity: sri(bytes),
bytes: bytes.length,
origin: 'first-party'
};
}
// Third-party entries are policy, not output: copy them verbatim.
const approvals = JSON.parse(await readFile(APPROVALS, 'utf8'));
for (const [url, entry] of Object.entries(approvals.thirdParty ?? {})) {
assets[url] = { ...entry, origin: 'third-party' };
}
const manifest = {
schema: 'sri-manifest/1',
algorithm: 'sha384',
commit: process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? 'local',
generatedAt: new Date().toISOString(),
assets
};
await writeFile('sri-manifest.json', JSON.stringify(manifest, null, 2) + '\n');
console.log(
`sri-manifest: ${Object.keys(assets).length} assets ` +
`(${files.length} first-party, ${Object.keys(approvals.thirdParty ?? {}).length} third-party)`
);
The committed approvals file is deliberately small and boring. It is the only place a third-party digest can legitimately change, and every change to it shows up as a reviewable diff:
{
"thirdParty": {
"https://cdn.example.com/[email protected]/analytics.min.js": {
"integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC",
"bytes": 41203,
"approvedBy": "sec-review-2148",
"approvedOn": "2026-07-14"
}
}
}
Verification signal. The step is working when it prints a count and the file exists with a stable digest across two consecutive builds of the same commit:
sri-manifest: 9 assets (8 first-party, 1 third-party)
Run it twice on an unchanged tree and compare. If sha384sum sri-manifest.json differs between runs for the same commit, your bundler is non-deterministic — usually a timestamp, an absolute path, or an unsorted module map baked into the output. Fix that first, because every downstream gate assumes reproducibility. Bundler-specific configuration for stable, hashed output is covered in Automating Hash Generation in Webpack 5 and Generating SRI Hashes in Vite.
The manifest is also what your template layer reads, so the markup never contains a literal digest a human typed:
<script
src="/assets/app.9f1c4b2e.js"
integrity="sha384-r0OTAWQb1c2t2X1BLXbrxHmp9Yk7CSaVI9v4RG0aQCVDN6EX8UPFPPtXeAOgKKQ7"
crossorigin="anonymous"
defer></script>
Step 2 — Diff the Manifest Against the Previous Release
Permalink to "Step 2 — Diff the Manifest Against the Previous Release"A manifest on its own proves nothing. The value appears when you compare two of them. The comparison is not a plain object diff, because the three categories of change carry completely different meanings and only one of them is an incident.
Fetch the baseline manifest — from the previous successful build’s artifacts, from the deployed environment, or from the merge base of the pull request — and classify each key:
// scripts/diff-sri-manifest.mjs
// Usage: node scripts/diff-sri-manifest.mjs baseline.json sri-manifest.json
import { readFile } from 'node:fs/promises';
const [, , basePath, headPath] = process.argv;
const base = JSON.parse(await readFile(basePath, 'utf8'));
const head = JSON.parse(await readFile(headPath, 'utf8'));
const keys = new Set([...Object.keys(base.assets), ...Object.keys(head.assets)]);
const changes = { added: [], removed: [], firstPartyDrift: [], thirdPartyDrift: [] };
for (const key of [...keys].sort()) {
const before = base.assets[key];
const after = head.assets[key];
if (!before) { changes.added.push(key); continue; }
if (!after) { changes.removed.push(key); continue; }
if (before.integrity === after.integrity) continue;
const record = { url: key, before: before.integrity, after: after.integrity };
if (after.origin === 'third-party') changes.thirdPartyDrift.push(record);
else changes.firstPartyDrift.push(record);
}
for (const [kind, list] of Object.entries(changes)) {
for (const item of list) {
console.log(`${kind.padEnd(16)} ${typeof item === 'string' ? item : item.url}`);
}
}
if (changes.thirdPartyDrift.length > 0) {
console.error(
`\nBLOCKED: ${changes.thirdPartyDrift.length} third-party digest(s) changed ` +
`without a matching entry in sri-approvals.json.`
);
process.exit(1);
}
console.log('\nOK: no unapproved third-party digest changes.');
Verification signal. On a normal feature branch the output is a short list of first-party drift and a clean exit:
firstPartyDrift /assets/app.9f1c4b2e.js
firstPartyDrift /assets/vendor.4d81a03f.js
added /assets/checkout.ac0921bb.js
OK: no unapproved third-party digest changes.
The classification logic is what makes this liveable, and it is worth stating as an explicit decision procedure. Only one path through it ends in a failed build.
Note the shape of the third branch. The question is not “did the digest change since last time” — a vendor could change the file twice and the second build would compare against the already-tampered first. The question is “does the digest match the value a human approved in a reviewed commit”. Comparing against a committed approvals file rather than against the previous build is what stops drift from ratcheting silently forward. Continuous monitoring of vendor-hosted files, independent of your own release cadence, is covered in Detecting Changes in Third-Party Scripts.
Step 3 — Fail the Pipeline on Unapproved Digest Changes
Permalink to "Step 3 — Fail the Pipeline on Unapproved Digest Changes"Wire the diff into a required status check. On GitHub Actions the pull request job needs the merge base, so check out with enough history and generate the baseline from the base ref rather than downloading it:
# .github/workflows/sri-gate.yml
name: SRI Integrity Gate
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
jobs:
sri-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Build and hash the head commit
run: |
npm run build
node scripts/build-sri-manifest.mjs
mv sri-manifest.json head-manifest.json
- name: Build and hash the base commit
if: github.event_name == 'pull_request'
run: |
git worktree add ../base "${{ github.event.pull_request.base.sha }}"
cd ../base
npm ci --ignore-scripts
npm run build
node scripts/build-sri-manifest.mjs
mv sri-manifest.json "$GITHUB_WORKSPACE/base-manifest.json"
- name: Diff manifests
if: github.event_name == 'pull_request'
run: node scripts/diff-sri-manifest.mjs base-manifest.json head-manifest.json
- name: Upload manifest
uses: actions/upload-artifact@v4
with:
name: sri-manifest
path: head-manifest.json
retention-days: 90
The GitLab equivalent uses rules to separate merge-request behaviour from branch behaviour, and artifacts with an explicit expiry so the manifest survives long enough to be compared and audited:
# .gitlab-ci.yml (excerpt)
stages: [build, verify]
sri:manifest:
stage: build
image: node:22
script:
- npm ci --ignore-scripts
- npm run build
- node scripts/build-sri-manifest.mjs
artifacts:
paths: [sri-manifest.json]
expire_in: 90 days
sri:diff:
stage: verify
image: node:22
needs: ["sri:manifest"]
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
- git show "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME:sri-approvals.json" > base-approvals.json
- node scripts/diff-sri-manifest.mjs base-approvals.json sri-manifest.json
Verification signal. Open a pull request that edits a pinned third-party digest without touching the approvals file. The job should end with your own message followed by the runner’s exit line — Error: Process completed with exit code 1. on GitHub Actions, ERROR: Job failed: exit code 1 on GitLab. Then make the same change with the approvals update and confirm the job goes green. A gate you have never seen fail is a gate you cannot trust.
Two configuration details decide whether this actually blocks anything. On GitHub the job must be added under branch protection as a required status check, otherwise a red check is merely decorative. On GitLab the equivalent is enabling “Pipelines must succeed” on the target branch. And in both systems, avoid continue-on-error or allow_failure: true on the diff job; a soft-failing integrity gate trains people to ignore it within about two sprints.
Step 4 — Verify the Deployed Origin Serves the Hashed Bytes
Permalink to "Step 4 — Verify the Deployed Origin Serves the Hashed Bytes"Everything up to here happens inside the build. It proves what the build produced, not what a browser will receive. Between the two sit an upload, a CDN, an edge cache, possibly a transform or minification layer, and a routing configuration — all of which can change the bytes. The post-deploy check closes that gap by fetching each asset over the real network path and recomputing its digest.
#!/usr/bin/env bash
# scripts/verify-deployed-sri.sh <base-url> [manifest]
set -euo pipefail
BASE_URL="${1:?usage: verify-deployed-sri.sh https://www.example.com [manifest]}"
MANIFEST="${2:-sri-manifest.json}"
failures=0
while IFS=$'\t' read -r url expected; do
# Absolute URLs are third-party; relative paths hang off the deployed origin.
case "$url" in
http*) target="$url" ;;
*) target="${BASE_URL}${url}" ;;
esac
actual="sha384-$(curl -sSfL --compressed --retry 3 --retry-delay 5 "$target" \
| openssl dgst -sha384 -binary \
| openssl base64 -A)"
if [ "$actual" = "$expected" ]; then
printf 'ok %s\n' "$target"
else
printf 'FAIL %s\n expected %s\n actual %s\n' \
"$target" "$expected" "$actual"
failures=$((failures + 1))
fi
done < <(jq -r '.assets | to_entries[] | "\(.key)\t\(.value.integrity)"' "$MANIFEST")
if [ "$failures" -gt 0 ]; then
echo "integrity verification failed for ${failures} asset(s)" >&2
exit 1
fi
echo "all assets match the manifest"
Three details in that script matter more than they look. --compressed makes curl advertise the content encodings it understands and decode the response before it reaches the pipe, which is required because integrity is evaluated against the decoded payload, not the wire bytes. -f (inside -sSfL) turns an HTTP error status into a non-zero exit instead of hashing an error page into a false mismatch. And --retry absorbs the ordinary propagation delay after a deploy without hiding a real mismatch, because a genuinely wrong digest fails identically on every attempt.
Verification signal. A healthy run prints one ok line per asset and the final summary:
ok https://www.example.com/assets/app.9f1c4b2e.js
ok https://www.example.com/assets/main.7b30c9d1.css
ok https://cdn.example.com/[email protected]/analytics.min.js
all assets match the manifest
Run this as a smoke test gated on the deploy job, against the real hostname rather than an origin bypass. Hitting the origin directly skips exactly the layer most likely to rewrite bytes. If your edge does perform transforms, the interaction between them and integrity metadata is the subject of CDN Trust Mapping & Routing; the short version is that any transform applied after hashing will break integrity, and the fix is to hash after the transform or disable it for hashed paths.
Step 5 — Sign and Archive the Manifest as Audit Evidence
Permalink to "Step 5 — Sign and Archive the Manifest as Audit Evidence"A manifest that lives only in a build log is evidence of nothing, because build logs are mutable, expire, and are produced by the same system whose behaviour you are trying to attest. Signing the manifest with a workload identity — rather than a long-lived key stored as a secret — turns it into something an auditor can check months later without trusting your CI configuration.
On GitHub, the built-in attestation action does this with an OIDC token minted for the specific workflow run, so there is no key to leak:
attest-manifest:
runs-on: ubuntu-latest
needs: [sri-diff]
if: github.event_name == 'push'
permissions:
contents: read
id-token: write
attestations: write
steps:
- uses: actions/download-artifact@v4
with:
name: sri-manifest
- uses: actions/attest-build-provenance@v2
with:
subject-path: head-manifest.json
Verification is a single command, and it works from any machine with the GitHub CLI — no access to the pipeline required:
gh attestation verify head-manifest.json --repo my-org/my-app
If you are not on GitHub, or you want an artifact that outlives the platform, keyless signing with Sigstore produces a portable bundle:
# Sign in CI (ambient OIDC credentials, no key material stored anywhere)
cosign sign-blob --yes sri-manifest.json --bundle sri-manifest.cosign.bundle
# Verify later, from anywhere
cosign verify-blob sri-manifest.json \
--bundle sri-manifest.cosign.bundle \
--certificate-identity-regexp '^https://github\.com/my-org/my-app/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Verification signal. gh attestation verify prints a line beginning Loaded digest sha256:... followed by a confirmation that the attestation was verified against the repository’s identity, and exits zero. cosign verify-blob prints Verified OK. Store the signed bundle wherever your organisation keeps release evidence — object storage with versioning and object lock is a better home than the CI artifact store, whose retention is measured in days and whose contents can be deleted by anyone with write access to the repository.
For compliance work the manifest answers a question that comes up constantly and is otherwise painful: which exact scripts were loaded by the payment page on a given date? A signed, dated manifest per release answers it directly, with digests, and without archaeology through deployment logs.
Configuration Reference
Permalink to "Configuration Reference"| Gate | Trigger | Failure mode | Blocking? |
|---|---|---|---|
sri:manifest pre-commit hook |
git commit with staged files under dist/ |
Committed manifest does not match the working tree | Yes, locally — bypassable with --no-verify by design |
sri:diff review check |
pull_request / merge_request_event targeting main |
A third-party digest changed with no matching approvals entry | Yes — configure as a required status check |
sri:manifest release build |
push to main or a tag |
Unreadable asset, non-deterministic output, missing approvals file | Yes — fails the release job before deploy |
sri:attest signing job |
Success of the release build, push events only |
Missing id-token: write or attestations: write permission |
Yes on release branches; skip entirely on pull requests |
sri:verify-deployed smoke test |
Success of the deploy job, against the public hostname | Live digest differs from the manifest, or the asset 404s | Yes — should trigger an automatic rollback |
sri:watch vendor monitor |
Scheduled cron, typically hourly |
Upstream digest changed outside a release | No — opens an issue and pages if the asset is on a payment page |
Two entries deserve a note. The pre-commit hook is intentionally bypassable: local hooks are a convenience for the developer, not a control, and treating them as a control produces a false sense of coverage while pushing people toward --no-verify habits. The scheduled watcher is intentionally non-blocking: failing a pipeline nobody triggered generates noise at three in the morning and teaches the on-call engineer to mute the channel. Blocking behaviour belongs where a human is already waiting.
Gate Coverage: What Each Stage Can Prove
Permalink to "Gate Coverage: What Each Stage Can Prove"Each gate answers a narrow question, and the value of the arrangement comes from the overlap rather than from any single check. Reading the coverage as a grid also makes the limits obvious — in particular that no column of gates covers the runner itself.
The “partial” cells are worth reading carefully. The post-deploy check catches first-party drift only for assets that are actually referenced by the pages it crawls; a lazily loaded chunk that no smoke-tested route imports is never fetched and never verified. Likewise the post-build signing job proves that a third-party digest matched at build time, which is a weaker claim than the pull request gate’s — the vendor could change the file five minutes later, which is exactly why the scheduled watcher exists as a fifth, non-blocking check.
Integration with Adjacent Tooling
Permalink to "Integration with Adjacent Tooling"Runtime-injected scripts. Assets that never appear in server-rendered HTML — tag managers, feature-flag loaders, anything appended by JavaScript — are invisible to a manifest generated from build output alone. Feed the manifest to the loader at runtime and have it apply the recorded digest to each element it creates, a pattern covered in Adding Integrity to Runtime-Injected Scripts. The gate then covers dynamic loads for free, because they read the same source of truth.
Violation telemetry as the outermost gate. Even a perfect pipeline is a point-in-time check. Browsers report integrity failures through the reporting endpoint configured by your Content Security Policy, which means real users become the last line of detection for a mismatch that appears after deploy — a cache poisoning event, a bad edge transform, a vendor swapping a file. Routing those reports into an alert, as described in Alerting on SRI Failures from CSP Reports, closes the loop between the pipeline and production.
Release evidence. The signed manifest slots naturally alongside your SBOM in the same release bundle. The SBOM says which components went into the build; the manifest says which exact bytes reached the browser. Auditors ask for both, and producing them from the same pipeline run means the two can never disagree about what shipped.
Troubleshooting
Permalink to "Troubleshooting"Error: Unable to find any artifacts for the associated workflow
Raised by actions/download-artifact@v4 when no artifact matches the requested name in the current run. Two causes dominate: the upload job was skipped by an if: condition (common on the first run of a new workflow, or on fork pull requests where the signing job is deliberately skipped), or the download job is running in a different workflow run than the upload. Artifacts in v4 are scoped to a single run by default. Either add needs: so the jobs share a run, or reconstruct the baseline by building the base commit as shown in Step 3.
Error: Resource not accessible by integration
The workflow token lacks a permission the attestation step needs. actions/attest-build-provenance requires id-token: write and attestations: write, and top-level permissions: contents: read silently strips both. Add the block at job level rather than editing the top-level one, so only the signing job holds the elevated token. This error also appears when the repository’s default workflow permissions are set to read-only at the organisation level, which no amount of workflow YAML can override.
WARNING: sri-manifest.json: no matching files. Ensure that the artifact path is relative to the working directory
GitLab’s artifact uploader resolves artifacts:paths relative to $CI_PROJECT_DIR, not to wherever your script happened to cd. If the manifest is written inside a subdirectory, either write it to the project root or set the path to packages/web/sri-manifest.json. The job still reports success with only a warning, so the failure surfaces one stage later as a missing dependency — set artifacts:expire_in and check the job’s artifact browser after the first run rather than assuming it worked.
curl: (22) The requested URL returned error: 404 during post-deploy verification
The asset in the manifest is not at the URL the manifest claims. Ordinarily this means the deploy uploaded to a different prefix than the build assumed (/assets/ versus /static/assets/), or the CDN has not yet purged an index that points at the previous filenames. Distinguish the two by requesting the same path with a cache-busting query string: if that succeeds, it is propagation and the retry flags should have absorbed it; if it 404s too, your public path configuration and your manifest disagree.
shasum: WARNING: 1 computed checksum did NOT match
Emitted by shasum -c when verifying a checksum file rather than an SRI manifest, and almost always a content-encoding problem in the fetch step rather than a genuine tampering event. Confirm by comparing byte counts: curl -sSfL --compressed "$URL" | wc -c against the bytes field in the manifest. A large discrepancy means you hashed a compressed body; a discrepancy of a few bytes means a real content change, and a discrepancy of exactly the size of an error page means the fetch failed without -f.
Failed to find a valid digest in the 'integrity' attribute for resource 'https://cdn.example.com/analytics.min.js' with computed SHA-384 integrity 'kZ0R…'. The resource has been blocked.
The browser-side counterpart, seen in the console when the gate did not catch a mismatch — typically because the URL is not in the manifest at all, or because the response was served without permissive CORS headers so the payload was opaque. Cross-origin subresources must be requested with crossorigin="anonymous" and answered with an Access-Control-Allow-Origin header, or integrity cannot be evaluated. The full diagnostic sequence is in Debugging SRI Hash Mismatch Errors.
Security Boundary Note
Permalink to "Security Boundary Note"Integrity gates protect the correspondence between reviewed source and shipped bytes across the deployment path. Every gate on this page computes a digest, compares it to a recorded value, and stops something when the two disagree. That is a genuinely useful property, and it is also a narrow one. These gates do not protect against:
- Compromise of the build machine itself. This is the important one. The manifest is generated on the same runner that produced the assets, from the same filesystem, by a process with the same privileges. An attacker with code execution in the runner — through a malicious dependency, a poisoned base image, a compromised action, or a leaked deploy token — can modify the bundle and the manifest together, sign the result with the runner’s own legitimate identity, and every gate here will pass. Hashing cannot detect tampering by the entity doing the hashing. Detecting that class of attack requires attestation about how the artifact was built, produced by a control plane the workload cannot influence: hardened, ephemeral runners, a signing identity bound to a specific workflow definition rather than to a secret any job can read, and verification of that provenance at the consuming end. Provenance Verification Workflows covers the verification side of that boundary.
- Malicious code that is legitimately reviewed and merged. A digest confirms that bytes are the intended ones. It has no opinion about whether the intent was good. A backdoor introduced in a pull request that a human approved will hash correctly at every stage.
- Changes made after the smoke test passes. Post-deploy verification is a sample at one instant. Cache poisoning, an edge configuration change, or a vendor swapping a file an hour after deploy all happen outside the pipeline’s observation window. Only browser-side enforcement plus violation reporting covers that interval continuously.
- Assets the manifest does not enumerate. Images, iframes, fonts loaded via CSS
@font-face, and anything fetched by a third-party script after it loads are outside the manifest by construction. Integrity metadata is honoured onscriptandlinkelements, not on arbitrary subresources, and a gate can only check what it knows exists. - Availability. A gate that blocks a bad release also blocks a good one when the vendor changes a file at an inconvenient moment. Plan the override path in advance — a documented, logged, time-limited approval — because the alternative is an engineer disabling the check under pressure at the worst possible time.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Should the SRI manifest be committed to the repository or only produced as a build artifact?
Commit the third-party section, which is a policy document listing the digests you have approved, and produce the first-party section fresh on every build. First-party digests change on every content edit, so committing them creates constant merge noise. The committed half is what the pull request gate diffs; the generated half is what the post-deploy check verifies.
How do I let an approved third-party version bump through the gate without disabling it?
Change the pinned digest in the committed approvals file in the same pull request that changes the tag or lockfile entry. The gate compares the built manifest against that file, not against the previous build, so an intentional bump reviewed by a human passes on the first run while an out-of-band CDN change still fails.
Does a post-deploy hash check work when the CDN serves compressed responses?
Yes, provided you hash the decoded body. Integrity is checked against the response payload after content decoding, so a gzip or Brotli response must be decompressed before hashing. Passing --compressed to curl makes it advertise the encodings it can handle and decode the body for you; Brotli support depends on how libcurl was built.
Where should the gate run for pull requests from forks?
Run the diff and build gates on the pull_request event, which has a read-only token and no access to secrets. Keep signing, artifact attestation and the deploy verification on push events for trusted branches. A fork must never be able to mint a signature or write to the approvals file that the gate reads.
Can a CI gate detect that the build machine itself was compromised?
No. Every gate described here computes hashes on the same machine that produced the bytes, so an attacker with code execution in the runner can tamper with the output and the manifest together and both will agree. Detecting that class of attack requires provenance attestation signed by an identity the runner cannot forge.
Is SHA-384 required, or can the manifest record SHA-256?
All three of SHA-256, SHA-384 and SHA-512 are valid integrity algorithms and browsers accept any of them. SHA-384 is the practical default because it is the strongest option with universal support and produces shorter metadata than SHA-512. When several digests are present the user agent evaluates the strongest algorithm it recognises.
What should the gate do when a third-party digest changes at three in the morning?
A scheduled watcher should open an issue rather than fail a build nobody triggered. Reserve hard failures for pipelines a human is waiting on. The watcher records the old and new digests, links the vendor changelog if one exists, and leaves the approvals file untouched so the next real build still blocks until someone reviews the change.
Related
Permalink to "Related"- Failing CI on SRI Hash Drift — the exit-code logic and reviewer messaging for the blocking diff step in detail
- Generating an SRI Manifest in GitHub Actions — a complete workflow file, including caching and artifact retention settings
- Verifying Deployed Assets Against a Hash Manifest — the post-deploy smoke test expanded to crawl routes and handle preview environments
- Verifying SLSA Build Provenance in CI — the control that covers the boundary a hash gate cannot: a compromised builder