From 5f6160080214c7f438ba6802a374c2338ba8bb27 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 14 Jul 2026 13:38:19 +0000 Subject: [PATCH 01/12] fix(webapp): survive asset hash rotation across rolling deploys Documents are content-hash-coupled to the build baked into the image that rendered them, so during a rolling deploy a stale document requests /build asset URLs that 404 on the new image: unstyled pages and dead buttons from partial hydration. Three layers: - Carry the previous published image's /build assets into each new image (PREV_IMAGE build arg, resolved in the publish workflow, no-op default for forks/local builds), pruned after 14 days. - Default document responses to Cache-Control: no-cache so browsers always revalidate HTML. - Inline recovery script that force-reloads (at most twice, 30s apart) when a /build stylesheet/script fails to load or a chunk import rejects - covers rollbacks and tabs older than the retention window. --- .github/workflows/publish-webapp.yml | 20 +++++++ .../stale-deploy-asset-recovery.md | 10 ++++ .../app/components/StaleAssetRecovery.tsx | 56 +++++++++++++++++++ apps/webapp/app/entry.server.tsx | 6 ++ apps/webapp/app/root.tsx | 3 + docker/Dockerfile | 10 ++++ 6 files changed, 105 insertions(+) create mode 100644 .server-changes/stale-deploy-asset-recovery.md create mode 100644 apps/webapp/app/components/StaleAssetRecovery.tsx diff --git a/.github/workflows/publish-webapp.yml b/.github/workflows/publish-webapp.yml index 3f8c03a4166..e984b81a7e9 100644 --- a/.github/workflows/publish-webapp.yml +++ b/.github/workflows/publish-webapp.yml @@ -113,6 +113,25 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: πŸ”Ž Resolve previous image for asset carry-forward + id: prev_image + # Previous image whose /build assets the Dockerfile carries forward. + # Prefer the tag being published (mutable tags still point at the + # previous build here), fall back to `main`; if neither exists the + # Dockerfile default makes the carry-forward a no-op. + run: | + for candidate in "${IMAGE_REPO}:${TAG}" "${IMAGE_REPO}:main"; do + if docker manifest inspect "$candidate" >/dev/null 2>&1; then + echo "prev_image=${candidate}" >> "$GITHUB_OUTPUT" + echo "Using previous image: ${candidate}" + exit 0 + fi + done + echo "No previous image found; asset carry-forward will be a no-op" + env: + IMAGE_REPO: ${{ steps.set_tags.outputs.image_repo }} + TAG: ${{ steps.get_tag.outputs.tag }} + - name: 🐳 Build image and push to GitHub Container Registry id: build_push uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 @@ -130,6 +149,7 @@ jobs: SENTRY_RELEASE=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }} SENTRY_ORG=triggerdev SENTRY_PROJECT=trigger-cloud + PREV_IMAGE=${{ steps.prev_image.outputs.prev_image || 'builder' }} secrets: | sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.server-changes/stale-deploy-asset-recovery.md b/.server-changes/stale-deploy-asset-recovery.md new file mode 100644 index 00000000000..f77ae56d9d1 --- /dev/null +++ b/.server-changes/stale-deploy-asset-recovery.md @@ -0,0 +1,10 @@ +--- +area: webapp +type: fix +--- + +Fix intermittent unstyled/broken pages during rolling deploys. Documents are content-hash-coupled to the build baked into the image that rendered them, so a stale document requests `/build` asset URLs that 404 on the new image (unstyled page, dead buttons from partial hydration). Three changes: + +- Docker images now carry forward the previous published image's content-hashed `/build` assets (new `PREV_IMAGE` build arg, resolved in the publish workflow; no-op default for forks/local/self-hosted builds), pruned after 14 days β€” stale clients keep resolving their asset URLs across deploys. +- Document responses default to `Cache-Control: no-cache` so browsers always revalidate HTML. +- An inline recovery script in the document head force-reloads (at most twice, 30s apart) when a `/build` stylesheet/script fails to load or a dynamic chunk import rejects β€” covers rollbacks, retention-window overruns, and builds without a previous image. diff --git a/apps/webapp/app/components/StaleAssetRecovery.tsx b/apps/webapp/app/components/StaleAssetRecovery.tsx new file mode 100644 index 00000000000..6f55f1461d8 --- /dev/null +++ b/apps/webapp/app/components/StaleAssetRecovery.tsx @@ -0,0 +1,56 @@ +// Self-heals stale-deploy asset failures: when a /build asset 404s (the +// client holds HTML from a previous build), reload at most twice, 30s apart. +// Must render before so the listener precedes the stylesheet. +const script = `(function () { + var KEY = "trigger:assetReload"; + var MIN_INTERVAL = 30000; + var RESET_AFTER = 300000; + var MAX_ATTEMPTS = 2; + var scheduled = false; + function reload() { + if (scheduled) return; + try { + var state = JSON.parse(sessionStorage.getItem(KEY) || "{}"); + var elapsed = Date.now() - (state.t || 0); + var attempts = elapsed > RESET_AFTER ? 0 : state.n || 0; + if (attempts >= MAX_ATTEMPTS) return; + // Failures fire once, at page load β€” delay the retry instead of + // dropping it, so an in-progress deploy gets time to finish. + var wait = Math.max(0, MIN_INTERVAL - elapsed); + sessionStorage.setItem(KEY, JSON.stringify({ t: Date.now() + wait, n: attempts + 1 })); + scheduled = true; + setTimeout(function () { + location.reload(); + }, wait); + } catch (e) { + return; + } + } + window.addEventListener( + "error", + function (event) { + var el = event.target; + if (!el || el === window) return; + var url = el.tagName === "LINK" ? el.href : el.tagName === "SCRIPT" ? el.src : null; + if (url && url.indexOf("/build/") !== -1) reload(); + }, + true + ); + window.addEventListener("unhandledrejection", function (event) { + var message = event.reason && event.reason.message; + if ( + typeof message === "string" && + /dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message) + ) { + reload(); + } + }); +})();`; + +export function StaleAssetRecovery() { + if (process.env.NODE_ENV !== "production") { + return null; + } + + return