svg
把六个 Astro 站点收敛成一个 mono repo:rikolab 迁移到 Cloudflare Pages 的完整记录

把六个 Astro 站点收敛成一个 mono repo:rikolab 迁移到 Cloudflare Pages 的完整记录

2026 年 4 月 20 日 9 min read
R Written by Riko

背景

rikolab 最早是一个开源主页加上若干独立工具站点。每个站点都是独立仓库、独立部署、独立维护:起步的时候省事,时间一长就要为每多一个站复制一份模板、同步一份配置。站点数量涨到 7 个之后,改一个共用字段要在 7 个仓库里各改一遍,这种结构的维护成本已经不划算。

这次迁移做的事情是把 7 个独立 Git 仓库合并成一个 mono repo:顶层目录即仓库,子项目放进 apps/,公共配置放进 shared/。部署切到 Cloudflare Pages,由 GitHub Actions 按改动路径选择性构建和推送。

最终得到一套统一的仓库、一套统一的 CI、一层共享配置,以及按改动路径增量部署的能力。后面从这个结果往回讲:先看具体变化,再讲概念,再讲过程和心得。

一、迁移后的结果

1. 仓库结构的变化

维度迁移前迁移后
仓库数量7 个独立 Git 仓库1 个 mono repo
顶层目录不是 Git 仓库,子目录各自带 .git顶层即仓库,子目录不再带 .git
依赖管理每个站点单独 install根层 pnpm workspace 统一管理
共享配置每个站点复制粘贴shared/ 下集中维护

2. 部署链路的变化

维度迁移前迁移后
部署平台各自手动或脚本部署Cloudflare Pages 统一托管
部署触发手动推送到 main 自动触发
部署范围每次要自己判断发哪个根据改动路径自动算出受影响的 app
部署矩阵GitHub Actions 按改动生成 matrix
子域名映射分散维护统一从站点元数据派生

3. 维护成本的变化

  • 改一次共享逻辑,所有受影响站点一起更新,不再手工同步
  • SEO、i18n、sitemap、robots.txt 只在一处维护
  • 一个独立的小法务页面并入了主产品站,少了一个要单独维护的项目
  • 新增一个站点的成本压到最低:补一条元数据记录,新建一个 apps/<name> 目录即可

4. 迁移后的仓库样子

rikolab/
  apps/
    main/
    lumino/
    wakeupscreen/
    macnewfile/
    vibewebhelper/
    xpathtools/
  shared/
    astro/
    i18n/
    seo/
    site-metadata/
  scripts/
    ci/
  .github/
    workflows/

一个仓库覆盖主站和五个产品站,部署、依赖、共享配置全部收敛。

二、两个前置概念:mono repo 与 Cloudflare Pages

这部分是为不熟悉这两个概念的读者准备的。熟悉的可以跳到第三节。

1. 什么是 mono repo

mono repo(单体仓库)是一种组织方式:把多个项目放进同一个 Git 仓库。关键在「组织方式」,不在技术栈。

不等于的事:

  • 不等于把所有代码打成一个巨大的应用
  • 不等于所有项目必须共享运行时
  • 不等于必须用某种特定构建工具

它能带来的好处:

  • 共享代码只维护一份,避免各仓库版本漂移
  • 跨项目的改动可以一个 PR 完成,不用跨仓库打补丁
  • 统一 CI/CD 入口,部署、检查、依赖管理集中处理
  • 新项目的起步成本被压低,可以直接复用仓库里的脚手架

和 multi repo 的差异:

维度multi repomono repo
代码共享靠发包或复制靠目录引用或 workspace
跨项目改动多仓库 PR单仓库 PR
权限控制天然按仓库拆分需要额外规则
CI/CD各自配置集中配置,按路径触发
仓库体积

mono repo 的劣势主要在仓库体积和权限粒度。对于站点数量可控、维护者也集中的小型场景,这两项都不构成阻碍。

2. 什么是 Cloudflare Pages

Cloudflare Pages 是 Cloudflare 提供的静态站点托管服务。基本能力:

  • 把构建产物(例如 Astro 的 dist)上传到 Pages 项目
  • 每个 Pages 项目自带 *.pages.dev 域名
  • 可以绑定自定义域名
  • 可以配合 Cloudflare Workers 提供边缘函数

