Skip to content

Commit 38db5c7

Browse files
authored
perf(build): seed Next's .next/cache from an image so the web build is incremental (#1044)
1 parent 2bf8357 commit 38db5c7

2 files changed

Lines changed: 160 additions & 16 deletions

File tree

‎.github/workflows/release-images.yml‎

Lines changed: 118 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -212,36 +212,138 @@ jobs:
212212
echo "::add-mask::$ANON"
213213
echo "ANON_KEY=$ANON" >> "$GITHUB_ENV"
214214
215+
# Next's incremental build cache, carried between runs as an image.
216+
#
217+
# `cache-from: type=gha` below only restores LAYERS, and `COPY . .` in the
218+
# Dockerfile invalidates every layer below it on any source change, so the
219+
# layer cache never spares us `npm run build` -- it only spares npm ci and
220+
# apt. Seeding .next/cache lets the build itself be incremental, which is
221+
# the part that actually takes the time.
222+
#
223+
# A BuildKit `--mount=type=cache` cannot do this job: setup-buildx-action
224+
# creates a fresh docker-container builder every run, so a mount-backed
225+
# cache is empty every run. An image is pulled, so it survives.
226+
#
227+
# Falls back to `next-cache-empty` (a stage inside the Dockerfile) when the
228+
# tag does not exist yet -- first run on a new branch, or after a manual
229+
# delete. Never fails the build for a missing cache.
230+
#
231+
# Every dynamic value reaches the shell through `env:`, never through a
232+
# ${{ }} expansion inside `run:`. GitHub expands those before the shell
233+
# parses the script, so a ref name or dispatch input containing shell
234+
# syntax would execute here (CWE-78). REGISTRY and REPO_OWNER are already
235+
# workflow-level env, so they are plain shell variables too.
236+
- name: Resolve Next build cache image
237+
id: nextcache
238+
env:
239+
REF_NAME: ${{ github.ref_name }}
240+
run: |
241+
set -euo pipefail
242+
# Docker tags allow [A-Za-z0-9._-] and may not start with . or -, so a
243+
# ref like `feature/foo` or a v* tag with `+build` is not usable as-is.
244+
# Left unsanitised the failure is SILENT in both directions: inspect
245+
# fails and reads as a cold miss, and the push fails into the export
246+
# step's continue-on-error, so such a branch rebuilds cold forever.
247+
# Matches the `/` -> `-` convention the version logic already uses.
248+
SAFE_REF="$(printf '%s' "$REF_NAME" | tr -c 'A-Za-z0-9._-' '-')"
249+
SAFE_REF="$(printf '%s' "$SAFE_REF" | sed -E 's/^[.-]+//')"
250+
SAFE_REF="${SAFE_REF:0:128}"
251+
[ -n "$SAFE_REF" ] || SAFE_REF="unnamed"
252+
REF="${REGISTRY}/${REPO_OWNER}/web-nextcache:${SAFE_REF}"
253+
printf 'push=%s\n' "$REF" >> "$GITHUB_OUTPUT"
254+
if docker buildx imagetools inspect "$REF" >/dev/null 2>&1; then
255+
printf 'seed=%s\n' "$REF" >> "$GITHUB_OUTPUT"
256+
echo "seeding .next/cache from $REF"
257+
else
258+
printf 'seed=%s\n' "next-cache-empty" >> "$GITHUB_OUTPUT"
259+
echo "no cache image at $REF yet; building cold"
260+
fi
261+
262+
# Composed once, then used by BOTH build steps below. They must match
263+
# exactly: the cache-export build reuses the `builder` stage from the web
264+
# build via the builder's in-run cache, and any difference in these args
265+
# changes that stage's layers and silently recompiles the whole app.
266+
#
267+
# Same rule as above: values arrive via `env:` and are referenced as shell
268+
# variables. ANON_KEY is already in the environment from the step that
269+
# read it out of the cluster. The heredoc delimiter is randomised because
270+
# a value containing the literal delimiter on its own line would otherwise
271+
# let a caller append arbitrary entries to $GITHUB_ENV.
272+
- name: Compose web build args
273+
env:
274+
WEB_HOST: ${{ steps.hosts.outputs.web }}
275+
API_HOST: ${{ steps.hosts.outputs.api }}
276+
CHANNEL: ${{ steps.hosts.outputs.channel }}
277+
HOST_SUFFIX: ${{ steps.hosts.outputs.suffix }}
278+
VERSION: ${{ needs.meta.outputs.version }}
279+
GIT_SHA: ${{ github.sha }}
280+
SEED_IMAGE: ${{ steps.nextcache.outputs.seed }}
281+
OAUTH_CLIENT_ID: ${{ secrets.STAGING_GITHUB_OAUTH_CLIENT_ID }}
282+
BUGSINK_DSN: ${{ secrets.STAGING_BUGSINK_DSN }}
283+
BUGSINK_HOST: ${{ secrets.STAGING_BUGSINK_HOST }}
284+
POSTHOG_KEY: ${{ secrets.STAGING_POSTHOG_KEY }}
285+
POSTHOG_HOST: ${{ secrets.STAGING_POSTHOG_HOST }}
286+
POSTHOG_UI_HOST: ${{ secrets.STAGING_POSTHOG_UI_HOST }}
287+
run: |
288+
set -euo pipefail
289+
DELIM="ARGS_EOF_$(openssl rand -hex 16)"
290+
{
291+
printf '%s<<%s\n' 'WEB_BUILD_ARGS' "$DELIM"
292+
printf '%s\n' "NEXT_PUBLIC_PAWTOGRADER_WEB_URL=$WEB_HOST"
293+
printf '%s\n' "NEXT_PUBLIC_SUPABASE_URL=$API_HOST"
294+
printf '%s\n' "NEXT_PUBLIC_SUPABASE_ANON_KEY=$ANON_KEY"
295+
printf '%s\n' "NEXT_PUBLIC_GITHUB_OAUTH_CLIENT_ID=$OAUTH_CLIENT_ID"
296+
printf '%s\n' "NEXT_PUBLIC_BUGSINK_DSN=$BUGSINK_DSN"
297+
printf '%s\n' "NEXT_PUBLIC_BUGSINK_HOST=$BUGSINK_HOST"
298+
printf '%s\n' "NEXT_PUBLIC_POSTHOG_KEY=$POSTHOG_KEY"
299+
printf '%s\n' "NEXT_PUBLIC_POSTHOG_HOST=$POSTHOG_HOST"
300+
printf '%s\n' "NEXT_PUBLIC_POSTHOG_UI_HOST=$POSTHOG_UI_HOST"
301+
printf '%s\n' "NEXT_PUBLIC_ENABLE_SIGNUPS=false"
302+
printf '%s\n' "NEXT_PUBLIC_GIT_COMMIT_SHA=$GIT_SHA"
303+
printf '%s\n' "NEXT_PUBLIC_PAWTOGRADER_CHANNEL=$CHANNEL"
304+
printf '%s\n' "NEXT_PUBLIC_CHANNEL_HOST_SUFFIX=$HOST_SUFFIX"
305+
printf '%s\n' "SENTRY_RELEASE=$VERSION"
306+
printf '%s\n' "SUPABASE_URL=$API_HOST"
307+
printf '%s\n' "NEXT_CACHE_IMAGE=$SEED_IMAGE"
308+
printf '%s\n' "$DELIM"
309+
} >> "$GITHUB_ENV"
310+
215311
- name: Build & push web image
216312
uses: docker/build-push-action@v6
217313
with:
218314
context: .
219315
file: Dockerfile
220316
push: true
221317
tags: ${{ format('{0}/{1}/web:{2}', env.REGISTRY, env.REPO_OWNER, needs.meta.outputs.version) }}
222-
build-args: |
223-
NEXT_PUBLIC_PAWTOGRADER_WEB_URL=${{ steps.hosts.outputs.web }}
224-
NEXT_PUBLIC_SUPABASE_URL=${{ steps.hosts.outputs.api }}
225-
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ env.ANON_KEY }}
226-
NEXT_PUBLIC_GITHUB_OAUTH_CLIENT_ID=${{ secrets.STAGING_GITHUB_OAUTH_CLIENT_ID }}
227-
NEXT_PUBLIC_BUGSINK_DSN=${{ secrets.STAGING_BUGSINK_DSN }}
228-
NEXT_PUBLIC_BUGSINK_HOST=${{ secrets.STAGING_BUGSINK_HOST }}
229-
NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.STAGING_POSTHOG_KEY }}
230-
NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.STAGING_POSTHOG_HOST }}
231-
NEXT_PUBLIC_POSTHOG_UI_HOST=${{ secrets.STAGING_POSTHOG_UI_HOST }}
232-
NEXT_PUBLIC_ENABLE_SIGNUPS=false
233-
NEXT_PUBLIC_GIT_COMMIT_SHA=${{ github.sha }}
234-
NEXT_PUBLIC_PAWTOGRADER_CHANNEL=${{ steps.hosts.outputs.channel }}
235-
NEXT_PUBLIC_CHANNEL_HOST_SUFFIX=${{ steps.hosts.outputs.suffix }}
236-
SENTRY_RELEASE=${{ needs.meta.outputs.version }}
237-
SUPABASE_URL=${{ steps.hosts.outputs.api }}
318+
build-args: ${{ env.WEB_BUILD_ARGS }}
238319
labels: |
239320
org.opencontainers.image.source=https://github.com/${{ github.repository }}
240321
org.opencontainers.image.revision=${{ github.sha }}
241322
org.opencontainers.image.version=${{ needs.meta.outputs.version }}
242323
cache-from: type=gha,scope=web
243324
cache-to: type=gha,scope=web,mode=max
244325
326+
# Publish this run's .next/cache for the next run to seed from.
327+
#
328+
# Same context, same Dockerfile, same build args, so the `builder` stage is
329+
# already in this builder's cache from the step above -- this exports the
330+
# cache directory rather than rebuilding anything. It is a scratch image
331+
# holding one directory, so the push is small next to what it saves.
332+
#
333+
# continue-on-error: the web image is already pushed by this point, and a
334+
# failure to store a cache must never fail a release.
335+
- name: Build & push Next build cache
336+
continue-on-error: true
337+
uses: docker/build-push-action@v6
338+
with:
339+
context: .
340+
file: Dockerfile
341+
target: next-cache-export
342+
push: true
343+
tags: ${{ steps.nextcache.outputs.push }}
344+
build-args: ${{ env.WEB_BUILD_ARGS }}
345+
cache-from: type=gha,scope=web
346+
245347
- name: Tag additional refs
246348
run: |
247349
set -euo pipefail

