CI/CD Asset Pipeline Integration for Fingerprinted Static Assets
Getting fingerprinted assets into production without stale HTML references or cache poisoning requires a precise deployment sequence that build tools alone cannot enforce — your CI/CD pipeline must coordinate hashing, uploading, and atomic origin swaps as a single reproducible unit.
When to Use This Approach vs. Alternatives
This pipeline pattern — hash at build time, upload assets before HTML, deploy HTML last — is the right choice when:
- You control the build environment and want deterministic, reproducible outputs (see deterministic build outputs)
- Your CDN serves assets from S3, R2, or a similar object store where you control
Cache-Controlheaders per object - You need zero-downtime deploys where no user ever receives HTML referencing assets that do not yet exist on the CDN
- You want a hash manifest artifact as an audit trail or for rollback automation
Use a simpler approach (e.g., Vercel, Netlify one-command deploy) when:
- You do not need fine-grained cache header control per file
- Your host handles atomic deploys internally and does not expose upload order
- You are deploying a prototype or internal tool where stale-asset windows are acceptable
The critical differentiator is upload order. A naive aws s3 sync dist/ s3://bucket/ uploads files in arbitrary order. If index.html lands before main.a1b2c3d4.js, users who receive the new HTML immediately hit a 403 or 404 for the new asset. The pattern in this page eliminates that race by uploading assets first and HTML last.
Prerequisites
| Requirement | Minimum Version / Detail |
|---|---|
| Node.js | 20.x LTS (matches GitHub Actions node-version: '20') |
| npm | 10.x (bundled with Node 20) |
| Vite or Webpack | Vite 5.x / Webpack 5.x with content hashing enabled |
| AWS CLI | v2.x for s3 sync and cloudfront create-invalidation |
| Wrangler | 3.x for Cloudflare Pages / R2 deploys |
| GitHub Actions | Current runner: ubuntu-24.04 |
actions/cache |
v4 |
actions/upload-artifact |
v4 |
actions/download-artifact |
v4 |
Your bundler must emit a hash manifest. In Vite asset pipeline configuration, the vite-plugin-manifest (or Vite’s built-in build.manifest: true) produces dist/.vite/manifest.json. In Webpack output hashing setup, use WebpackManifestPlugin from webpack-manifest-plugin. Both produce a JSON map of logical chunk names to hashed filenames.
Configuration Reference
| Key / Flag | Type | Default | Effect |
|---|---|---|---|
build.manifest (Vite) |
boolean | false |
Emits .vite/manifest.json mapping logical names to hashed filenames |
output.filename [contenthash:8] (Webpack) |
string template | [name].js |
Appends 8-hex-char content hash to JS output files |
--cache-control (aws s3 sync) |
string | (none) | Sets Cache-Control header on uploaded objects |
--exclude (aws s3 sync) |
glob | (none) | Skips matching keys; used to exclude *.html on first pass |
--metadata-directive REPLACE (aws s3 cp) |
enum | COPY |
Forces S3 to write new metadata even when content is unchanged |
concurrency.group (GitHub Actions) |
string | (none) | Serialises concurrent workflow runs for the same ref |
concurrency.cancel-in-progress |
boolean | false |
Cancels a queued run when a newer run starts for the same group |
retention-days (upload-artifact) |
integer | 90 | How long the manifest artifact is kept in GitHub |
--commit-dirty (wrangler pages deploy) |
boolean | false |
Allows deploy from an unclean git working tree inside CI |
Step-by-Step Implementation
Step 1 — Configure Your Bundler for Reproducible Hashes
Content hashes must derive from file content only, not from timestamps or build-order IDs. The content hashing vs. semantic versioning guide explains why content-only hashes are the correct primitive here. 8-character hex hashes are sufficient for most projects; use 12–16 characters when you have a monorepo with thousands of chunks and need stronger collision resistance.
Vite (vite.config.ts):
import { defineConfig } from 'vite';
export default defineConfig({
build: {
manifest: true,
rollupOptions: {
output: {
entryFileNames: 'assets/[name]-[hash:8].js',
chunkFileNames: 'assets/[name]-[hash:8].js',
assetFileNames: 'assets/[name]-[hash:8][extname]',
},
},
},
});
Webpack (webpack.config.js):
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
module.exports = {
mode: 'production',
output: {
filename: 'assets/[name]-[contenthash:8].js',
chunkFilename: 'assets/[name]-[contenthash:8].js',
assetModuleFilename: 'assets/[name]-[contenthash:8][ext]',
clean: true,
},
plugins: [
new WebpackManifestPlugin({
fileName: 'asset-manifest.json',
}),
],
};
Step 2 — GitHub Actions Workflow: S3 + CloudFront
Save this file at .github/workflows/deploy-s3.yml. The workflow splits into two jobs: build and deploy. Separating them lets the deploy job run on a minimal runner without Node.js installed, and makes the artifact handoff explicit.
name: Deploy to S3 + CloudFront
on:
push:
branches:
- main
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NODE_ENV: production
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
- name: Upload manifest artifact
uses: actions/upload-artifact@v4
with:
name: asset-manifest
path: dist/.vite/manifest.json
retention-days: 30
deploy:
needs: build
runs-on: ubuntu-24.04
environment: production
steps:
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
# Upload all fingerprinted assets FIRST with immutable headers.
# Exclude *.html so no user can receive new HTML before new assets exist.
- name: Upload hashed assets to S3 (immutable)
run: |
aws s3 sync dist/ s3://${{ secrets.S3_BUCKET }}/ \
--exclude "*.html" \
--cache-control "public, max-age=31536000, immutable" \
--metadata-directive REPLACE \
--delete
# Upload HTML LAST with must-revalidate so browsers always check freshness.
# At this point all assets the new HTML references already exist on the CDN.
- name: Upload HTML to S3 (no-cache)
run: |
aws s3 sync dist/ s3://${{ secrets.S3_BUCKET }}/ \
--include "*.html" \
--exclude "*" \
--cache-control "no-cache, must-revalidate" \
--metadata-directive REPLACE
# Invalidate only HTML. Fingerprinted assets are immutable and must NOT be
# invalidated — doing so evicts cold-cached assets and spikes origin load.
- name: Invalidate CloudFront HTML only
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/index.html"
- name: Download manifest artifact (for audit log)
uses: actions/download-artifact@v4
with:
name: asset-manifest
path: manifests/
- name: Print deployed manifest
run: cat manifests/manifest.json
The concurrency.cancel-in-progress: false setting is intentional. When two pushes land in quick succession, cancelling the in-flight deploy could leave the bucket in a half-uploaded state. Instead, the second run queues and executes cleanly after the first finishes.
Step 3 — GitHub Actions Workflow: Cloudflare Pages + R2
For Cloudflare-hosted projects, Wrangler’s pages deploy command handles the atomic swap internally. Static assets uploaded to a Pages deployment are served from Cloudflare’s edge before the deployment is activated, which means the upload-order concern is handled by the platform. You still need to manage the manifest artifact for rollback and audit purposes.
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
concurrency:
group: deploy-production-cf
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NODE_ENV: production
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist-cf
path: dist/
retention-days: 7
- name: Upload manifest artifact
uses: actions/upload-artifact@v4
with:
name: asset-manifest-cf
path: dist/.vite/manifest.json
retention-days: 30
deploy:
needs: build
runs-on: ubuntu-24.04
environment: production
steps:
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist-cf
path: dist/
- name: Install Wrangler
run: npm install -g wrangler@3
# wrangler pages deploy activates the new deployment atomically after
# all assets have been uploaded to Cloudflare's edge network.
# No manual HTML invalidation is needed — Pages handles cache routing.
- name: Deploy to Cloudflare Pages
run: |
wrangler pages deploy dist/ \
--project-name ${{ secrets.CF_PAGES_PROJECT }} \
--commit-dirty=true \
--branch main
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
- name: Download manifest artifact (for audit log)
uses: actions/download-artifact@v4
with:
name: asset-manifest-cf
path: manifests/
- name: Print deployed manifest
run: cat manifests/manifest.json
On Cloudflare Pages, there is no equivalent of aws cloudfront create-invalidation because the platform performs an atomic deployment switch. The old deployment continues serving traffic until the new one is fully staged, then the routing rules flip in one operation. You can read more about targeted purge strategies in the Cloudflare cache rules and purge reference.
Step 4 — Manifest Artifact Handoff Pattern
The artifact handoff between jobs is what makes rollback feasible. The build job uploads manifest.json as a named artifact. The deploy job downloads it, uses it (or logs it), and retains it for post-deploy verification and the rolling back fingerprinted assets in CI/CD workflow.
In a downstream rollback job, you would download a previous run’s manifest artifact using the GitHub API:
# List recent workflow runs and find the run ID to roll back to
gh run list --workflow=deploy-s3.yml --limit 10
# Download the manifest artifact from a specific run
gh run download <RUN_ID> --name asset-manifest --dir rollback-manifests/
# Inspect the manifest to find previous hashed filenames
cat rollback-manifests/manifest.json
Storing manifests with a 30-day retention gives you enough history to roll back any deploy within a sprint cycle without bloating GitHub artifact storage.
SVG Diagram: CI/CD Pipeline Stages
Verification
Run these commands after a deploy to confirm headers and manifest integrity.
# 1. Verify a hashed asset has the immutable Cache-Control header
curl -sI "https://assets.example.com/assets/main-a1b2c3d4.js" \
| grep -i cache-control
# Expected: cache-control: public, max-age=31536000, immutable
# 2. Verify index.html has no-cache
curl -sI "https://assets.example.com/index.html" \
| grep -i cache-control
# Expected: cache-control: no-cache, must-revalidate
# 3. Confirm the manifest artifact was saved in the latest workflow run
gh run list --workflow=deploy-s3.yml --limit 1 --json databaseId --jq '.[0].databaseId'
# Then download and inspect:
gh run download <RUN_ID> --name asset-manifest --dir /tmp/manifest-check/
cat /tmp/manifest-check/manifest.json | python3 -m json.tool | head -30
# 4. Confirm no extra CloudFront invalidations were created (only /index.html)
aws cloudfront list-invalidations \
--distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
--query "InvalidationList.Items[0].{Status:Status,Paths:InvalidationBatch.Paths}" \
--output json
# 5. For Cloudflare Pages: confirm deployment is live
wrangler pages deployment list --project-name my-project | head -5
Edge Cases and Known Issues
Stale assets from partial uploads. If the Upload hashed assets step succeeds but the HTML upload fails, you end up with new assets on S3 but the old index.html still pointing to old hashes. This is safe — users continue receiving the old HTML with the old assets, which are still in the bucket. Re-run the workflow to complete the deploy.
--delete flag removes old assets too quickly. The aws s3 sync --delete flag removes any S3 key not present in the local dist/ directory. If users are still on the old deploy when --delete runs, their browsers will request old hashed filenames that no longer exist. Mitigate this by either omitting --delete and running a scheduled cleanup job, or by adding a grace period of at least the max-age of your CDN edge cache (typically 5–30 minutes between old HTML eviction and old asset deletion). For a comprehensive cleanup strategy, see the rolling back fingerprinted assets in CI/CD guide.
Concurrency and overlapping deploys. The concurrency.cancel-in-progress: false setting queues overlapping runs rather than cancelling them. With cancel-in-progress: true, a cancelled deploy could leave S3 in a mixed state (some new assets, old HTML). Never use cancel-in-progress: true for the deploy job.
CloudFront invalidation path case sensitivity. CloudFront paths are case-sensitive. If your server renders /Index.html (capital I), the invalidation for /index.html will not clear it. Use the exact path as it appears in the S3 key. See AWS CloudFront invalidation for batching multiple paths.
Wrangler --commit-dirty in a clean checkout. wrangler pages deploy reads git log to attach a commit hash to the deployment. In CI, the checkout is clean, but if you’re building inside a subdirectory or monorepo, Wrangler may report a dirty tree because build artifacts are untracked. The --commit-dirty=true flag suppresses this check. It does not affect the deployed content.
Manifest path differs between Vite and Webpack. Vite 5.x emits the manifest to dist/.vite/manifest.json. Vite 4.x emitted it to dist/manifest.json. Webpack with WebpackManifestPlugin emits to dist/asset-manifest.json by default. Update the path: in upload-artifact to match your actual output.
SRI hashes and CI reproducibility. If you generate subresource integrity hashes in CI, the SRI values must match the deployed file content byte-for-byte. Any post-build transformation (minification, comment stripping) that runs outside the build step will break SRI. Ensure all transformations happen inside npm run build.
Performance Impact
| Operation | Typical Duration | Notes |
|---|---|---|
npm ci with warm cache |
5–15 s | actions/cache on ~/.npm typically saves 30–60 s on medium-sized projects |
| Vite production build | 10–60 s | Depends on chunk count; Vite’s Rollup bundler is single-threaded |
aws s3 sync (assets only) |
10–120 s | Proportional to changed file count; unchanged hashes are skipped |
aws s3 sync (HTML only) |
1–3 s | Typically only 1–3 HTML files |
| CloudFront invalidation | 5–30 s | Single path /index.html completes faster than wildcard invalidations |
wrangler pages deploy |
15–90 s | Includes asset upload and deployment activation |
| Manifest artifact upload | 1–3 s | Manifest JSON is typically under 50 KB |
Caching ~/.npm with actions/cache using hashFiles('package-lock.json') as the cache key is the single highest-impact optimisation for build time. When the lockfile does not change, npm ci reduces from 60–90 s to under 10 s on most projects.
The --delete flag on aws s3 sync adds a LIST operation before syncing, which adds 1–5 seconds but prevents bucket bloat. On very large buckets (hundreds of thousands of objects), LIST can take significantly longer; consider partitioning assets into a separate prefix or bucket.
FAQ
Should I purge all fingerprinted assets after each deploy?
No. Fingerprinted assets are immutable by definition — their content hash guarantees that the filename changes whenever the content changes. Invalidating them evicts perfectly valid objects from CDN edge caches, forcing a cold-cache origin fetch for every user on every deploy. Only invalidate HTML entry points that do not carry a content hash in their filename. For the mechanics of targeted invalidation, see AWS CloudFront invalidation.
Why upload assets before HTML rather than deploying everything at once?
If HTML and assets upload simultaneously (or in an undefined order), a user can receive the new index.html while the new main-a1b2c3d4.js has not yet been written to S3. Their browser requests an asset that returns 403 or 404, breaking the page. Uploading all hashed assets first creates a safe state where new filenames exist before any HTML references them. The HTML upload is the commit point of the atomic swap.
Can I use actions/cache for the build output instead of actions/upload-artifact?
Cache and artifact serve different purposes. actions/cache is keyed on inputs (lockfile hash, source hash) and is designed for reuse across runs to skip work. actions/upload-artifact stores the actual output of a specific run for handoff between jobs in the same run or for post-deploy download. You need the artifact for the deploy job to receive the built files. You optionally use cache to speed up npm ci or to skip rebuilds when source is unchanged.
How do I handle multiple HTML entry points (e.g., /about/index.html, /contact/index.html)?
Pass multiple paths to CloudFront in a single invalidation call to stay within the 1000-path-per-invalidation limit and to avoid per-invalidation charges:
aws cloudfront create-invalidation \
--distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
--paths "/index.html" "/about/index.html" "/contact/index.html"
For sites with hundreds of HTML files, a wildcard /\*.html works but counts as one invalidation path. However, wildcards in CloudFront only match a single path segment — use /* to match all paths recursively, keeping in mind this evicts every cached object including any non-HTML resources not carrying a content hash. The GitHub Actions hash manifest and atomic CDN deploy guide covers scripting dynamic path lists from the manifest.
Related
- Build Tool & Framework Asset Pipeline Integration — parent overview of build-time hashing strategies across all major bundlers
- GitHub Actions Hash Manifest and Atomic CDN Deploy — deep dive into manifest-driven deploy scripts and multi-environment promotion
- Rolling Back Fingerprinted Assets in CI/CD — restoring a previous manifest and re-pointing CDN origin to old asset hashes
- AWS CloudFront Invalidation — batching invalidation requests, cost control, and wildcard vs. exact-path tradeoffs
- Deterministic Build Outputs — ensuring the same source always produces the same hashes across CI workers and local machines