GitHub Actions Hash Manifest and Atomic CDN Deploy

Every frontend team eventually hits the same wall: a deploy that ships HTML referencing chunk filenames that do not yet exist on the CDN. Users land on a stale cached page, the browser fetches a new HTML document pointing to main.a1b2c3d4.js, and gets a 404 because the asset upload is still running. The page breaks for seconds or minutes depending on CDN propagation speed.

The root cause is ordering. When your CI/CD pipeline uploads HTML and assets in a single undifferentiated aws s3 sync, both operations race to the same S3 bucket with no guarantee that assets arrive before the HTML that references them. Fixing this requires separating the pipeline into jobs with explicit ordering: assets upload first with immutable cache headers, HTML uploads last, and a targeted CloudFront invalidation fires only after HTML is confirmed live.

This page covers the complete workflow — from building a content-hash-based manifest with Vite through multi-job GitHub Actions orchestration to atomic S3 sync and a single index.html CloudFront invalidation. For foundational concepts on why deterministic build outputs matter before this pipeline even starts, see that reference first.


Why Deploying HTML Before Assets Causes 404s

When a user’s browser holds a cached copy of index.html and a new deploy begins, several race conditions are possible:

Race 1 — CDN serves fresh HTML, assets not yet uploaded. The CDN invalidation runs before assets finish uploading. The browser gets new HTML, requests chunk.a1b2c3d4.js, but S3 returns 404 because the upload job is still running.

Race 2 — Single sync uploads HTML before assets. aws s3 sync dist/ s3://bucket/ processes files in filesystem order. On large projects, index.html alphabetically precedes most asset paths and often uploads first.

Race 3 — Old HTML + new assets. Less harmful but still possible: a CDN edge still serving cached old index.html that references a chunk hash that no longer exists after a subsequent deploy pruned it.

All three collapse into a single two-variable question: which generation of HTML is reachable, and which generations of hashed objects exist at the origin. Only one of the four combinations is broken.

Origin states during a deploy A matrix pairing old or new HTML with either old assets only or both generations of assets. Three combinations are consistent; new HTML with only old assets is the broken state. Origin states during a deploy Assets: old only Assets: old and new HTML: old HTML: new Consistent steady state before deploy every reference resolves Safe staging new hashes uploaded, unused reversible at zero cost Broken HTML names hashes that have not been written yet Consistent again the release is complete old hashes still resolve Job ordering exists to make the lower-left cell unreachable.
Three of four states are safe. The entire pipeline design is an argument about never entering the fourth.

The correct ordering guarantee is:

  1. All versioned assets (JS, CSS, fonts, images) are present in S3 before any HTML changes become visible.
  2. HTML goes live only after assets are confirmed uploaded.
  3. CloudFront invalidation fires only after HTML is in S3.

GitHub Actions needs: keys enforce this ordering across jobs, making it structural rather than accidental.


Anatomy of the Three-Job Pipeline

The pipeline splits into three sequential jobs with explicit needs: dependencies:

Job Inputs Outputs Cache-Control header
build source code dist/ directory artifact
upload-assets dist/ artifact assets in S3, manifest.json artifact public,max-age=31536000,immutable
deploy-html manifest.json artifact index.html in S3 + CloudFront invalidation no-cache,must-revalidate

Why separate manifest.json as an artifact? The Vite build emits .vite/manifest.json mapping source module paths to their hashed filenames. Passing this between jobs as an actions/upload-artifact@v4 artifact means the deploy-html job can verify the expected chunks are present in S3 before it touches HTML. It also feeds rollback workflows that need to know which hash was live at a given commit. The trade-offs between manifest formats are covered in the asset manifest generation reference.

Why no-cache,must-revalidate on HTML but immutable on assets? Assets are content-addressed — main.a1b2c3d4.js will never change because the hash encodes the content. Serving them with max-age=31536000,immutable tells every CDN edge and browser to cache them forever. HTML is not content-addressed; it’s the entry point that must always reflect the latest deploy. no-cache forces a revalidation on every request. See the Vite asset pipeline configuration reference for how build.manifest: true generates the hash-to-filename map. Webpack users can find equivalent setup at the webpack output hashing reference.

Three-job pipeline with artifact handoffs The build job produces a dist artifact, the upload-assets job syncs hashed files with immutable headers and publishes a manifest artifact, and the deploy-html job promotes the staged HTML and issues one invalidation. Job sequence and artifact handoffs Job: build actions/cache on ~/.npm npm ci vite build writes dist/ upload-artifact: dist needs Job: upload-assets download-artifact: dist s3 sync, immutable, 1 year s3 cp index.html.next upload-artifact: manifest needs Job: deploy-html download-artifact: manifest promote to /index.html create-invalidation, one path remove the staged copy artifact handoff immutable cache policy no-cache entry point A job cannot start until its predecessor has succeeded, so ordering is structural.
Every arrow is a needs: key. Deleting one does not make the deploy faster — it makes the ordering guarantee accidental again.

Decision Matrix: When Atomic Ordering Matters