‎Dockerfile‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
# Seed for Next's incremental build cache (.next/cache), supplied as an IMAGE
2+
# rather than a BuildKit cache mount.
3+
#
4+
# A `--mount=type=cache` lives in the builder's local state, and CI here creates
5+
# a fresh `docker-container` buildx builder on every run (both the
6+
# pawtograder-ci runner set and Khoury's pawtograder-infra-runner-set), so such
7+
# a mount is empty every time and buys nothing. A registry image is pulled, so
8+
# it survives an ephemeral builder.
9+
#
10+
# Defaults to the `next-cache-empty` stage below, which is what a clean build
11+
# gets: BuildKit resolves `FROM ${NEXT_CACHE_IMAGE}` to a STAGE when the value
12+
# names one, and to a registry image otherwise. So this is a no-op unless a
13+
# caller passes a published cache image.
14+
ARG NEXT_CACHE_IMAGE=next-cache-empty
15+
116
FROM node:22-bookworm-slim AS deps
217
WORKDIR /app
318
COPY package.json package-lock.json ./
@@ -17,6 +32,13 @@ RUN npm config set fetch-retries 5 \
1732
done \
1833
&& test "$success" -eq 1
1934

35+
# Empty fallback, so a build that passes no NEXT_CACHE_IMAGE still resolves.
36+
# Reuses the node base that deps already pulled, so it costs a mkdir, not a pull.
37+
FROM node:22-bookworm-slim AS next-cache-empty
38+
RUN mkdir -p /next-cache
39+
40+
FROM ${NEXT_CACHE_IMAGE} AS next-cache
41+
2042
FROM node:22-bookworm-slim AS builder
2143
WORKDIR /app
2244

