Skip to content

Commit c872c9c

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 c872c9c

6 files changed

Lines changed: 124 additions & 0 deletions

File tree

‎.github/workflows/publish-webapp.yml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,29 @@ 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+
# The Dockerfile copies the previous release's content-hashed /build
119+
# assets into the new image so clients holding stale HTML during a
120+
# rolling deploy don't 404 on their asset URLs. Prefer the tag being
121+
# published (mutable tags like `main` still point at the previous
122+
# build here); fall back to `main` for tags that don't exist yet
123+
# (e.g. semver releases). If neither resolves (first build, forks),
124+
# no build-arg is passed and the Dockerfile default makes the
125+
# carry-forward a no-op.
126+
run: |
127+
for candidate in "${IMAGE_REPO}:${TAG}" "${IMAGE_REPO}:main"; do
128+
if docker manifest inspect "$candidate" >/dev/null 2>&1; then
129+
echo "prev_image=${candidate}" >> "$GITHUB_OUTPUT"
130+
echo "Using previous image: ${candidate}"
131+
exit 0
132+
fi
133+
done
134+
echo "No previous image found; asset carry-forward will be a no-op"
135+
env:
136+
IMAGE_REPO: ${{ steps.set_tags.outputs.image_repo }}
137+
TAG: ${{ steps.get_tag.outputs.tag }}
138+
116139
- name: 🐳 Build image and push to GitHub Container Registry
117140
id: build_push
118141
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
@@ -130,6 +153,7 @@ jobs:
130153
SENTRY_RELEASE=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }}
131154
SENTRY_ORG=triggerdev
132155
SENTRY_PROJECT=trigger-cloud
156+
PREV_IMAGE=${{ steps.prev_image.outputs.prev_image || 'builder' }}
133157
secrets: |
134158
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
135159
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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Recovers from stale-deploy asset failures. Documents reference
2+
// content-hashed /build assets that exist only in the image that rendered
3+
// them; during a rolling deploy a client can hold HTML whose asset URLs 404
4+
// on the new image (unstyled page, dead buttons from partial hydration).
5+
// This inline script force-reloads (at most twice, 30s apart, so a
6+
// persistently blocked asset can't cause a reload loop) so the client picks
7+
// up the current build instead of sitting broken. Must render before <Links /> so the
8+
// listener is installed before the stylesheet can fail.
9+
const script = `(function () {
10+
var KEY = "trigger:assetReload";
11+
var MIN_INTERVAL = 30000;
12+
var RESET_AFTER = 300000;
13+
var MAX_ATTEMPTS = 2;
14+
var scheduled = false;
15+
function reload() {
16+
if (scheduled) return;
17+
try {
18+
var state = JSON.parse(sessionStorage.getItem(KEY) || "{}");
19+
var elapsed = Date.now() - (state.t || 0);
20+
var attempts = elapsed > RESET_AFTER ? 0 : state.n || 0;
21+
if (attempts >= MAX_ATTEMPTS) return;
22+
// Asset failures fire once, at page load — inside the min interval when
23+
// this is already a reloaded page. Delay the retry instead of dropping
24+
// it, so a still-in-progress deploy gets time to finish.
25+
var wait = Math.max(0, MIN_INTERVAL - elapsed);
26+
sessionStorage.setItem(KEY, JSON.stringify({ t: Date.now() + wait, n: attempts + 1 }));
27+
scheduled = true;
28+
setTimeout(function () {
29+
location.reload();
30+
}, wait);
31+
} catch (e) {
32+
return;
33+
}
34+
}
35+
window.addEventListener(
36+
"error",
37+
function (event) {
38+
var el = event.target;
39+
if (!el || el === window) return;
40+
var url = el.tagName === "LINK" ? el.href : el.tagName === "SCRIPT" ? el.src : null;
41+
if (url && url.indexOf("/build/") !== -1) reload();
42+
},
43+
true
44+
);
45+
window.addEventListener("unhandledrejection", function (event) {
46+
var message = event.reason && event.reason.message;
47+
if (
48+
typeof message === "string" &&
49+
/dynamically imported module|Importing a module script failed|ChunkLoadError/i.test(message)
50+
) {
51+
reload();
52+
}
53+
});
54+
})();`;
55+
56+
export function StaleAssetRecovery() {
57+
if (process.env.NODE_ENV !== "production") {
58+
return null;
59+
}
60+
61+
return <script dangerouslySetInnerHTML={{ __html: script }} />;
62+
}

‎apps/webapp/app/entry.server.tsx‎

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

59+
// Documents reference content-hashed /build assets that only exist in the
60+
// image that rendered them. A cached document served after a deploy points
61+
// at asset URLs that 404 (unstyled page, broken hydration), so every
62+
// document must be revalidated. Route headers that set their own policy win.
63+
if (!responseHeaders.has("Cache-Control")) {
64+
responseHeaders.set("Cache-Control", "no-cache");
65+
}
66+
5967
if (url.pathname.startsWith("/login")) {
6068
responseHeaders.set("X-Frame-Options", "SAMEORIGIN");
6169
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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
ARG NODE_IMAGE=node:22.23.1-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4
2+
# Previously published webapp image (e.g. ghcr.io/triggerdotdev/trigger.dev:main).
3+
# Its content-hashed /build assets are carried into this image so clients
4+
# holding HTML from the previous release keep resolving their asset URLs
5+
# during and after a rolling deploy. Defaults to the builder stage, which
6+
# makes the carry-forward a no-op (first build, forks, local builds).
7+
ARG PREV_IMAGE=builder
28

39
FROM golang:1.26-alpine AS goose_builder
410
RUN go install github.com/pressly/goose/v3/cmd/goose@v3.27.1
@@ -74,6 +80,10 @@ RUN --mount=type=secret,id=sentry_auth_token \
7480
SENTRY_AUTH_TOKEN=$(cat /run/secrets/sentry_auth_token) \
7581
pnpm run build --filter=webapp...
7682

83+
# Source of the previous release's content-hashed browser assets (see the
84+
# PREV_IMAGE comment at the top of this file).
85+
FROM ${PREV_IMAGE} AS prev-assets
86+
7787
# Runner
7888
FROM ${NODE_IMAGE} AS runner
7989
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 +97,14 @@ COPY --from=production-deps --chown=node:node /triggerdotdev .
8797
COPY --from=dev-deps --chown=node:node /triggerdotdev/internal-packages/database/generated ./internal-packages/database/generated
8898
COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/build/server.js ./apps/webapp/build/server.js
8999
COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/build ./apps/webapp/build
100+
# Previous release's hashed assets first, current build's on top (identical
101+
# hashes overwrite in place). Files older than 14 days are pruned so images
102+
# don't accumulate every build ever shipped; COPY preserves mtimes, so age is
103+
# measured from when an asset was originally built.
104+
COPY --from=prev-assets --chown=node:node /triggerdotdev/apps/webapp/public/build ./apps/webapp/public/build
90105
COPY --from=builder --chown=node:node /triggerdotdev/apps/webapp/public ./apps/webapp/public
106+
RUN find ./apps/webapp/public/build -type f -mtime +14 -delete \
107+
&& find ./apps/webapp/public/build -type d -empty -delete
91108
COPY --from=builder --chown=node:node /triggerdotdev/scripts ./scripts
92109

93110
# Goose and schemas

0 commit comments

Comments
 (0)