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.
The correct ordering guarantee is:
- All versioned assets (JS, CSS, fonts, images) are present in S3 before any HTML changes become visible.
- HTML goes live only after assets are confirmed uploaded.
- 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.
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.
The only file that changes on deploy is index.html. Invalidating /index.html is sufficient because:
- All asset filenames in the new
index.htmlare new hashes not yet cached by any browser. - Browsers that hold a cached copy of the old
index.htmlwill revalidate it on the next navigation because ofno-cache,must-revalidate. - CloudFront edges that served the old
index.htmlwill 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.
Related
- CI/CD pipeline patterns for hashed assets — parent guide covering upload ordering, credentials, preflight checks, and pruning policy
- Targeted CDN invalidation — invalidation mechanics, propagation timing, cost, and wildcard vs. path-targeted strategies
- Rollback without re-uploading assets — how to use the
vite-manifestartifact from this workflow to return to a prior content hash - Two live generations and instant cutover — what changes when the cutover moves from the object store to the router
- Reproducible hash output — why the same source must always produce the same hash, and how to audit your build for non-determinism