diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index 92382b05fb..d0409dadb5 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -210,6 +210,10 @@ jobs: platforms: linux/amd64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + GIT_SHA=${{ steps.vars.outputs.commit_sha }} + secrets: | + NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }} # Multi-source cache strategy: # - Read from: main cache (stable baseline) + branch cache (incremental) # - Write to: branch-specific cache (or main if on main branch) @@ -220,6 +224,8 @@ jobs: - name: Build and push Heroku targets if: inputs.heroku_app != '' + env: + NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }} run: | set -e IMAGE_NAME="${{ steps.vars.outputs.image_name }}" @@ -232,6 +238,8 @@ jobs: --platform linux/amd64 \ --target "$target" \ --tag "registry.heroku.com/${{ inputs.heroku_app }}/$target" \ + --build-arg GIT_SHA=${{ steps.vars.outputs.commit_sha }} \ + --secret id=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY \ --push \ --cache-from "type=registry,ref=${IMAGE_NAME}:buildcache-main" \ --cache-from "type=registry,ref=${IMAGE_NAME}:buildcache-${CACHE_SCOPE}" \ diff --git a/.github/workflows/pr_preview.yml b/.github/workflows/pr_preview.yml index 304515fda8..8dacfbd099 100644 --- a/.github/workflows/pr_preview.yml +++ b/.github/workflows/pr_preview.yml @@ -330,6 +330,7 @@ jobs: PUBLIC_FRONTEND_SENTRY_DSN="${{ vars.PUBLIC_FRONTEND_SENTRY_DSN }}" \ SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \ PUBLIC_POSTHOG_KEY="${{ vars.PUBLIC_POSTHOG_KEY }}" \ + NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}" \ --app "${APP_NAME}" \ --stage diff --git a/Dockerfile b/Dockerfile index 87a39abfa3..97dc915264 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,9 @@ RUN . venv/bin/activate && ./manage.py collectstatic --noinput FROM base AS frontend_build WORKDIR /app +ARG GIT_SHA +ENV GIT_SHA=$GIT_SHA + # Copy only frontend source files COPY front_end/ /app/front_end/ @@ -71,8 +74,10 @@ COPY --from=frontend_deps /app/front_end/node_modules /app/front_end/node_module # Build frontend ENV NODE_ENV=production -RUN cd front_end \ - && NODE_OPTIONS=--max-old-space-size=8192 npm run build \ +RUN --mount=type=secret,id=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY \ + cd front_end \ + && NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=$(cat /run/secrets/NEXT_SERVER_ACTIONS_ENCRYPTION_KEY) \ + NODE_OPTIONS=--max-old-space-size=8192 npm run build \ && rm -rf .next/cache # Inject Sentry sourcemaps @@ -82,6 +87,8 @@ RUN cd front_end && npx sentry-cli sourcemaps inject .next # FINAL ENVIRONMENT # ============================================================ FROM base AS final_env +ARG GIT_SHA +ENV GIT_SHA=$GIT_SHA RUN mkdir -p /app && chown 1001:0 /app WORKDIR /app diff --git a/front_end/next.config.mjs b/front_end/next.config.mjs index 3fbfe17b9a..b758328126 100644 --- a/front_end/next.config.mjs +++ b/front_end/next.config.mjs @@ -6,8 +6,13 @@ const withNextIntl = createNextIntlPlugin(); const AWS_STORAGE_BUCKET_NAME = process.env.AWS_STORAGE_BUCKET_NAME; const AWS_S3_CUSTOM_DOMAIN = process.env.AWS_S3_CUSTOM_DOMAIN; +// This enables automatic cache busting and automatic hard reloads when the build id changes. +const BUILD_ID = process.env.GIT_SHA || "development"; + /** @type {import("next").NextConfig} */ const nextConfig = { + generateBuildId: () => BUILD_ID, + deploymentId: BUILD_ID, trailingSlash: true, productionBrowserSourceMaps: true, env: { @@ -33,21 +38,21 @@ const nextConfig = { }, ...(AWS_STORAGE_BUCKET_NAME ? [ - { - protocol: "https", - hostname: `${AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com`, - pathname: "/**", - }, - ] + { + protocol: "https", + hostname: `${AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com`, + pathname: "/**", + }, + ] : []), ...(AWS_S3_CUSTOM_DOMAIN ? [ - { - protocol: "https", - hostname: AWS_S3_CUSTOM_DOMAIN, - pathname: "/**", - }, - ] + { + protocol: "https", + hostname: AWS_S3_CUSTOM_DOMAIN, + pathname: "/**", + }, + ] : []), ], }, @@ -83,44 +88,20 @@ const nextConfig = { }, ]; }, - webpack: (config, { buildId, webpack }) => { - // propagate buildId to environment so we could trigger prompt message on outdated version - config.plugins.push( - new webpack.DefinePlugin({ - "process.env.BUILD_ID": JSON.stringify(buildId), - }) - ); - - config.output.filename = config.output.filename.replace( - "[chunkhash]", - buildId - ); - - // Grab the existing rule that handles SVG imports - const fileLoaderRule = config.module.rules.find((rule) => - rule.test?.test?.(".svg") - ); - - config.module.rules.push( - // Reapply the existing rule, but only for svg imports ending in ?url - { - ...fileLoaderRule, - test: /\.svg$/i, - resourceQuery: /url/, // *.svg?url - }, - // Convert all other *.svg imports to React components - { - test: /\.svg$/i, - issuer: fileLoaderRule.issuer, - resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url - use: ["@svgr/webpack"], - } - ); - - // Modify the file loader rule to ignore *.svg, since we have it handled now. - fileLoaderRule.exclude = /\.svg$/i; - - return config; + turbopack: { + rules: { + "*.svg": [ + { + condition: { query: /\burl\b/ }, + type: "asset", + }, + { + condition: { not: { query: /\burl\b/ } }, + loaders: ["@svgr/webpack"], + as: "*.js", + }, + ], + }, }, }; diff --git a/front_end/package.json b/front_end/package.json index 8834a42121..bfd5d231cf 100644 --- a/front_end/package.json +++ b/front_end/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build --webpack", + "build": "next build", "start": "next start", "lint": "run-p lint:*", "lint:js": "eslint .", diff --git a/front_end/src/app/(api)/app-version/route.ts b/front_end/src/app/(api)/app-version/route.ts deleted file mode 100644 index 1aff956fd6..0000000000 --- a/front_end/src/app/(api)/app-version/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Used for accessing the app version on server. - */ -export async function GET() { - return Response.json({ - buildId: process.env.BUILD_ID, - }); -} diff --git a/front_end/src/app/(futureeval)/futureeval/layout.tsx b/front_end/src/app/(futureeval)/futureeval/layout.tsx index 26db6bd5e8..1b9c84bd22 100644 --- a/front_end/src/app/(futureeval)/futureeval/layout.tsx +++ b/front_end/src/app/(futureeval)/futureeval/layout.tsx @@ -3,7 +3,6 @@ import "@fortawesome/fontawesome-svg-core/styles.css"; import type { Metadata } from "next"; import CookiesBanner from "@/app/(main)/components/cookies_banner"; -import VersionChecker from "@/app/(main)/components/version_checker"; import { defaultDescription } from "@/constants/metadata"; import FutureEvalFooter from "./components/futureeval-footer"; @@ -31,7 +30,6 @@ export default function FutureEvalLayout({
{children}
- ); } diff --git a/front_end/src/app/(main)/components/version_checker.tsx b/front_end/src/app/(main)/components/version_checker.tsx deleted file mode 100644 index ef9f1863f0..0000000000 --- a/front_end/src/app/(main)/components/version_checker.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"use client"; -import { isNil } from "lodash"; -import { useRouter } from "next/navigation"; -import { FC, useCallback, useEffect, useRef, useState } from "react"; - -import BaseModal from "@/components/base_modal"; -import Button from "@/components/ui/button"; -import { logError } from "@/utils/core/errors"; - -const POLLING_INTERVAL = 1000 * 30; // 30 seconds - -const VersionChecker: FC = () => { - const router = useRouter(); - - const [showRefreshPrompt, setShowRefreshPrompt] = useState(false); - const didRefreshPrompting = useRef(false); - const closeRefreshPrompt = () => { - setShowRefreshPrompt(false); - }; - - const checkVersion = useCallback(async () => { - if (didRefreshPrompting.current) { - // if we already prompted the user, don't check again - return; - } - - const clientVersion = process.env.BUILD_ID; - if (isNil(clientVersion)) { - console.warn("Can't check app version. Client version is missing."); - return; - } - - const serverVersion = await fetchServerVersion(); - if (isNil(serverVersion)) { - console.warn("Can't check app version. Server version is missing."); - return; - } - - if (clientVersion !== serverVersion) { - setShowRefreshPrompt(true); - didRefreshPrompting.current = true; - } - }, []); - - useEffect(() => { - void checkVersion(); - - const interval = setInterval(() => { - void checkVersion(); - }, POLLING_INTERVAL); - - return () => { - clearInterval(interval); - }; - }, [checkVersion]); - - return ( - - A new version of Metaculus is available. To avoid errors, please refresh - this page. Draft comments and posts are saved and will persist after the - refresh. -
- -
-
- ); -}; - -// Track consecutive errors to avoid spamming Sentry -let consecutiveErrors = 0; -const ERROR_THRESHOLD = 3; - -const fetchServerVersion = async () => { - try { - const response = await fetch("/app-version/"); - - if (!response.ok) { - consecutiveErrors++; - if (consecutiveErrors >= ERROR_THRESHOLD) { - logError( - new Error( - `Failed to fetch app version ${ERROR_THRESHOLD} times in a row` - ), - { - message: "Error fetching app version", - } - ); - } - return null; - } - - // Reset counter on successful response - consecutiveErrors = 0; - const data = await response.json(); - return data?.buildId ?? null; - } catch (error) { - consecutiveErrors++; - if (consecutiveErrors >= ERROR_THRESHOLD) { - logError(error, { - message: "Error fetching app version", - }); - } - return null; - } -}; - -export default VersionChecker; diff --git a/front_end/src/app/(main)/layout.tsx b/front_end/src/app/(main)/layout.tsx index f0554e84d9..ddea50c5d5 100644 --- a/front_end/src/app/(main)/layout.tsx +++ b/front_end/src/app/(main)/layout.tsx @@ -12,7 +12,6 @@ import CookiesBanner from "./components/cookies_banner"; import Footer from "./components/footer"; import GlobalHeader from "./components/headers/global_header"; import ImpersonationBanner from "./components/impersonation_banner"; -import VersionChecker from "./components/version_checker"; config.autoAddCss = false; @@ -46,7 +45,6 @@ export default async function RootLayout({ )} - ); } diff --git a/front_end/src/app/(storefront)/layout.tsx b/front_end/src/app/(storefront)/layout.tsx index e9b1c8afe9..d895ba51ba 100644 --- a/front_end/src/app/(storefront)/layout.tsx +++ b/front_end/src/app/(storefront)/layout.tsx @@ -8,7 +8,6 @@ import { ForceLightProvider } from "@/contexts/force_light_context"; import StorefrontFooter from "./components/storefront_footer"; import FeedbackFloat from "../(main)/(home)/components/feedback_float"; import CookiesBanner from "../(main)/components/cookies_banner"; -import VersionChecker from "../(main)/components/version_checker"; config.autoAddCss = false; @@ -29,7 +28,6 @@ export default function StorefrontLayout({ - ); diff --git a/front_end/src/proxy.ts b/front_end/src/proxy.ts index 838f23b752..b1251c55d0 100644 --- a/front_end/src/proxy.ts +++ b/front_end/src/proxy.ts @@ -152,7 +152,7 @@ export const config = { // Ignores prefetch requests, all media files // And embedded urls source: - "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|questions/embed|experiments/embed|opengraph-image-|twitter-image-|app-version|.*\\..*).*)", + "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|questions/embed|experiments/embed|opengraph-image-|twitter-image-|.*\\..*).*)", missing: [ { type: "header", key: "next-router-prefetch" }, { type: "header", key: "purpose", value: "prefetch" }, diff --git a/front_end/src/sentry/options.ts b/front_end/src/sentry/options.ts index 511bafd2c3..e484b31900 100644 --- a/front_end/src/sentry/options.ts +++ b/front_end/src/sentry/options.ts @@ -17,11 +17,6 @@ export function buildSentryOptions( tracesSampler: (ctx) => { const name = ctx.name; - // Completely exclude app-version health checks - if (name.includes("/app-version")) { - return 0; - } - // Heavily reduce middleware traces - low informational value if (name.startsWith("middleware ")) { return 0.005;