我们的 CI 构建网站时不给任何密钥,这是有意的
本文译自 2026-09-23 的英文原文。
有一段时间,我们的持续集成只跑两项检查:对全部代码做类型检查,跑全部测试。两项都绿就合并。后来,一个测试文件被误建在 web 应用的 pages/ 目录里。TypeScript 没意见,它是合法的 TypeScript;测试运行器也没意见,它跑了,也过了。可生产构建会在预渲染这一步失败:框架把 pages/ 下的一切都当作路由,试图把一个测试渲染成页面。
CI 里没有任何一步能发现它,因为 CI 里没有任何一步真的构建过网站。
修法很无聊:把网站构建出来
现在,每个 pull request 都会从一份干净的检出跑真实的构建命令。构建这一步不生成部署,也不上传任何东西,而且不允许悄悄失败:它的结果就是构建的退出码,别无其他。有五条已知警告说字体资源「didn't resolve at build time」,它们会被打印出来,然后被刻意忽略,有一条 TODO 指着它们。我们不去 grep 日志里的「WARN」,因为按警告 grep 的闸,往往在上游某个包第一次改措辞时,就从有用变成了噪音。
真正有意思的决定,是构建拿不到什么。
没有 secret,没有站点配置
CI 不给构建任何站点密钥:没有公开的 API key,没有 secret,什么都没有;它能看到的站点取值,只有 Worker 配置文件里提交的那两个非机密值。这不是偷懒。有一条规则原本只在原则上成立,现在由构建来强制执行:构建期渲染的路由,一律不得读数据库。
网站有两类路由。预渲染路由在构建时被转成静态 HTML。服务端渲染路由在 Worker 里按请求运行;在服务端,只有它们可以打开数据库客户端,而且是在请求到来时才按需打开。(浏览器端的 island,比如页头的账户链接,会在访客的浏览器里打开自己的客户端;那是另一个客户端,不归这条规则管。)如果某个预渲染路由在构建期代码里混进一次没有防护的数据库调用,构建就会在 CI 里报缺少配置的错误而失败,不会等到有人在生产环境里看到它。有防护的调用,也就是包在全站通用的优雅降级写法里的那种,则会照样构建成功,只是那一节变空。所以没有凭据是一项很强的测试,但不是完备的测试。
这件事已经起过一次作用。早先的构建有一个前置步骤,靠查询数据库里的每一个报告页来生成 sitemap。它能用,但这意味着没有生产库的读权限就跑不了构建,于是 CI 要么拿着本不该持有的凭据,要么跳过构建。我们把它换成了一个按请求服务端渲染的 sitemap 路由:它读取框架自己产出的路由清单;如果这份清单缺失,它会响亮地返回 500,而不是给出一个空 sitemap。CI 构建就此失去了持有密钥的唯一理由。
部署构建是另一回事。它会把一些公开值内联进浏览器 bundle,其中包括 Supabase URL 和匿名 key(anon key),因为登录页需要它们,而静态 bundle 无法在运行时接收这些值。它们都不是 secret。部署脚本在部署前会检查它们确实进了 bundle;而 CI 从不部署,也就从来看不到这个 key。
跑 build,不跑部署脚本
还有一个细节,读起来像吹毛求疵,直到它救你一次。我们的 package 有一个 build 脚本和一个 deploy 脚本,deploy 脚本的最后一步就是把新版本的 Worker 推到生产。CI 直接调用框架的构建命令,而不是这个 package 脚本,这样以后这两个脚本无论怎么调整,pull request 的检查都不可能部署任何东西。workflow 文件里这一行上方的注释把原因写得相当长,因为将来读到它的人会忍不住想去「简化」它。
我们得到了什么
现在,一个绿色的 pull request 意味着:它能通过类型检查,它的测试都通过了,而且它描述的网站确实能构建成要部署的那个产物,过程中用不到任何公开 CI runner 不该拥有的东西。第三条正是之前缺的那条,也是三条里唯一一条和部署时实际发生的事相像的。