Rolling Back Fingerprinted Assets in CI/CD

You merged the PR, the deploy ran clean, and then the error reports started rolling in. Now you need to undo the release—but your project uses content-addressed asset fingerprinting, and the rollback playbook you wrote for server-side apps does not directly apply here. Do you roll forward with a hot-fix commit, or do you reach for the prior build artifact and redeploy the old HTML?

This page answers that decision precisely, covers the retain-N artifact strategy and blue/green approach, and provides a single GitHub Actions workflow you can trigger with one click. It assumes familiarity with the CI/CD asset pipeline integration pattern where an atomic deploy uploads hashed assets first and rewrites index.html last.


Why fingerprinted-asset rollback is different

In a classic server-side deploy, rolling back means reverting the running binary to an older version. Every resource the old version needs travels with it.

With fingerprinted assets, the CDN origin (S3, R2, or similar) accumulates immutable files forever. A file named main.a3f8c21b.js is never overwritten—if its content changes, it gets a new hash and a new key. This means:

  • Every asset from every prior build is already live in object storage. You do not need to re-upload them.
  • The only mutable file in the entire system is index.html (and any asset manifest it references).
  • Rolling back = re-deploying only the old index.html that references the old hashes.

This is fundamentally simpler than a full deploy, but it requires that you saved the old artifact. Understanding the rollback concept for content-hashed releases in depth is useful here, as is the companion page on rolling back cache keys after a bad deploy.


Diagnosis: confirming the problem before you act

Before choosing roll-forward or roll-back, confirm where the breakage lives:

Symptom Likely cause Approach
JS runtime error referencing main.<hash>.js Bad JS in the new build Roll-back HTML
Layout broken, CSS looks wrong Bad CSS in the new build Roll-back HTML
404 on a hashed asset URL Asset was never uploaded or was deleted Roll-forward only (rollback cannot fix missing files)
Build tool config changed (output dir, hash algo) Config drift, old artifacts incompatible Roll-forward only
Error in index.html template rendering Bad HTML template Roll-back HTML
Problem only in one region CDN propagation lag Wait or invalidate only

Run a quick header check first:

curl -sI https://your-site.com/assets/main.a3f8c21b.js | grep -E "HTTP|cache-control|x-cache"

If the asset returns 404, you cannot roll back—the problem is a missing object, not a bad manifest. If the asset returns 200 immutable, the files are fine and only the HTML needs to change.


Decision matrix: roll-forward vs. roll-back

Fingerprinted Asset Rollback Decision Flow Current Deploy Bad HTML / bad JS output Assets still in S3/R2? No / config drift Yes Roll-Forward Fix commit → new build → new deploy Download Prior Artifact (run_id) Re-upload index.html only CloudFront Invalidate /index.html Verified Old hashes served from CDN
Rollback decision flow for fingerprinted assets: bad deploy triggers the assets-present check, leading to either a roll-forward (fix commit and new build) or a targeted HTML-only rollback followed by CDN invalidation.

The table below maps conditions to strategies:

Condition Recommended strategy Reasoning
Bug is in JS/CSS logic, all assets uploaded correctly Roll-back Fastest recovery; assets already live
Bug is in index.html template or manifest Roll-back Swap one file
Breaking change was in webpack/Vite config (output dir, chunk names changed) Roll-forward Artifact incompatible with current infra
One or more hashed assets were deleted from S3/R2 Roll-forward Prior artifact references objects that no longer exist
Rollback target is older than your artifact retention window Roll-forward Artifact gone; cannot retrieve it
Schema migration ran on deploy and cannot be reversed Roll-forward Database state makes rollback unsafe
Bug only affects one browser or a feature flag cohort Roll-forward Targeted fix is safer

Retain-N artifact strategy

The GitHub Actions default artifact retention window is 90 days, but you can set it explicitly per job to enforce a deliberate policy. A common pattern is to keep the last 10 production artifacts:

  • Tag each release: git tag v$(date +%Y%m%d%H%M%S) before the deploy job.
  • Upload the entire dist/ folder (which contains index.html and the asset manifest but not the immutable chunks—those are in S3) as a named artifact tied to the workflow run.
  • Set retention-days: 30 to bound storage cost while preserving a rolling window.

Record the mapping between git tag and GitHub Actions run_id in your release notes or a lightweight DynamoDB/KV table. During an incident you need to retrieve the run_id immediately—not search through GitHub UI pages.

For monorepos, hash lengths of 12–16 hex characters reduce collision probability across hundreds of parallel asset graphs. Single-repo projects can stay at the default 8 hex characters.


Blue/green approach with S3 prefix switching

An alternative to artifact-based rollback is maintaining two live copies of your HTML layer:

  • Blue prefix: s3://your-bucket/blue/index.html
  • Green prefix: s3://your-bucket/green/index.html

Each deploy writes to the inactive slot. CloudFront’s origin path is set to /blue or /green via a parameter store value. Switching means updating the origin path and running a CloudFront invalidation for /index.html.

