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., a one-command deploy on a managed static host) 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.

Upload order and the broken-page window An unordered sync publishes HTML before the hashed chunks exist, opening a window where browsers receive 404 responses. The ordered pipeline uploads assets first, verifies the manifest, then publishes HTML, so no gap exists. Deploy timeline: unordered sync vs. ordered pipeline One-pass sync — undefined order HTML published browser requests main-a1b2c3d4.js and gets 404 assets finish upload broken window Ordered pipeline — assets, manifest check, then HTML hashed assets upload immutable headers manifest verified every hash present HTML goes live commit point no gap time Hashed filenames must exist at the origin before any HTML naming them is reachable.
The only difference between the two rows is ordering — and that ordering is the entire difference between a clean release and a window of 404s.

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, setting 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; the shape and the trade-offs between the two formats are covered in the asset manifest generation reference.

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
--size-only (aws s3 sync) boolean false Compares object size instead of size+mtime; avoids needless re-uploads of identical hashed files
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
permissions.id-token (GitHub Actions) enum none Must be write to mint an OIDC token for keyless cloud auth
--commit-dirty (wrangler pages deploy) boolean false Allows deploy from an unclean git working tree inside CI

Two of these deserve emphasis. --metadata-directive REPLACE matters because S3 will happily leave an old Cache-Control header in place when the object body is byte-identical; without it, a header policy change never reaches already-uploaded objects. And --size-only is worth adding once your asset directory is large: content-hashed files never change body for a given key, so comparing timestamps only produces wasted PUT calls after every fresh CI checkout.

Step-by-Step Implementation

The five stages below map one-to-one onto workflow steps. Stages one through three are entirely reversible — nothing user-visible has changed yet. Stage four is the commit point of the release.

Five pipeline stages and their cache headers Build produces content hashes, assets upload with immutable headers, the manifest is archived as an artifact, HTML is published with a no-cache header, and only the unhashed entry point is invalidated. Five pipeline stages and their cache headers Build npm ci, vite build content hashes Upload assets immutable, 1 year exclude *.html Save manifest upload-artifact 30-day retention Deploy HTML no-cache header the commit point Invalidate /index.html only assets untouched Stages 1 to 3 are reversible; stage 4 is the point of no return for the release. Stage 5 touches only the unhashed entry point, never a fingerprinted object.
Each stage owns exactly one cache-header policy, which is why a single misplaced --cache-control flag is the most common cause of a stale deploy.

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 npm downloads
        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 \
            --size-only

      # 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 npm downloads
        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.

Step 5 — Gate the HTML Publish on a Manifest Preflight

Ordering alone guarantees that the upload started before HTML publishes. It does not guarantee that every object actually landed — a partial multipart upload, an expired credential mid-sync, or an --exclude pattern that silently skipped a directory all produce a bucket that is missing exactly the file the new HTML needs. A preflight step closes that gap by reading the manifest and issuing a HEAD request for every hashed key before the commit point.

Add this step to the deploy job, immediately before the HTML upload:

#!/usr/bin/env bash
# scripts/preflight-manifest.sh — verify every hashed asset exists at the origin
set -euo pipefail

MANIFEST="${1:?usage: preflight-manifest.sh <manifest.json> <bucket>}"
BUCKET="${2:?usage: preflight-manifest.sh <manifest.json> <bucket>}"

# Vite manifest: every entry has a "file" key; some also have "css" arrays.
KEYS=$(python3 - "$MANIFEST" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
out = set()
for entry in data.values():
    if isinstance(entry, dict):
        if entry.get("file"):
            out.add(entry["file"])
        for css in entry.get("css", []):
            out.add(css)
    elif isinstance(entry, str):
        out.add(entry)
for key in sorted(out):
    print(key)
PY
)

missing=0
while read -r key; do
  [ -z "$key" ] && continue
  if aws s3api head-object --bucket "$BUCKET" --key "$key" >/dev/null 2>&1; then
    printf 'ok      %s\n' "$key"
  else
    printf 'MISSING %s\n' "$key"
    missing=$((missing + 1))
  fi
