fix(deploy): prevent Next.js chunk version skew
Independent Staging Quality Gate / validate (push) Successful in 13m28s
Independent Staging Quality Gate / publish (push) Successful in 10m14s

This commit is contained in:
Jesse_Chen
2026-08-16 09:09:27 +08:00
parent 7b620c7a2e
commit 8ddcef4cf1
6 changed files with 42 additions and 2 deletions
+1 -1
View File
@@ -314,7 +314,7 @@ jobs:
set -euo pipefail
printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin
docker build -f deploy/railway-api.Dockerfile -t "$IMAGE_REPOSITORY:api-$GITEA_SHA" .
docker build -f deploy/railway-web.Dockerfile -t "$IMAGE_REPOSITORY:web-$GITEA_SHA" .
docker build --build-arg NEXT_DEPLOYMENT_ID="$GITEA_SHA" -f deploy/railway-web.Dockerfile -t "$IMAGE_REPOSITORY:web-$GITEA_SHA" .
docker push "$IMAGE_REPOSITORY:api-$GITEA_SHA"
docker push "$IMAGE_REPOSITORY:web-$GITEA_SHA"
@@ -114,6 +114,8 @@ jobs:
with:
context: .
file: deploy/railway-web.Dockerfile
build-args: |
NEXT_DEPLOYMENT_ID=${{ github.sha }}
push: true
tags: ghcr.io/jesse-ux/jyotisha-web:${{ github.sha }}
+9
View File
@@ -156,6 +156,15 @@ Gitea is the primary source repository and Actions control plane. Gitea automati
`Independent Staging Quality Gate` runs for relevant `pull_request` paths, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` publishes the API/web images and a run-bound artifact containing their `sha256` digests plus the allowlisted controller bundle. The publish job rechecks the current staging head and dispatches `.gitea/workflows/deploy-staging.yml` from `refs/heads/staging` with the exact SHA and source gate run ID. The deploy workflow waits for that gate's success, validates the artifact against the full 40-character commit, and deploys digest references rather than trusting discoverability tags.
Every Web image build must pass that same full 40-character commit as the Docker
build argument `NEXT_DEPLOYMENT_ID`. Next.js embeds it during `next build`, adds
the deployment marker to rendered documents and static asset requests, and can
replace a stale client-side navigation with a full navigation when a release
changes underneath an already-open browser tab. Never omit it, substitute a
branch name, or set it only as a runtime environment variable: doing so can
leave users on an old client runtime that fails to load the new release's
immutable chunks.
The staging env file must include these non-secret selectors so Compose cannot fall back to production paths:
```dotenv
+3 -1
View File
@@ -11,9 +11,11 @@ COPY frontend/scripts ./scripts
COPY frontend/db ./db
COPY frontend/supabase/migrations ./supabase/migrations
ARG NEXT_DEPLOYMENT_ID
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
ENV NEXT_DEPLOYMENT_ID=${NEXT_DEPLOYMENT_ID} \
NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
COPY SKILL.md /app/SKILL.md
COPY assets /app/assets
+14
View File
@@ -3422,3 +3422,17 @@
- 防复发:生产私有状态目录必须保持 `deploy:deploy 0700`,共享 lock 必须是普通非 symlink 文件且不可通过删除重建来“修复”;任何恢复流程失败都不得手填 `restore_verified=true` 或跳过恢复门禁。
- 相关记录:生产迁移 runbook、Run 1865、Run 1869、Run 1873、Run 1877
- 修复版本:待提交(精确 SHA 以重新发布后的远端分支与 production health 为准)
## BUG-204 | 生产发布后报告页因 Next.js chunk 版本偏斜落入通用错误页
- 状态:resolved(待 staging 与 production 精确 SHA 发布验收)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:发布切换期间已打开旧页面的用户进行客户端导航时,包括 `/reports` 等动态页面。
- 用户现象:生产 `/reports` 显示 `This page couldnt load`,只能点击 Reload 或手工刷新;同一登录会话刷新后报告列表恢复正常。
- 根因:生产 Next.js Web 构建未设置 `deploymentId`。旧标签页仍运行上一发布的客户端 runtime,在新镜像切换后进行客户端导航时请求了当前发布无法匹配的静态 chunk,触发 `ChunkLoadError` 并落入 Next.js 通用错误页。报告 API、登录态和报告数据本身没有失败。
- 修复:Web Docker build stage 接收并设置 `NEXT_DEPLOYMENT_ID`Gitea 主发布链与 GitHub fallback 都把各自完整 commit SHA 作为 build argument 注入。Next.js 因而在构建产物中写入 deployment marker,并为静态资源请求附加 deployment query,使跨发布的客户端版本不一致能够触发完整导航,而不是继续加载不匹配的 chunk。新增 workflow 合同测试,锁定 Dockerfile 和两个构建入口都不能丢失该参数。
- 验证:聚焦 workflow 合同测试 36/36 通过;使用固定 40 位测试 SHA 的真实 Next.js production build 成功,生成 HTML 含 `data-dpl-id`JS/CSS URL 含同一 `?dpl=` 参数。远端仍需完成 staging quality/deploy、release gate、生产恢复点与 restore drill、migration gate、production deploy,以及登录态 `/reports` 浏览器验收。
- 防复发:所有可发布 Web 镜像必须在 `next build` 阶段注入与镜像/发布清单相同的完整 Git SHA;仅设置容器运行时变量无效。发布验收必须覆盖已登录页面和静态资源 deployment marker,不能只看 `/api/health`
- 相关记录:`deploy/README.md``deploy/railway-web.Dockerfile`、staging/production exact-SHA release workflows
- 修复版本:待提交(精确 SHA 以重新发布后的远端分支与 production health 为准)
@@ -169,6 +169,11 @@ test("railway web image uses Next standalone runtime output", () => {
assert.match(config, /outputFileTracingRoot: repositoryRoot/);
assert.match(startupGuard, /process\.env\.NEXT_RUNTIME !== "nodejs"/);
assert.match(startupGuard, /verifyAllActiveSkillPackages\(\)/);
assert.match(dockerfile, /ARG NEXT_DEPLOYMENT_ID\n/);
assert.match(
dockerfile,
/ENV NEXT_DEPLOYMENT_ID=\$\{NEXT_DEPLOYMENT_ID\}[\s\S]*RUN npm run build/,
);
assert.match(dockerfile, /RUN npm run build\n/);
assert.doesNotMatch(dockerfile, /npm prune --omit=dev/);
assert.match(dockerfile, /COPY --from=build \/app\/frontend\/\.next\/standalone \/app/);
@@ -202,6 +207,10 @@ test("quality gate validates relevant changes once and publishes a digest manife
assert.doesNotMatch(workflow, /npm run test:db --prefix frontend/);
assert.match(workflow, /id: api_build[\s\S]*steps\.api_build\.outputs\.digest/);
assert.match(workflow, /id: web_build[\s\S]*steps\.web_build\.outputs\.digest/);
assert.match(
workflow,
/id: web_build[\s\S]*build-args: \|[\s\S]*NEXT_DEPLOYMENT_ID=\$\{\{ github\.sha \}\}/,
);
assert.match(workflow, /\^sha256:\[0-9a-f\]\{64\}\$/);
assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/);
assert.match(workflow, /name: staging-image-manifest-\$\{\{ github\.sha \}\}/);
@@ -219,6 +228,10 @@ test("Gitea quality gate validates before publishing an immutable ACR manifest",
assert.equal((workflow.match(/runs-on: manman-linux/g) ?? []).length, 2);
assert.match(workflow, /validate:[\s\S]*?timeout-minutes: 45[\s\S]*?publish:[\s\S]*?timeout-minutes: 60/);
assert.match(workflow, /GITEA_SHA: \$\{\{ gitea\.sha \}\}/);
assert.match(
workflow,
/docker build --build-arg NEXT_DEPLOYMENT_ID="\$GITEA_SHA" -f deploy\/railway-web\.Dockerfile/,
);
assert.equal((workflow.match(/git clean -ffdx/g) ?? []).length, 2);
assert.equal((workflow.match(/git status --porcelain --untracked-files=all/g) ?? []).length, 2);
assert.equal((workflow.match(/for attempt in 1 2 3/g) ?? []).length, 5);