Scenario Atomic ordering required? Notes
SPA with code splitting Yes Multiple chunk files; stale HTML references missing hashes
Single-file bundle (no splitting) Yes, but lower risk Still worth enforcing; costs nothing
SSR app with client hydration Yes, critical Hydration mismatch on version skew causes runtime errors
Static site, no JS No HTML-only deploys with no versioned assets
Two generations live at once Partially Traffic shift replaces ordering concern, but assets still need to pre-exist
Monorepo with shared chunks Yes, elevated Cross-package chunk sharing means a single missing hash breaks multiple entry points

For monorepos or large projects with many shared chunks, consider extending hash length to 12–16 hex characters to reduce collision probability across packages. The 8-character default used in examples here is appropriate for single-app projects. Where two generations of the site are deliberately live at the same time, the ordering rule does not disappear — it moves, and the blue-green asset deploys page covers where it lands.


Complete GitHub Actions Workflow

The workflow below is self-contained and production-ready. It targets a Vite project deploying to S3 + CloudFront, but the structure applies equally to webpack-based pipelines by swapping the build command.

Store AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET, and CF_DIST_ID as GitHub Actions repository secrets before running.

name: Atomic CDN Deploy

on:
  push:
    branches:
      - main

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  build:
    name: Build
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Cache node_modules
        uses: actions/cache@v4
        with:
          path: node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - 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: 1

  upload-assets:
    name: Upload Assets
    needs: build
    runs-on: ubuntu-24.04
    permissions:
      id-token: write
      contents: read
    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

      - name: Sync versioned assets with immutable cache headers
        run: |
          aws s3 sync dist/ s3://${{ secrets.S3_BUCKET }}/ \
            --exclude "*.html" \
            --cache-control "public,max-age=31536000,immutable" \
            --no-progress

      - name: Upload index.html with no-cache headers (pre-stage, not yet live)
        run: |
          aws s3 cp dist/index.html s3://${{ secrets.S3_BUCKET }}/index.html.next \
            --cache-control "no-cache,must-revalidate" \
            --content-type "text/html; charset=utf-8"

      - name: Upload Vite manifest as pipeline artifact
        uses: actions/upload-artifact@v4
        with:
          name: vite-manifest
          path: dist/.vite/manifest.json
          retention-days: 7

  deploy-html:
    name: Deploy HTML
    needs: upload-assets
    runs-on: ubuntu-24.04
    environment: production
    steps:
      - name: Download Vite manifest artifact
        uses: actions/download-artifact@v4
        with:
          name: vite-manifest
          path: manifest/

      - 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

      - name: Promote index.html to live path
        run: |
          aws s3 cp s3://${{ secrets.S3_BUCKET }}/index.html.next \
            s3://${{ secrets.S3_BUCKET }}/index.html \
            --cache-control "no-cache,must-revalidate" \
            --content-type "text/html; charset=utf-8"

      - name: Invalidate CloudFront index.html
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CF_DIST_ID }} \
            --paths "/index.html"

      - name: Clean up staged file
        run: |
          aws s3 rm s3://${{ secrets.S3_BUCKET }}/index.html.next

Key decisions in this workflow

concurrency: cancel-in-progress: false — The group: deploy-production key prevents two deploys from running simultaneously. Setting cancel-in-progress: false (rather than true) ensures an in-flight deploy finishes before the next one starts. Cancelling mid-deploy would leave HTML and assets in a partially updated state. If you need the latest commit to always win immediately, switch to cancel-in-progress: true and accept the risk of a brief asset gap on the cancelled run.

No --delete on the asset sync — Pruning old hashed objects during a deploy is tempting and wrong. Any browser holding the previous HTML still resolves the previous hashes, and an edge that has not yet revalidated will serve that HTML for the length of its TTL. Removing --delete from the deploy path and moving pruning into a scheduled job keeps the sync fast, avoids the extra LIST call, and eliminates a whole class of “worked in staging, 404s in production” incidents.

index.html.next staging pattern — Rather than uploading index.html directly in upload-assets and risking it going live before CloudFront propagation, the workflow stages it as index.html.next, then atomically promotes it in deploy-html. This is belt-and-suspenders: the needs: ordering already guarantees assets are present, but the staging pattern adds an explicit moment where the operator could inspect the staged file before promotion via the environment: production GitHub Environments gate.

retention-days: 1 for dist, 7 for manifest — The dist/ artifact is large and only needed to pass between jobs in the same run. The manifest artifact is small and useful for post-deploy debugging and rollback workflows.


Why a Single /index.html Invalidation Is Enough