done <<< "$KEYS"

if [ "$missing" -gt 0 ]; then
  echo "Preflight failed: $missing object(s) missing. HTML will not be published."
  exit 1
fi
echo "Preflight passed: all manifest entries present in s3://$BUCKET/"

Wire it in with a single step, and the job fails loudly before the commit point rather than quietly after it:

      - name: Preflight — every manifest entry must exist in S3
        run: |
          chmod +x scripts/preflight-manifest.sh
          scripts/preflight-manifest.sh dist/.vite/manifest.json "${{ secrets.S3_BUCKET }}"

The cost is one HEAD request per asset — a few hundred milliseconds for a typical 40-chunk build, and far less than the cost of a partial release. Teams that also publish subresource integrity attributes usually extend the same script to compare the stored object’s ETag against the locally computed digest.

Deploy Credentials Without Long-Lived Secrets

Storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as repository secrets works, but those credentials never expire and are readable by every workflow in the repository. GitHub Actions can instead mint a short-lived OpenID Connect token that AWS STS exchanges for a session lasting minutes rather than forever. The exchange requires exactly two things: permissions.id-token: write on the job, and a trust policy on the IAM role that pins the repository and ref.

Keyless deploy credentials via OpenID Connect The workflow job requests an identity token from GitHub, receives a signed JWT carrying repository and ref claims, presents it to AWS STS, and receives short-lived session credentials scoped to the deploy role. Keyless credential exchange per workflow run Actions job runs-on: ubuntu-24.04 GitHub OIDC issuer signs the identity token AWS STS AssumeRoleWithWebIdentity 1. request identity token 2. signed JWT with repo and ref claims 3. AssumeRoleWithWebIdentity 4. session credentials, minutes not months No long-lived cloud key is stored in the repository; the token is minted per run. A stolen log line expires before it can be replayed.
Four messages replace a permanent secret: the runner proves who it is, and the cloud hands back a session that dies with the job.

The workflow side is a two-line change. Replace the static key pair with a role ARN and grant the token permission:

  deploy:
    needs: build
    runs-on: ubuntu-24.04
    environment: production
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/frontend-deploy
          role-session-name: gha-frontend-deploy
          aws-region: us-east-1

The IAM side pins the token to a single repository and branch so a fork or a pull-request run cannot assume the role. The sub claim is the control point — matching on repo:acme/storefront:* would let any branch deploy production:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:acme/storefront:ref:refs/heads/main"
        }
      }
    }
  ]
}

Scope the attached policy narrowly too: s3:PutObject and s3:ListBucket on the asset bucket, plus cloudfront:CreateInvalidation on the one distribution. A deploy role does not need s3:DeleteObject unless you actually run --delete, and withholding it turns a compromised workflow into a nuisance rather than an outage. On Cloudflare, the equivalent is a scoped API token limited to the specific Pages project and zone rather than a global account key.

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

The four responses you should be able to recite from memory are below. If any row disagrees with what curl prints, the cause is almost always a --cache-control flag applied to the wrong s3 sync pass.

Post-deploy header checks A four-row matrix pairing each origin path with the Cache-Control header it must answer with and the edge cache state expected on the second request. Post-deploy header checks Path Expected Cache-Control Expected edge state /index.html no-cache, must-revalidate Miss, then revalidated /assets/main-a1b2c3d4.js max-age=31536000 Hit on the second request /asset-manifest.json no-cache, must-revalidate Miss on every deploy /favicon.ico max-age=604800 Hit, unhashed but stable
Any hashed asset answering no-cache means the asset sync ran without its --cache-control flag — check the step order before blaming the CDN.

