fix(web): vendor Inter so image builds skip Google Fonts
Independent Staging Quality Gate / validate (push) Successful in 9m0s
Independent Staging Quality Gate / publish (push) Successful in 1m59s

BUG-430 loaded Inter via next/font/google, which failed Gitea publish
run 2408 when the Docker build could not reach fonts.googleapis.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-05 00:28:26 +08:00
parent 40c623edf6
commit 7c776bdd3a
9 changed files with 171 additions and 10 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-05 — 发布构建不再从网上拉 Inter
西文正文字体改成仓库里的 Inter 文件,镜像构建不再访问 Google Fonts。页面上看起来还是同一套字。Skill 版本未变。
## 2026-09-04 — 入学毕业不再连问两道相同的上大学发挥题
候选区分阶段,同一套发挥选项只问一次。入学和毕业如果切分相同,只问入学那年;毕业不再套「调剂 / 发挥失常」这道题。Skill 版本仍是 10.0.14。
+21 -4
View File
@@ -6578,15 +6578,15 @@
- 状态:resolved
- 首次发现:2026-08-29
- 最近更新:2026-08-29
- 最近更新:2026-09-05
- 影响面:全站 `--font-body`、后台 antd `fontFamily`
- 用户现象:设计稿写了 Inter,实际请求里没有 woff2;非苹果系统落到 Segoe UI / 微软雅黑。
- 触发条件:打开任意带根布局的页面。
- 根因:仓库 0 个字体文件、0 处 `@font-face`、0 处 `next/font`CSS 与 admin token 却把 Inter 写在 StyreneB 之后。
- 修复:根布局用 `next/font/google` 加载 Inter`display: "swap"``--font-inter``--font-body` 与 admin `fontFamily` 改为 `var(--font-inter, Inter)`。Tiempos / 宋体栈未改。构建产物与 `/` HTML 可见 woff2 preload。
- 修复:根布局用 `next/font/local` 加载仓内 `InterVariable-latin.woff2``display: "swap"``--font-inter`)。早期曾用 `next/font/google`,构建期会访问 Google Fonts,见 BUG-543`--font-body` 与 admin `fontFamily` 改为 `var(--font-inter, Inter)`。Tiempos / 宋体栈未改。构建产物与 `/` HTML 可见 woff2 preload。
- 验证:`frontend/tests/site-style-isolation-contract.test.ts``next build``/` HTML 含 Inter woff2 preload。
- 防复发:字体栈里出现的西文家族必须有 `next/font` `@font-face`;缺授权的展示字体(Tiempos)不得用同一套办法偷偷补上。
- 相关记录:
- 防复发:字体栈里出现的西文家族必须有 `next/font/local`(仓内字体文件)`@font-face`不得用 `next/font/google`缺授权的展示字体(Tiempos)不得用同一套办法偷偷补上。
- 相关记录:BUG-543
- 复发自:无
- 修复版本:待发布
@@ -8372,3 +8372,20 @@
- 相关记录:BUG-390、BUG-540
- 复发自:BUG-390(质量探针只对学业发出,但学业内 kind 未收紧)
- 修复版本:待发布
## BUG-543 | staging 镜像构建因 Google Fonts 拉不到 Inter 失败
- 状态:resolved
- 首次发现:2026-09-05
- 最近更新:2026-09-05
- 影响面:Gitea `backend-quality-gate.yml` `publish``deploy/railway-web.Dockerfile``RUN npm run build``frontend/src/app/layout.tsx`
- 用户现象:无终端用户可见现象。门禁 validate 通过后 publish 失败,staging 不发布新镜像。
- 触发条件:推送触及门禁路径后,publish 在 Docker 内执行 `npm run build`。构建环境访问不到 `fonts.googleapis.com`
- 根因:BUG-430 用 `next/font/google` 在构建期下载 Inter。validate 跑在 hostexecutor,能出网;publish 的镜像构建不能。Gitea run 2408SHA `40c623ed`)报 `Failed to fetch Inter from Google Fonts``railway-web.Dockerfile:26` 退出 1。
- 修复:把 latin 可变 InterOFL)放进 `frontend/src/app/fonts/InterVariable-latin.woff2`,根布局改 `next/font/local`。不改 workflow、不设构建期代理。字体栈与 `--font-inter` 不变。
- 验证:`site-style-isolation-contract` 锁定 `next/font/local`、仓内 woff2 魔数、以及 `frontend/src` 不再出现 `next/font/google` / `fonts.googleapis.com`。本机 `npx tsx --test tests/site-style-isolation-contract.test.ts``tsc --noEmit`
- 防复发:不得把西文正文字体改回 `next/font/google`。新字体必须是仓内文件 + `next/font/local``@font-face`
- 相关记录:BUG-430
- 复发自:BUG-430(加载方式在无 Google Fonts 网络的镜像构建里不成立)
- 修复版本:待发布
@@ -42,3 +42,13 @@
| `run_quality_gate.py --profile quick``npm test` | 2682 tests / 2673 pass / **9 fail**,全部是 Docker 迁移 `database migration failed``database-foundation` / `database-local-business` / redeem / identity / secret-refs / v9×3 / Skill migration)。与本单无关,属既有环境缺口。 |
未跑全量 `npm test`(时间);未做浏览器真人走查(无登录态)。清单:`docs/testing/rectification-quality-probe-dedupe-20260904.md`
## 门禁修复(BUG-543
Gitea run 2408`validate` 通过,`publish``railway-web.Dockerfile` `RUN npm run build` 失败。报错 `Failed to fetch Inter from Google Fonts`。根因是 BUG-430 的 `next/font/google` 在镜像构建里访问不到 Google。未改 workflow。
| 项 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `site-style-isolation-contract` Inter 加载断言 | `import { Inter } from "next/font/google"` | `import localFont from "next/font/local"` + 仓内 woff2 魔数 + `frontend/src` 禁止 `from "next/font/google"` / `fonts.googleapis.com` | 构建期不得出网拉字体 |
本机补充:`npx tsx --test tests/site-style-isolation-contract.test.ts` **4/4**`tsc --noEmit` 0 错;改动文件 eslint 0 error。本机 `npm run build` 被 worktree 里 `frontend/node_modules` 指向仓外的 symlink 挡住(Turbopack `points out of the filesystem root`),属既有环境缺口;CI publish 镜像内是 `npm ci`,无此 symlink。编号:BUG-543(未占用预留的 BUG-542)。未改 `.gitea/workflows/**`
+1 -1
View File
@@ -136,7 +136,7 @@ is read through an external store so a change in one tab reaches the others.
### Font stacks
- Display: `"Tiempos Headline", "Songti SC", "STSong", "Noto Serif CJK SC", Georgia, serif`. The licensed Copernicus/Tiempos files are unavailable; the Chinese Song serif stack is the declared production substitute.
- Body/UI: `StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`. Inter is loaded with `next/font/google` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter. Tiempos Headline remains unloaded; the Songti stack is still the declared production substitute.
- Body/UI: `StyreneB, var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`. Inter is loaded with `next/font/local` from `src/app/fonts/InterVariable-latin.woff2` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter, and image builds do not call fonts.googleapis.com. Tiempos Headline remains unloaded; the Songti stack is still the declared production substitute.
- Code/data: `"JetBrains Mono", "SFMono-Regular", Consolas, monospace`, exposed as `--font-mono`.
- Root boundary pages (`error.tsx`, `not-found.tsx`, `forbidden.tsx`, `global-error.tsx`) sit in the shared root layout segment and must not import `globals.css` — importing it would drag the chat stylesheet onto every admin route. They therefore cannot read `--font-mono` or any token, and inline their own values: a system stack for body text and `ui-monospace, SFMono-Regular, Menlo, monospace` for code. Keep those literals in step with the stacks above by hand.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
Inter latin variable (wght axis)
File: InterVariable-latin.woff2
Taken from @fontsource-variable/inter@5.3.0 files/inter-latin-wght-normal.woff2
Upstream: https://github.com/rsms/inter
License: SIL Open Font License 1.1 (OFL.txt)
+6 -3
View File
@@ -1,14 +1,17 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
import { StaleClientRecovery } from "@/components/stale-client-recovery";
import { themePreferenceBootScript } from "@/lib/theme-preference";
const inter = Inter({
subsets: ["latin"],
// Vendored Inter latin variable (OFL). Do not switch back to next/font/google;
// the publish image cannot download fonts at build time.
const inter = localFont({
src: "./fonts/InterVariable-latin.woff2",
display: "swap",
variable: "--font-inter",
weight: "100 900",
});
export const metadata: Metadata = {
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { readdirSync, readFileSync } from "node:fs";
import { extname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const siteStyles = readFileSync(new URL("../src/app/site-styles.ts", import.meta.url), "utf8");
@@ -10,7 +12,9 @@ const homePage = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
test("Inter is loaded through next/font and applied as a CSS variable", () => {
assert.match(layout, /import \{ Inter \} from "next\/font\/google"/);
assert.match(layout, /import localFont from "next\/font\/local"/);
assert.doesNotMatch(layout, /from ["']next\/font\/google["']/);
assert.match(layout, /src: "\.\/fonts\/InterVariable-latin\.woff2"/);
assert.match(layout, /display: "swap"/);
assert.match(layout, /variable: "--font-inter"/);
assert.match(layout, /className=\{inter\.variable\}/);
@@ -21,6 +25,31 @@ test("Inter is loaded through next/font and applied as a CSS variable", () => {
);
});
test("Inter is vendored locally so production builds do not fetch Google Fonts", () => {
const font = readFileSync(new URL("../src/app/fonts/InterVariable-latin.woff2", import.meta.url));
assert.equal(Buffer.from(font.subarray(0, 4)).toString(), "wOF2");
assert.ok(font.byteLength > 20_000);
const srcRoot = fileURLToPath(new URL("../src/", import.meta.url));
const stack = [srcRoot];
while (stack.length > 0) {
const dir = stack.pop()!;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (![".ts", ".tsx", ".js", ".jsx", ".css"].includes(extname(entry.name))) continue;
const source = readFileSync(fullPath, "utf8");
assert.doesNotMatch(
source,
/from ["']next\/font\/google["']|fonts\.googleapis\.com/,
`${fullPath} must not fetch Google Fonts at build time`,
);
}
}
});
test("site chrome owns globals.css; admin does not import it", () => {
assert.match(siteStyles, /import "\.\/globals\.css"/);
assert.match(siteStyles, /import "\.\/birth-time-choice\.css"/);