The hashed assets always live at the root (s3://your-bucket/assets/main.a3f8c21b.js), never inside a prefixed path, because both the blue and green index.html files reference the same immutable objects.

Blue/green is most practical when:

  • You have high deploy frequency and want an always-ready rollback target.
  • Your infra team prefers DNS/origin-level switching to re-running CI jobs.
  • You need sub-30-second rollback SLA.

For teams that deploy once a day or less, the artifact approach below is simpler to reason about.


GitHub Actions rollback workflow

The following workflow is triggered manually with a workflow_dispatch event. You supply the run_id of the prior successful deploy. The job downloads the saved dist artifact from that run, re-uploads only index.html with no-cache headers, and invalidates the CDN—without touching any immutable asset. This builds directly on the atomic deploy pattern described in the GitHub Actions hash manifest and atomic CDN deploy reference.

# .github/workflows/rollback.yml
name: Rollback fingerprinted HTML

on:
  workflow_dispatch:
    inputs:
      run_id:
        description: "GitHub Actions run_id of the build to restore"
        required: true
        type: string
      s3_bucket:
        description: "S3 bucket name (without s3:// prefix)"
        required: true
        type: string
        default: "my-frontend-bucket"
      cloudfront_distribution_id:
        description: "CloudFront distribution ID"
        required: true
        type: string

permissions:
  actions: read
  id-token: write
  contents: read

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: us-east-1

      - name: Download prior build artifact
        uses: actions/download-artifact@v4
        with:
          name: dist-production
          run-id: ${{ inputs.run_id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
          path: prior-dist

      - name: Verify artifact contains index.html
        run: |
          if [ ! -f prior-dist/index.html ]; then
            echo "ERROR: prior-dist/index.html not found. Artifact may be incomplete."
            exit 1
          fi
          echo "Artifact OK. Restoring index.html from run ${{ inputs.run_id }}"

      - name: Re-upload index.html with no-cache headers
        run: |
          aws s3 cp prior-dist/index.html \
            s3://${{ inputs.s3_bucket }}/index.html \
            --cache-control "no-cache, must-revalidate" \
            --content-type "text/html; charset=utf-8" \
            --metadata "rollback-source-run=${{ inputs.run_id }}"

      - name: Re-upload asset manifest (if present)
        run: |
          if [ -f prior-dist/asset-manifest.json ]; then
            aws s3 cp prior-dist/asset-manifest.json \
              s3://${{ inputs.s3_bucket }}/asset-manifest.json \
              --cache-control "no-cache, must-revalidate" \
              --content-type "application/json"
            echo "Manifest restored."
          else
            echo "No asset-manifest.json found; skipping."
          fi

      - name: Invalidate CloudFront HTML paths
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ inputs.cloudfront_distribution_id }} \
            --paths "/index.html" "/asset-manifest.json"

      - name: Print rollback summary
        run: |
          echo "Rollback complete."
          echo "  Source run_id : ${{ inputs.run_id }}"
          echo "  Bucket        : ${{ inputs.s3_bucket }}"
          echo "  Distribution  : ${{ inputs.cloudfront_distribution_id }}"
          echo "Immutable assets in S3 were NOT touched."

Two points to note. First, the actions/download-artifact@v4 action accepts a run-id input that lets you reach across workflow runs—you are not limited to the current run’s artifacts. Second, only index.html and the manifest are uploaded; the immutable hashed chunks (main.a3f8c21b.js, vendor.d9f1a04c.css, etc.) are already in S3 from the prior deploy and do not need to be re-sent. This keeps the rollback job under 30 seconds end-to-end.

If your frontend is on Cloudflare R2 + Cloudflare CDN purge rather than CloudFront, replace the AWS steps with the equivalent wrangler r2 object put and Cloudflare Cache API purge calls, but the structural logic is identical.


Why you never re-upload immutable assets

This is worth stating explicitly because it surprises engineers who are new to content-addressed deploys.

The Vite rollback and webpack rollback guides both confirm the same behaviour: a file with a given content hash represents exactly one immutable state. If main.a3f8c21b.js was uploaded during the prior deploy, it is still in S3 right now—nothing deleted it. The CDN’s cache for that file also has no reason to expire: you set Cache-Control: public, max-age=31536000, immutable on every hashed asset during the original upload.

Re-uploading those files would be wasted bandwidth and would re-trigger CDN edge population for no benefit. The only file that changed between the bad deploy and the prior deploy is index.html, which points to different hashes. Swap the pointer, invalidate the pointer’s CDN cache entry, and you are done.


Verification command

After the workflow finishes, confirm the old asset hashes are being served. Replace a3f8c21b with a hash that appeared in your prior index.html:

curl -sI https://your-site.com/index.html | grep -E "x-cache|age|cache-control|etag"

Expected output after a successful rollback and CDN invalidation:

cache-control: no-cache, must-revalidate
x-cache: Miss from cloudfront
age: 0

The age: 0 and Miss from cloudfront confirm CloudFront fetched a fresh copy from S3. If age is non-zero, the invalidation has not propagated to all edges yet—wait 30–60 seconds and retry. You can also open DevTools → Network, reload the page, and confirm the src attributes of <script> and <link> tags point to the hashes from your prior build rather than the bad build.


When to reconsider: prefer roll-forward instead

Roll-back is not always the right answer. Choose roll-forward when:

  • The breakage is in your build configuration itself. If the bad deploy changed webpack’s output.filename pattern from [name].[contenthash:8].js to something else, the artifact’s index.html references paths that no longer match the objects in S3. A roll-back would serve 404s.
  • Hashed assets were deleted from S3. Object storage lifecycle rules or a manual deletion may have removed old chunks. The prior index.html would reference missing objects.
  • The artifact is older than your retention window. If you kept 30 days of artifacts and you need to revert a 45-day-old state, the artifact is gone. You must roll forward or restore from a full S3 version snapshot.
  • A database migration shipped with the deploy. Rolling back the HTML while the database schema is in its new state can cause runtime errors worse than the original bug. Roll forward with a compensating schema change instead.
  • The incident is in a region where assets are already propagated. Depending on the failure mode, rolling forward with a one-line hot-fix commit may be faster than waiting for manual rollback approval.

The decision matrix at the top of this page captures these conditions. When in doubt, ask: “Do I know for certain that all objects referenced in the prior index.html still exist in S3?” If the answer is yes, roll-back is safe. If no, roll-forward.