The /favicon.ico row is the odd one out on purpose. Root-level files that browsers request by a fixed name cannot carry a hash, so they get a moderate TTL instead of an immutable one — a week is long enough to be cheap and short enough that a redesign reaches users without a purge. The same reasoning applies to robots.txt, manifest.webmanifest, and any service-worker script.

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. This is why the workflow above omits --delete entirely and defers pruning to a scheduled job.

Retention window before pruning old hashes Old HTML remains cached in browsers and at the edge for its TTL, and old hashed assets keep being requested past that point, so cleanup may only start after both windows close. Retention window before pruning old hashes old HTML still cached old hashes still requested cleanup allowed deploy N+1 HTML TTL expires earliest safe delete Delete an old hash too early and every user holding old HTML gets a 404.
The safe pruning point is not the deploy — it is the moment the last cached copy of the previous HTML has expired everywhere.

A practical cleanup rule: run the prune job on a schedule, and only delete objects whose LastModified is older than the longest HTML TTL plus your longest plausible open-tab session. Seven days is a common setting; the trade-off between that number and storage cost is discussed further in the cache-control immutable and TTL tuning reference.

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 integrity attributes 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 validation. Ensure all transformations happen inside npm run build.

Compression applied after upload. If your CDN or origin gzips or brotli-compresses objects on the fly, the bytes on the wire differ from the bytes you hashed. That is harmless for cache-busting but fatal for SRI, and it also makes ETag comparisons in the preflight script unreliable. Either pre-compress in the build and upload both variants with an explicit Content-Encoding, or compare against x-amz-meta checksums you set yourself at upload time.

Runner clock skew and artifact expiry. download-artifact fails with a confusing 404 when the referenced run’s artifacts have aged out. If your rollback path depends on a 30-day artifact but your incident is 45 days after the fact, the rollback simply is not available. Treat retention as a recovery-window SLA and set it deliberately rather than accepting the 90-day default by inertia.

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
Manifest preflight (head-object per key) 1–4 s 40 chunks at roughly 40 ms per request, run sequentially
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
Typical stage durations on a warm runner Horizontal bars comparing elapsed seconds for dependency install, build, asset sync, HTML sync, and CDN invalidation on a warm GitHub Actions runner. Typical stage durations on a warm runner npm ci (warm cache) 10 s vite build 35 s s3 sync (assets) 45 s s3 sync (HTML) 3 s CloudFront invalidate 20 s 0 s 30 s 60 s 90 s 120 s Measured on ubuntu-24.04 with a 40-chunk Vite build and a 12 MB dist directory.
The asset sync dominates wall-clock time, which is exactly why it belongs in its own job ahead of the HTML publish rather than inline with it.

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.

Adding --delete to aws s3 sync costs an extra LIST operation before syncing — 1–5 seconds on a normal bucket, considerably more once you cross a few hundred thousand objects. Since the safe-pruning rule above already pushes deletion into a separate scheduled job, the LIST cost disappears from the critical path of every deploy.

Pre-Deploy Checklist

Walk this list before the first production run of a new pipeline, and again whenever you change bundler output options.

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?

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, generate the path list from the build output rather than hard-coding it, and remember that CloudFront wildcards match a single path segment unless you use a trailing /*, which evicts every cached object including unhashed media. The GitHub Actions hash manifest and atomic CDN deploy guide covers scripting dynamic path lists from the manifest.

How do I run this pipeline for preview environments without duplicating the workflow?

Parameterise the bucket, distribution, and origin prefix as job outputs derived from the ref, then reuse the same job definitions through workflow_call. Preview deploys write to a per-branch prefix with a short TTL and skip the invalidation step entirely, because nothing is cached long enough to need it. The one thing you should not parameterise is ordering: preview environments that publish HTML first teach the team a habit that will eventually be exercised in production.

Does this pattern change if I deploy two generations of the site at once?

Only in where the HTML lives. Because hashed assets are already immutable, two generations can share one asset prefix indefinitely and the cutover reduces to which HTML document a request resolves to. The blue-green asset deploys with fingerprinted files page covers that model, including the traffic-splitting case where both generations serve real users simultaneously.