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-Control headers 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

CI/CD Pipeline: Build → Hash → Upload → Atomic Deploy Build npm ci vite build Upload Assets S3/R2 sync immutable headers exclude *.html Save Manifest upload-artifact manifest.json 30-day retention Deploy HTML index.html last no-cache header atomic swap Invalidate CloudFront: /index.html only CF Pages: no-op Build job Assets (immutable) Manifest artifact HTML (no-cache) Purge HTML only Assets upload before HTML — no user ever receives HTML referencing assets that do not yet exist on the CDN edge.
The five pipeline stages enforce upload order: hashed assets land on the CDN before index.html is replaced, guaranteeing the atomic swap property.

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.