A common instinct is to invalidate /* on every deploy to guarantee no stale content. This is expensive, slow, and unnecessary when assets are content-addressed.

Hashed assets like main.a1b2c3d4.js never change. CloudFront can and should serve them from cache indefinitely — that is the point of max-age=31536000,immutable. Invalidating them wastes CloudFront invalidation quota (the first 1,000 paths per month are free; wildcard /* counts as one path but triggers full-distribution propagation that can take 5–15 minutes) and forces every CDN edge to re-fetch assets from S3 on the next request.

Blast radius of a wildcard purge Each tile is one cached object at the edge. A wildcard purge evicts all eighteen; purging the single unhashed entry point evicts one and leaves every immutable object warm. Blast radius: wildcard purge vs. one path Wildcard purge: /* Targeted purge: /index.html evicted from every edge still cached, still valid 18 objects re-fetched from origin 1 object re-fetched from origin Only the unhashed entry point can be stale; hashed objects cannot be.
The wildcard is not a safety net. It is a self-inflicted cold cache on every deploy, paid for in origin egress and first-byte latency.

The only file that changes on deploy is index.html. Invalidating /index.html is sufficient because:

  1. All asset filenames in the new index.html are new hashes not yet cached by any browser.
  2. Browsers that hold a cached copy of the old index.html will revalidate it on the next navigation because of no-cache,must-revalidate.
  3. CloudFront edges that served the old index.html will fetch the new one after the targeted invalidation completes (typically under 60 seconds).

For CloudFront invalidation mechanics, propagation timing, and cost modeling, see the AWS CloudFront invalidation reference. For subresource integrity validation of hashed assets, which adds an additional integrity check beyond filename-based content addressing, see that guide.

Multi-page apps (MPA) with several HTML entry points require one invalidation path per entry: /index.html, /about/index.html, /dashboard/index.html. Still cheaper than /* and still avoids invalidating immutable assets.


Verification Command

After the deploy-html job completes, confirm the CloudFront invalidation has propagated and that cache headers are correct:

# Replace with your actual CloudFront domain and distribution ID
CF_DOMAIN="d1abc2def3gh4i.cloudfront.net"
CF_DIST_ID="E1ABCDEFGHIJKL"

# Check index.html cache headers (should show no-cache)
curl -sI "https://${CF_DOMAIN}/index.html" | grep -i "cache-control\|x-cache\|age"

# Check a hashed asset (should show max-age=31536000 and x-cache: Hit)
curl -sI "https://${CF_DOMAIN}/assets/main-a1b2c3d4.js" | grep -i "cache-control\|x-cache\|age"

# Confirm the invalidation completed (status should be Completed, not InProgress)
aws cloudfront get-invalidation \
  --distribution-id "${CF_DIST_ID}" \
  --id "$(aws cloudfront list-invalidations \
    --distribution-id "${CF_DIST_ID}" \
    --query 'InvalidationList.Items[0].Id' \
    --output text)"

Expected output for index.html: cache-control: no-cache, must-revalidate and x-cache: Miss from cloudfront (confirming the edge fetched fresh content after invalidation). Expected output for a hashed asset: cache-control: public, max-age=31536000, immutable and x-cache: Hit from cloudfront after the first request.

If the hashed asset comes back with x-cache: Miss on every request, the invalidation is almost certainly wider than you think — check the Paths element of the most recent invalidation batch before assuming an origin problem.


When to Reconsider This Approach

When your infrastructure can shift traffic between two live generations. If your CDN supports origin groups or edge routing, you can publish the new generation to a shadow location and cut over at the router rather than at the object store. This eliminates the asset-before-HTML ordering problem at the cost of running two prefixes and a garbage-collection policy that understands both.

When your app uses server-side rendering with version-pinned API responses. In SSR setups where the HTML is generated at request time by a server that also controls the asset manifest, the static file ordering problem does not apply in the same way. The server guarantees HTML and assets are consistent within a single server version.

When your CDN supports origin shield and you can tolerate brief stale HTML. Some teams accept a 30–60 second window of potential version skew if their error monitoring shows no impact and their user base is not latency-sensitive. This is a risk tolerance decision, not a technical recommendation.

When your asset graph is shallow (single bundle, no code splitting). A single-file bundle with a fixed filename like bundle.a1b2c3d4.js still benefits from ordered deployment, but the blast radius of a race condition is lower — there are no inter-chunk dependencies to violate. The workflow overhead may not be justified for very small projects.

When you need to support many HTML entry points with different invalidation schedules. Projects with 50+ HTML routes may benefit from a CDN-level versioning approach — appending a deploy ID as a query parameter to HTML files and updating a routing rule — rather than issuing 50+ individual CloudFront invalidations per deploy.


FAQ

Can the deploy-html job verify the manifest before promoting HTML?

Yes, and it should. The manifest artifact is downloaded into manifest/ specifically so the job can iterate every hashed filename and issue aws s3api head-object against the bucket before the promote step runs. A missing key then fails the job while the site is still in the safe-staging state, rather than after the commit point. The cost is one HEAD request per chunk, typically a couple of seconds.

Why stage index.html.next instead of writing index.html directly?

Because the promote step is then a single S3 server-side copy that takes milliseconds, rather than an upload whose duration depends on the runner’s network. It also gives the environment: production approval gate something concrete to guard: an approver can fetch the staged object and diff it against the live one before releasing the job.

What happens if the workflow fails between the asset sync and the HTML promote?

Nothing user-visible. The origin is in the safe-staging state — new hashed objects exist but no HTML references them, so every live request still resolves against the previous generation. Re-running the workflow is safe and idempotent: the sync re-uploads nothing (the hashes match), and the promote step publishes the HTML that was already staged.