@@ -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
0 commit comments