适合 Astro、Next.js、VitePress 这类生成静态产物的站点,也支持 SSR。

3. Cloudflare Pages 怎么支持 mono repo

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,理由放到第五节讲。

三、迁移故事:规模、技术栈与 mono repo 带来的变化

1. 迁移规模

整体规模:

  • 迁移前:7 个独立的 Git 仓库,其中 6 个是 Astro 静态站,1 个是小型法务页面
  • 迁移后:1 个 mono repo,内含 6 个 app
  • 其中一个原子项目体量太小,直接并入了主产品站,静态资源放进 public/ 下的子目录
  • 公共配置层集中抽取到 shared/
  • 所有站点统一绑定各自的子域名

没有保留任何子仓库的 Git 历史。迁移前的历史对后续维护几乎没有价值,轻负担比完整历史更重要。

2. 技术栈

整个仓库围绕几个固定选择:

选择作用
运行时Node.js 24本地与 CI 的 JS 运行时
包管理pnpm 10(workspace)多 app 共管依赖
站点框架Astro 5每个 app 的静态生成
CIGitHub Actions构建与部署触发
部署Cloudflare Pages + Wrangler静态托管与产物上传

技术栈的底子就是「每个单项目原本就在用的 Astro + pnpm」,多加了一层 workspace 让多项目共存,一层 CI 让部署统一。

3. 从单仓库到 mono repo,到底变了什么

这部分对熟悉 Git 和单项目开发、但还没上手过 mono repo 的读者应该最有参考价值。

依赖安装变成了一次

单仓库各装各的,install 只影响当前项目。mono repo 在根层执行一次 pnpm install,所有 app 的依赖在同一棵依赖树里。好处是依赖去重、版本可以统一锁定;代价是任一 app 的依赖变化都要回到根层重新 install。

共享代码不再需要发包

原本要让两个站复用一段逻辑,只有两个选择:复制粘贴,或者发一个 npm 包。mono repo 里多出第三种:shared/ 下的文件直接用相对路径 import,构建时和本地文件没区别。这条路径没有版本号、没有发布流程、没有升级成本。

提交可以跨项目

一个 commit 可以同时改 shared/seo 和六个 app 的配置。这对需要原子化变更的 refactor 很舒服,尤其是「所有站同时升级一个字段格式」这种场景。代价是 commit message 需要能描述清楚影响范围。

CI 需要知道「这次改动影响谁」

单仓库一推就构建。mono repo 不能每次都全量构建,否则 CI 时间会线性膨胀。需要一层「改动路径 → 受影响 app 集合」的映射函数。这层由 scripts/ci/changed-apps.mjs 实现,读取 GitHub Actions 提供的 changed files,配合元数据里的路径规则,返回真正需要重建的 app 列表。

这一层是 mono repo 能跑得顺的关键。没有它,部署体验反而会比多仓库更糟。

四、迁移过程中的坑

坑 1:pnpm 版本冲突

现象:GitHub Actions 报 Multiple versions of pnpm specified

原因:根层 package.jsonpackageManager 已经声明 pnpm@10.0.0,workflow 里的 pnpm/action-setup 又显式写了 version: 10

修复:删掉 workflow 里多余的 version: 10,只保留 packageManager 声明。

教训:pnpm 版本只在一个地方声明。

坑 2:Node 20 弃用警告

现象:Actions 提示 actions/checkout@v4pnpm/action-setup@v4 运行在 Node 20 上,即将弃用。

修复:一次性把 actions/checkoutpnpm/action-setupactions/setup-node 都升到最新版。

教训:mono repo 的 CI 改动点集中,正好借机统一升级。

坑 3:缺少 pnpm-lock.yaml

现象:Actions 报 Dependencies lock file is not found

原因:workflow 开了 cache: pnpm,但根目录还没提交 pnpm-lock.yaml

修复:在根目录生成并提交 pnpm-lock.yaml

教训:启用包管理器缓存前,先确认根层已经有对应 lock 文件。

坑 4:@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 的好处在这里体现得很直接,一次修复覆盖全部站点。

坑 5:sharpesbuild 的 install 脚本被 pnpm 拦截

现象:Actions 中主站构建失败,Astro 图片优化阶段报 Could not find Sharp

