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 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.
Rollback moves one pointer, not the objects The object store holds every hashed file from both releases. The withdrawn HTML pointed at the newer pair of hashes; the restored HTML points at the older pair. No object is uploaded or deleted. Rollback moves one pointer, not the objects Object store, append-only main-9f3a71c2.js app-3c0d55ea.css main-a1b2c3d4.js app-7be21f04.css index.html, release N-1 restored by the rollback index.html, release N withdrawn, not deleted No hashed object is uploaded, moved, or deleted during a rollback. The only write is to the entry point that names them.
Both releases coexist in storage permanently. Rollback is a pointer edit, which is why it costs seconds rather than a full build cycle.

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

Rollback decision flow Starting from a confirmed bad deploy, the flow asks whether the prior generation of hashed objects is still present in storage. If not, roll forward. If so, fetch the prior artifact, re-upload only the entry point, and invalidate it. Rollback decision flow Current deploy is bad symptom confirmed in prod Assets still in object storage? no yes Roll forward fix commit, new build then a normal deploy Fetch artifact by run id Re-upload index.html only Invalidate the entry point Verified old hashes served again
One question decides the branch: are the objects the previous HTML names still present at the origin? Everything downstream follows from the answer.

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 key-value table. During an incident you need to retrieve the run_id immediately—not search through a list of workflow runs by hand.

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.

The retention window is really a recovery-window SLA in disguise, and it has a partner setting: the lifecycle rule that prunes old objects from the bucket. If the bucket deletes hashed files after 7 days but you retain artifacts for 30, then a 10-day-old rollback target will restore an index.html whose chunks no longer exist. Keep the object lifetime greater than or equal to the artifact lifetime, and audit both numbers together rather than separately.

If you would rather keep a second generation permanently addressable instead of reconstructing it from an artifact, the blue-green asset deploy model keeps both HTML generations live at all times and turns rollback into a routing change rather than an upload.


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-24.04
    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: Verify every referenced chunk still exists in S3
        run: |
          set -euo pipefail
          missing=0
          for key in $(grep -oE '(assets|static)/[A-Za-z0-9._-]+\.(js|css)' prior-dist/index.html | sort -u); do
            if aws s3api head-object --bucket "${{ inputs.s3_bucket }}" --key "$key" >/dev/null 2>&1; then
              echo "ok      $key"
            else
              echo "MISSING $key"
              missing=$((missing + 1))
            fi
          done
          if [ "$missing" -gt 0 ]; then
            echo "Refusing to roll back: $missing object(s) referenced by the prior HTML are gone."
            exit 1
          fi

      - 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."

Three points to note. First, actions/download-artifact@v4 accepts a run-id input that lets you reach across workflow runs—you are not limited to the current run’s artifacts. Second, the pre-check step turns the “are the objects still there?” question from the decision tree into an automated gate: the job aborts before writing anything if a referenced chunk has been pruned. Third, only index.html and the manifest are uploaded; the immutable hashed chunks are already in S3 from the prior deploy and do not need to be re-sent.

Elapsed time for an HTML-only rollback Dispatch, artifact download, HTML upload, CDN invalidation, and verification plotted against a sixty second axis, with CDN propagation dominating the total. Elapsed time for an HTML-only rollback dispatch workflow download artifact upload index.html invalidate entry point verify with curl done at 52 s 0 s 20 s 40 s 60 s Most of the wall clock is CDN propagation, not work the pipeline does.
The pipeline's own steps account for roughly eighteen seconds; the rest is waiting for edges to pick up the new entry point.

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 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.


FAQ

Does a rollback need a CDN purge of the hashed assets too?

No, and issuing one actively hurts. The old hashed objects were never evicted and never changed, so their cached copies at the edge are still correct. Purging them forces every edge to re-fetch files it already holds, which spikes origin egress at exactly the moment you want the system to be calm. Purge the entry point and, if you serve one, the manifest — nothing else.

A service worker is still serving the broken build after the rollback. What now?

A service worker with a precached bundle ignores your HTML swap until it updates itself, so the rollback appears to do nothing for returning visitors. The fix belongs at the worker layer: ship a worker whose own script is unhashed and served with no-cache, so the browser re-fetches it on navigation and can skipWaiting() into the restored generation. Treat that as a separate remediation running in parallel with the HTML rollback, not as a reason to distrust the rollback.

Can I roll back automatically when error rates spike?

You can, and the workflow above is already shaped for it — swap workflow_dispatch for a repository_dispatch fired by your monitoring system and pass the last-known-good run_id as the payload. The guardrail that makes this safe is the object-existence pre-check: an automated rollback that cannot verify its target should refuse to run rather than replace a broken page with a blank one.

How many prior generations should I keep addressable?

Two is the practical minimum — the current release and the one before it — because that is what covers the common “the deploy we just shipped is bad” case. Teams that batch several releases per day usually keep five, which costs almost nothing in object storage and means an incident discovered a few hours late is still recoverable by pointer swap rather than by rebuild.