Skip to content

Commit 5f61600

Browse files
committed
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.
1 parent 0f349dd commit 5f61600

6 files changed

Lines changed: 105 additions & 0 deletions

File tree

.github/workflows/publish-webapp.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,25 @@ jobs:
113113
username: ${{ github.repository_owner }}
114114
password: ${{ secrets.GITHUB_TOKEN }}
115115

116+
- name: 🔎 Resolve previous image for asset carry-forward
117+
id: prev_image
118+
# Previous image whose /build assets the Dockerfile carries forward.
119+
# Prefer the tag being published (mutable tags still point at the
120+
# previous build here), fall back to `main`; if neither exists the
121+
# Dockerfile default makes the carry-forward a no-op.
122+
run: |
123+
for candidate in "${IMAGE_REPO}:${TAG}" "${IMAGE_REPO}:main"; do
124+
if docker manifest inspect "$candidate" >/dev/null 2>&1; then
125+
echo "prev_image=${candidate}" >> "$GITHUB_OUTPUT"
126+
echo "Using previous image: ${candidate}"
127+
exit 0
128+
fi
129+
done
130+
echo "No previous image found; asset carry-forward will be a no-op"
131+
env:
132+
IMAGE_REPO: ${{ steps.set_tags.outputs.image_repo }}
133+
TAG: ${{ steps.get_tag.outputs.tag }}
134+
116135
- name: 🐳 Build image and push to GitHub Container Registry
117136
id: build_push
118137
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
@@ -130,6 +149,7 @@ jobs:
130149
SENTRY_RELEASE=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }}
131150
SENTRY_ORG=triggerdev
132151
SENTRY_PROJECT=trigger-cloud
152+
PREV_IMAGE=${{ steps.prev_image.outputs.prev_image || 'builder' }}
133153
secrets: |
134154
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
135155
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
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:
7+
8+
- 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.
9+
- Document responses default to `Cache-Control: no-cache` so browsers always revalidate HTML.
10+
- 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.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Self-heals stale-deploy asset failures: when a /build asset 404s (the
2+
// client holds HTML from a previous build), reload at most twice, 30s apart.
3+
// Must render before <Links /> so the listener precedes the stylesheet.
4+
const script = `(function () {
5+
var KEY = "trigger:assetReload";
6+
var MIN_INTERVAL = 30000;
7+
var RESET_AFTER = 300000;
8+
var MAX_ATTEMPTS = 2;
9+
var scheduled = false;
10+
function reload() {
11+
if (scheduled) return;
12+
try {
13+
var state = JSON.parse(sessionStorage.getItem(KEY) || "{}");
14+
var elapsed = Date.now() - (state.t || 0);
15+
var attempts = elapsed > RESET_AFTER ? 0 : state.n || 0;
16+
if (attempts >= MAX_ATTEMPTS) return;
17+
// Failures fire once, at page load — delay the retry instead of
18+
// dropping it, so an in-progress deploy gets time to finish.
19+
var wait = Math.max(0, MIN_INTERVAL - elapsed);
20+
sessionStorage.setItem(KEY, JSON.stringify({ t: Date.now() + wait, n: attempts + 1 }));
21+
scheduled = true;
22+
setTimeout(function () {
23+
location.reload();
24+
}, wait);
25+
} catch (e) {
26+
return;
27+
}
28+
}
29+
window.addEventListener(
30+
"error",
31+
function (event) {
32+
var el = event.target;
33+
if (!el || el === window) return;
34+
var url = el.tagName === "LINK" ? el.href : el.tagName === "SCRIPT" ? el.src : null;
35+
if (url && url.indexOf("/build/") !== -1) reload();
36+
},
37+
true
38+
);
39+
window.addEventListener("unhandledrejection", function (event) {
40+
var message = event.reason && event.reason.message;
41+
if (
42+
typeof message === "string" &&
43+
/dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message)
44+
) {
45+
reload();
46+
}
47+
});
48+
})();`;
49+
50+
export function StaleAssetRecovery() {
51+
if (process.env.NODE_ENV !== "production") {
52+
return null;
53+
}
54+
55+
return <script dangerouslySetInnerHTML={{ __html: script }} />;
56+
}

apps/webapp/app/entry.server.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ export default function handleRequest(
5656
) {
5757
const url = new URL(request.url);
5858

59+
// Stale documents reference /build asset hashes that 404 after a deploy —
60+
// always revalidate HTML. Route-set headers win.
61+
if (!responseHeaders.has("Cache-Control")) {
62+
responseHeaders.set("Cache-Control", "no-cache");
63+
}
64+
5965
if (url.pathname.startsWith("/login")) {
6066
responseHeaders.set("X-Frame-Options", "SAMEORIGIN");
6167
responseHeaders.set("Content-Security-Policy", "frame-ancestors 'self'");

apps/webapp/app/root.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { ToastMessage } from "~/models/message.server";
77
import { commitSession, getSession } from "~/models/message.server";
88
import tailwindStylesheetUrl from "~/tailwind.css";
99
import { RouteErrorDisplay } from "./components/ErrorDisplay";
10+
import { StaleAssetRecovery } from "./components/StaleAssetRecovery";
1011
import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayout";
1112
import { ShortcutsProvider } from "./components/primitives/ShortcutsProvider";
1213
import { Toast } from "./components/primitives/Toast";
@@ -97,6 +98,7 @@ export function ErrorBoundary() {
9798
<head>
9899
<meta charSet="utf-8" />
99100

101+
<StaleAssetRecovery />
100102
<Meta />
101103
<Links />
102104
</head>
@@ -123,6 +125,7 @@ export default function App() {
123125
<>
124126
<html lang="en" className="h-full">
125127
<head>
128+
<StaleAssetRecovery />
126129
<Meta />
127130
<Links />
128131
</head>

docker/Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
ARG NODE_IMAGE=node:22.23.1-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4
2+
# Previously published image whose /build assets are carried forward so
3+
# stale clients survive a rolling deploy. Default (builder) is a no-op.
4+
ARG PREV_IMAGE=builder
25

36
FROM golang:1.26-alpine AS goose_builder
47
RUN go install github.com/pressly/goose/v3/cmd/goose@v3.27.1
@@ -74,6 +77,9 @@ RUN --mount=type=secret,id=sentry_auth_token \
7477
SENTRY_AUTH_TOKEN=$(cat /run/secrets/sentry_auth_token) \
7578
pnpm run build --filter=webapp...
7679

80+
# Previous release's hashed browser assets (see PREV_IMAGE above).
81+
FROM ${PREV_IMAGE} AS prev-assets
82+
7783
# Runner
7884
FROM ${NODE_IMAGE} AS runner
7985
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends openssl netcat-openbsd ca-certificates && rm -rf /var/lib/apt/lists/*
@@ -87,7 +93,11 @@ COPY --from=production-deps --chown=node:node /triggerdotdev .
8793
COPY --from=dev-deps --chown=node:node /triggerdotdev/internal-packages/database/generated ./internal-packages/database/generated
8894
COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/build/server.js ./apps/webapp/build/server.js
8995
COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/build ./apps/webapp/build
96+
# Previous assets first, current build on top; prune >14d (COPY keeps mtimes).
97+
COPY --from=prev-assets --chown=node:node /triggerdotdev/apps/webapp/public/build ./apps/webapp/public/build
9098
COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/public ./apps/webapp/public
99+
RUN find ./apps/webapp/public/build -type f -mtime +14 -delete \
100+
&& find ./apps/webapp/public/build -type d -empty -delete
91101
COPY --from=builder --chown=node:node /triggerdotdev/scripts ./scripts
92102

93103
# Goose and schemas

0 commit comments

Comments
 (0)