原因:pnpm 10 默认拦截部分依赖的 install 脚本,sharpesbuild 的 postinstall 没执行。

修复:在根 package.json 里加 pnpm.onlyBuiltDependencies 白名单:

"pnpm": {
  "onlyBuiltDependencies": [
    "esbuild",
    "sharp"
  ]
}

教训:升级 pnpm 版本后,涉及原生扩展的依赖要重新审视 install 脚本白名单。

坑 6:主站仍然无法解析 sharp

现象:install 脚本已放行,主站构建还是报 Could not find Sharp

原因:主站在图片优化阶段需要直接解析 sharp。只靠 Astro 的间接依赖链,模块解析在某些环境下不稳定。

修复:在主站的 package.json 里显式加 sharp 作为直接依赖。

教训:依赖某个原生模块的间接依赖不可靠,受影响的 app 应直接声明。

坑 7:浏览器端旧函数名残留

现象:本地 dev 可启动,页面可渲染,但浏览器控制台报 ReferenceError: normalizeBrowserLang is not defined

原因:入口脚本已经切到共享函数 detectPreferredLang,但内部仍有一处在调用旧函数名 normalizeBrowserLang()

修复:把残留的旧函数名换成新名字。

教训:把本地函数改为共享函数时,grep 一遍旧名字再提交。

五、Cloudflare Pages 使用心得

1. 它做得好的地方

  • 静态站托管完全免费,带宽和请求额度也很充裕
  • 自带 *.pages.dev 域名,方便临时验证
  • 自定义域名绑定顺手,HTTPS、HTTP/3、CDN 默认开箱
  • 构建与部署可以解耦:用 wrangler pages deploy 把外部 CI 构建出来的产物直接推上去

2. 一个项目只能部署 5 个站点的限制

Cloudflare Pages 在 Git 集成模式下有一项限制:一个项目只能绑定 5 个站点。这次要部署 6 个(主站 + 5 个产品站),刚好超额。

绕过方式是完全放弃 Cloudflare 侧的 Git 集成,改走 Token 上传:

  • 在 Cloudflare 控制台给每个站点单独创建一个 Pages 项目
  • 在仓库级 secret 中配置 CLOUDFLARE_ACCOUNT_IDCLOUDFLARE_API_TOKEN
  • 由 GitHub Actions 构建出产物,再通过 wrangler pages deploy <dir> --project-name <name> 推到对应项目

这样每个 app 对应一个 Pages 项目,CI 按需推送,不再受绑定上限约束。新增站点时只需要在 Cloudflare 控制台加一个项目,元数据里加一条记录,其余流程不用改。

3. 构建流程是怎么区分的

推送到 main 之后的完整流程:

  1. Actions 拉出本次 commit 的 diff,得到改动文件列表
  2. changed-apps.mjs 把改动路径映射成受影响的 app 列表
  3. deploy-matrix.mjs 把 app 列表展开成 job matrix
  4. 每个 job 做三件事:装依赖 → 在 app 目录 build → 用 wrangler 把 dist 推到对应 Pages 项目

关键在第 2 步的区分规则:

改动位置构建范围
只在 apps/foo/只 build foo
shared/、根 package.json、workflow 自身全量 build
只在 docs/ 或其他非构建目录不 build 任何 app

映射表的基础由站点元数据派生,新增站点不用改 workflow,只改元数据。

六、最后的一些感想

1. 这种迁移越早做越好

回头看,这件事可以更早做。只有两三个站点的时候,重复配置的成本还不明显。拖到七个站点,每次改一个共用字段都要跨仓库同步,心智成本指数级上涨。mono repo 的迁移有一次性成本,但这个成本随着站点数量线性增长,越早摊销越划算。

2. 组件复用的能力比想象中好用

shared/ 这一步是整个迁移里最让人有获得感的。以前改 SEO canonical 规则要在 6 个仓库里改 6 遍,改完还要担心漏了哪个。现在改 shared/seo/index.mjs 一个文件,所有站点下次构建一起生效。这种「一次改动覆盖全部」的体感,是 mono repo 最直接的收益,也是复制粘贴的方式永远给不了的。

3. Cloudflare 确实大方

整个迁移过程里,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.

svg