@@ -38,6 +60,13 @@ RUN apt-get -o Acquire::Retries=3 update \
3860
COPY --from=deps /app/node_modules ./node_modules
3961
COPY . .
4062

63+
# Restore the previous build's .next/cache. This does NOT make the layer cache
64+
# hit -- `COPY . .` above already invalidates everything below it on any source
65+
# change, which is exactly why layer caching cannot speed this build up. What it
66+
# does is let `npm run build` reuse Next's own per-module compilation cache, so
67+
# only the modules that actually changed are recompiled.
68+
COPY --from=next-cache /next-cache ./.next/cache
69+
4170
# Build-time args that Next.js inlines into the client bundle
4271
ARG NEXT_PUBLIC_SUPABASE_URL=""
4372
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY=""
@@ -142,6 +171,19 @@ RUN --mount=type=secret,id=sentry_auth_token \
142171
fi; \
143172
NODE_OPTIONS=--max-old-space-size=8000 npm run build
144173

174+
# Next always writes .next/cache, but make that explicit so the export stage
175+
# below cannot fail on a configuration that skips it.
176+
RUN mkdir -p /app/.next/cache
177+
178+
# Publishable cache image: nothing but the cache directory, on scratch. Push
179+
# this to a `:<branch>` tag and feed it back as NEXT_CACHE_IMAGE next run.
180+
#
181+
# Build it with `--target next-cache-export` in the SAME job as the web build:
182+
# the builder still holds this run's layers, so it exports the cache directory
183+
# without recompiling anything.
184+
FROM scratch AS next-cache-export
185+
COPY --from=builder /app/.next/cache /next-cache
186+
145187
FROM node:22-bookworm-slim AS runner
146188
WORKDIR /app
147189
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 NEXT_TELEMETRY_DISABLED=1

0 commit comments

Comments
 (0)