rikolab 最早是一个开源主页加上若干独立工具站点。每个站点都是独立仓库、独立部署、独立维护:起步的时候省事,时间一长就要为每多一个站复制一份模板、同步一份配置。站点数量涨到 7 个之后,改一个共用字段要在 7 个仓库里各改一遍,这种结构的维护成本已经不划算。
这次迁移做的事情是把 7 个独立 Git 仓库合并成一个 mono repo:顶层目录即仓库,子项目放进 apps/,公共配置放进 shared/。部署切到 Cloudflare Pages,由 GitHub Actions 按改动路径选择性构建和推送。
最终得到一套统一的仓库、一套统一的 CI、一层共享配置,以及按改动路径增量部署的能力。后面从这个结果往回讲:先看具体变化,再讲概念,再讲过程和心得。
| 维度 | 迁移前 | 迁移后 |
|---|---|---|
| 仓库数量 | 7 个独立 Git 仓库 | 1 个 mono repo |
| 顶层目录 | 不是 Git 仓库,子目录各自带 .git | 顶层即仓库,子目录不再带 .git |
| 依赖管理 | 每个站点单独 install | 根层 pnpm workspace 统一管理 |
| 共享配置 | 每个站点复制粘贴 | shared/ 下集中维护 |
| 维度 | 迁移前 | 迁移后 |
|---|---|---|
| 部署平台 | 各自手动或脚本部署 | Cloudflare Pages 统一托管 |
| 部署触发 | 手动 | 推送到 main 自动触发 |
| 部署范围 | 每次要自己判断发哪个 | 根据改动路径自动算出受影响的 app |
| 部署矩阵 | 无 | GitHub Actions 按改动生成 matrix |
| 子域名映射 | 分散维护 | 统一从站点元数据派生 |
robots.txt 只在一处维护apps/<name> 目录即可rikolab/
apps/
main/
lumino/
wakeupscreen/
macnewfile/
vibewebhelper/
xpathtools/
shared/
astro/
i18n/
seo/
site-metadata/
scripts/
ci/
.github/
workflows/
一个仓库覆盖主站和五个产品站,部署、依赖、共享配置全部收敛。
这部分是为不熟悉这两个概念的读者准备的。熟悉的可以跳到第三节。
mono repo(单体仓库)是一种组织方式:把多个项目放进同一个 Git 仓库。关键在「组织方式」,不在技术栈。
它不等于的事:
它能带来的好处:
和 multi repo 的差异:
| 维度 | multi repo | mono repo |
|---|---|---|
| 代码共享 | 靠发包或复制 | 靠目录引用或 workspace |
| 跨项目改动 | 多仓库 PR | 单仓库 PR |
| 权限控制 | 天然按仓库拆分 | 需要额外规则 |
| CI/CD | 各自配置 | 集中配置,按路径触发 |
| 仓库体积 | 小 | 大 |
mono repo 的劣势主要在仓库体积和权限粒度。对于站点数量可控、维护者也集中的小型场景,这两项都不构成阻碍。
Cloudflare Pages 是 Cloudflare 提供的静态站点托管服务。基本能力:
dist)上传到 Pages 项目*.pages.dev 域名适合 Astro、Next.js、VitePress 这类生成静态产物的站点,也支持 SSR。
Cloudflare Pages 本身的项目模型是「一个 Pages 项目对应一个站点」,它对 mono repo 的支持有两条路径。
路径 A:Pages 自带的 Git 集成
每个 Pages 项目绑定 Git 仓库,指定 Root Directory 让构建命令运行在某个子目录。配置简单,不依赖外部 CI。限制是每个项目固定指向一个子目录,触发规则由 Cloudflare 侧决定。
路径 B:外部 CI 推送产物
由 GitHub Actions 负责构建,用 wrangler pages deploy 把指定目录产物推到某个 Pages 项目。触发规则、构建顺序、矩阵逻辑都在 Actions 侧。灵活度更高,也能绕开一些 Cloudflare 侧的限制。
本次选择了路径 B,理由放到第五节讲。
整体规模:
public/ 下的子目录shared/没有保留任何子仓库的 Git 历史。迁移前的历史对后续维护几乎没有价值,轻负担比完整历史更重要。
整个仓库围绕几个固定选择:
| 层 | 选择 | 作用 |
|---|---|---|
| 运行时 | Node.js 24 | 本地与 CI 的 JS 运行时 |
| 包管理 | pnpm 10(workspace) | 多 app 共管依赖 |
| 站点框架 | Astro 5 | 每个 app 的静态生成 |
| CI | GitHub Actions | 构建与部署触发 |
| 部署 | Cloudflare Pages + Wrangler | 静态托管与产物上传 |
技术栈的底子就是「每个单项目原本就在用的 Astro + pnpm」,多加了一层 workspace 让多项目共存,一层 CI 让部署统一。
这部分对熟悉 Git 和单项目开发、但还没上手过 mono repo 的读者应该最有参考价值。
单仓库各装各的,install 只影响当前项目。mono repo 在根层执行一次 pnpm install,所有 app 的依赖在同一棵依赖树里。好处是依赖去重、版本可以统一锁定;代价是任一 app 的依赖变化都要回到根层重新 install。
原本要让两个站复用一段逻辑,只有两个选择:复制粘贴,或者发一个 npm 包。mono repo 里多出第三种:shared/ 下的文件直接用相对路径 import,构建时和本地文件没区别。这条路径没有版本号、没有发布流程、没有升级成本。
一个 commit 可以同时改 shared/seo 和六个 app 的配置。这对需要原子化变更的 refactor 很舒服,尤其是「所有站同时升级一个字段格式」这种场景。代价是 commit message 需要能描述清楚影响范围。
单仓库一推就构建。mono repo 不能每次都全量构建,否则 CI 时间会线性膨胀。需要一层「改动路径 → 受影响 app 集合」的映射函数。这层由 scripts/ci/changed-apps.mjs 实现,读取 GitHub Actions 提供的 changed files,配合元数据里的路径规则,返回真正需要重建的 app 列表。
这一层是 mono repo 能跑得顺的关键。没有它,部署体验反而会比多仓库更糟。
现象:GitHub Actions 报 Multiple versions of pnpm specified。
原因:根层 package.json 的 packageManager 已经声明 pnpm@10.0.0,workflow 里的 pnpm/action-setup 又显式写了 version: 10。
修复:删掉 workflow 里多余的 version: 10,只保留 packageManager 声明。
教训:pnpm 版本只在一个地方声明。
现象:Actions 提示 actions/checkout@v4、pnpm/action-setup@v4 运行在 Node 20 上,即将弃用。
修复:一次性把 actions/checkout、pnpm/action-setup、actions/setup-node 都升到最新版。
教训:mono repo 的 CI 改动点集中,正好借机统一升级。
pnpm-lock.yaml现象:Actions 报 Dependencies lock file is not found。
原因:workflow 开了 cache: pnpm,但根目录还没提交 pnpm-lock.yaml。
修复:在根目录生成并提交 pnpm-lock.yaml。
教训:启用包管理器缓存前,先确认根层已经有对应 lock 文件。
@astrojs/sitemap 构建崩溃现象:多个站点 build 阶段报 Cannot read properties of undefined (reading 'reduce')。
原因:@astrojs/sitemap@3.7.2 与当前项目配置组合不兼容,所有启用 sitemap 的站点都会复现。
修复:所有 app 的 @astrojs/sitemap 从 ^3.2.1 固定为 3.2.1。
教训:mono repo 的好处在这里体现得很直接,一次修复覆盖全部站点。
sharp 与 esbuild 的 install 脚本被 pnpm 拦截现象:Actions 中主站构建失败,Astro 图片优化阶段报 Could not find Sharp。
原因:pnpm 10 默认拦截部分依赖的 install 脚本,sharp 和 esbuild 的 postinstall 没执行。
修复:在根 package.json 里加 pnpm.onlyBuiltDependencies 白名单:
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"sharp"
]
}
教训:升级 pnpm 版本后,涉及原生扩展的依赖要重新审视 install 脚本白名单。
现象:install 脚本已放行,主站构建还是报 Could not find Sharp。
原因:主站在图片优化阶段需要直接解析 sharp。只靠 Astro 的间接依赖链,模块解析在某些环境下不稳定。
修复:在主站的 package.json 里显式加 sharp 作为直接依赖。
教训:依赖某个原生模块的间接依赖不可靠,受影响的 app 应直接声明。
现象:本地 dev 可启动,页面可渲染,但浏览器控制台报 ReferenceError: normalizeBrowserLang is not defined。
原因:入口脚本已经切到共享函数 detectPreferredLang,但内部仍有一处在调用旧函数名 normalizeBrowserLang()。
修复:把残留的旧函数名换成新名字。
教训:把本地函数改为共享函数时,grep 一遍旧名字再提交。
*.pages.dev 域名,方便临时验证wrangler pages deploy 把外部 CI 构建出来的产物直接推上去Cloudflare Pages 在 Git 集成模式下有一项限制:一个项目只能绑定 5 个站点。这次要部署 6 个(主站 + 5 个产品站),刚好超额。
绕过方式是完全放弃 Cloudflare 侧的 Git 集成,改走 Token 上传:
CLOUDFLARE_ACCOUNT_ID 与 CLOUDFLARE_API_TOKENwrangler pages deploy <dir> --project-name <name> 推到对应项目这样每个 app 对应一个 Pages 项目,CI 按需推送,不再受绑定上限约束。新增站点时只需要在 Cloudflare 控制台加一个项目,元数据里加一条记录,其余流程不用改。
推送到 main 之后的完整流程:
changed-apps.mjs 把改动路径映射成受影响的 app 列表deploy-matrix.mjs 把 app 列表展开成 job matrixdist 推到对应 Pages 项目关键在第 2 步的区分规则:
| 改动位置 | 构建范围 |
|---|---|
只在 apps/foo/ 下 | 只 build foo |
在 shared/、根 package.json、workflow 自身 | 全量 build |
只在 docs/ 或其他非构建目录 | 不 build 任何 app |
映射表的基础由站点元数据派生,新增站点不用改 workflow,只改元数据。
回头看,这件事可以更早做。只有两三个站点的时候,重复配置的成本还不明显。拖到七个站点,每次改一个共用字段都要跨仓库同步,心智成本指数级上涨。mono repo 的迁移有一次性成本,但这个成本随着站点数量线性增长,越早摊销越划算。
抽 shared/ 这一步是整个迁移里最让人有获得感的。以前改 SEO canonical 规则要在 6 个仓库里改 6 遍,改完还要担心漏了哪个。现在改 shared/seo/index.mjs 一个文件,所有站点下次构建一起生效。这种「一次改动覆盖全部」的体感,是 mono repo 最直接的收益,也是复制粘贴的方式永远给不了的。
整个迁移过程里,Cloudflare 的各项免费额度都没碰到天花板:Pages 项目数量、部署次数、带宽、自定义域名。唯一遇到的限制是 Git 集成的 5 个站点上限,而且有干净的绕过方式。对小型独立站点集合来说,Cloudflare 的免费版已经非常充足,甚至可以说慷慨。
If you have anything you'd like to discuss, any ideas you want to bounce around, please send me a message.