diff --git a/hzh/CICD/CICD 在 Slidev 中的应用.md b/hzh/CICD/CICD 在 Slidev 中的应用.md new file mode 100644 index 0000000..10706cc --- /dev/null +++ b/hzh/CICD/CICD 在 Slidev 中的应用.md @@ -0,0 +1,95 @@ +--- +tags: + - CI/CD + - Slidev + - Docker + - Nginx + - Gitea Actions +create time: 2026-05-27 19:30 +--- + +# CICD 在 Slidev 中的应用 + +## 概述 + +本文档记录使用 Gitea Actions 实现 Slidev 演示文稿项目的自动化构建与部署流程:代码推送到 `main` 分支后自动构建所有 deck,并将产物部署到基于 Docker 的 Nginx 容器。 + +> [!TIP] 本文件夹包含以下子文档: +> - [[CICD 在 Slidev 中的应用/workflow-configuration]] — Workflow YAML 逐块详解(触发条件、运行环境、依赖管理) +> - [[CICD 在 Slidev 中的应用/deployment-strategy]] — 部署架构深入分析(DooD 模式、Nginx 运作、设计决策) +> - [[CICD 在 Slidev 中的应用/Docker-in-Container 部署模式详解]] — DooD vs SID vs DinD vs Kaniko 方案对比 + +## 项目背景 + +本项目是一个基于 [Slidev](https://sli.dev/) 的多 deck 演示文稿平台,使用 Markdown 编写幻灯片内容。项目中包含多个独立的演示文稿(每个称为一个 **deck**),统一由 `index.html` 的 landing page 进行导航分发。 + +**技术栈选型:** + +| 组件 | 选择 | 理由 | +|------|------|------| +| 演示文稿框架 | Slidev | Markdown + Vue 驱动,适合技术分享场景 | +| 代码托管 | Gitea(自部署) | 团队内部私有实例,支持 Actions 兼容语法 | +| CI/Runner | Gitea Act Runner(阿里云) | 自建 runner,避免公有 cloud runner 的配额限制 | +| 构建工具 | pnpm + Slidev build | 多 deck 批量构建,输出纯静态站点 | +| 部署方式 | Docker DooD + Nginx Alpine | 无状态部署,每次重建保证干净环境 | + +**为什么需要 CI/CD?** + +Slidev 的演示文稿本质是静态站点,传统手动部署流程为:本地 `build` → 拷贝文件到服务器 → 重启 Nginx。这种方式的痛点在于: + +- **人工遗漏**:新增 deck 后忘记重新构建或遗漏文件复制 +- **环境不一致**:本地构建产物可能在服务器上因依赖版本差异出现问题 +- **协作困难**:多人同时修改不同 deck 时无法自动化集成 + +通过 Gitea Actions + Docker 实现自动化后,开发者只需关注 `.md` 内容的编写和提交,构建、部署全流程自动完成——**push 即上线**。 + +## 正文 + +### CI/CD 流水线概览 + +对于一个 Slidev 项目,典型的发布流程可以拆解为以下几个阶段: + +```mermaid +flowchart LR + A["📝 push 到 main"] --> B["Gitea Actions 触发"] + B --> C["安装系统依赖"] + C --> D["检出代码"] + D --> E["安装 pnpm + 项目依赖"] + E --> F["构建 slides"] + F --> G["启动 Nginx 容器"] + G --> H["复制产物到容器"] + H --> I["网站上线"] +``` + +### 架构总览 + +```mermaid +graph TB + subgraph "本地开发" + DEV["开发者\n编辑 .md"] --> PUSH["git push → main"] + end + + subgraph "Gitea Runner (阿里云)" + ACTION["Gitea Actions Runner"] + BUILD["node:22-alpine 容器\n构建 slides → dist/"] + end + + subgraph "宿主机 Docker" + NGINX["slides-nginx 容器\n8080 → 80"] + HTML["/usr/share/nginx/html/\n← dist/ 内容"] + end + + USER["访客浏览器"] + PUSH --> ACTION + ACTION --> BUILD + BUILD --> COPY["docker cp"] + COPY --> NGINX + NGINX --> HTML + NGINX --> USER +``` + +## 关联笔记 + +- [[CICD 在 Slidev 中的应用/workflow-configuration]] +- [[CICD 在 Slidev 中的应用/deployment-strategy]] +- [[CICD 在 Slidev 中的应用/Docker-in-Container 部署模式详解]] diff --git a/hzh/CICD/CICD 在 Slidev 中的应用/Docker-in-Container 部署模式详解.md b/hzh/CICD/CICD 在 Slidev 中的应用/Docker-in-Container 部署模式详解.md new file mode 100644 index 0000000..3db0171 --- /dev/null +++ b/hzh/CICD/CICD 在 Slidev 中的应用/Docker-in-Container 部署模式详解.md @@ -0,0 +1,160 @@ +--- +tags: [CI/CD, Docker, DooD, Deployment] +create time: 2026-05-27 19:30 +--- + +# Docker-in-Container 部署模式详解 + +## 概述 + +当 CI/CD 流水线需要在容器化环境中启动和操控另一个容器时(例如 Gitea Actions 中构建产物后部署 Nginx),有两种主流方案:**DooD (Docker-out-of-Docker)** 与 **SID (Socket-In-Docker)**。本文对比两种模式的原理、配置和取舍。 + +## 核心问题 + +Workflow 的 `run:` 命令在某个容器(如 `node:22-alpine`)内执行,但你需要指挥宿主机上的 Docker daemon 来操作其他容器。问题是:**容器里没有 Docker,怎么发命令?** + +```mermaid +flowchart LR + subgraph HOST["宿主机"] + DAEMON["Docker Daemon\n(:2375 or /var/run/docker.sock)"] + end + + subgraph CONTAINER["workflow 运行容器\n(node:22-alpine)"] + CLI["docker-cli ?"] + end + + DAEMON <-->|通信方式见下文| CLI + + style DAEMON fill:#e8f4fd + style CONTAINER fill:#fff4e8 +``` + +## 方案一:DooD — Docker-out-of-Docker + +### 原理 + +在容器内部安装完整的 `docker` CLI 包,通过某种方式连接到宿主机的 Docker daemon。 + +```yaml +# .github/workflows/example.yml +steps: + - name: Install Docker CLI + run: | + sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + apk add --no-cache docker-cli + + - name: Build and Deploy + run: | + docker build -t myapp . + docker run --name web nginx:alpine + docker cp dist/. web:/usr/share/nginx/html/ +``` + +连接宿主机 daemon 的方式通常有两种: + +| 方式 | 实现 | 特点 | +|------|------|------| +| 环境变量 + TCP | 宿主机暴露 `DOCKER_HOST=tcp://host.docker.internal:2375` | 需要开放端口,可能有安全风险 | +| Socket 共享 + CLI 并存 | 挂载 `/var/run/docker.sock` 且容器内装了 docker-cli | 最常用,兼容性好 | + +### 优点 + +- **功能完整** — 容器内有完整的 docker CLI,任何 `docker` 命令都能用 +- **调试友好** — 可以直接 `docker exec` 进入其他容器查日志 +- **无需额外配置** — runner 内置支持或简单环境变量即可 + +### 缺点 + +- **安装体积大** — docker-cli 本身约 40-60MB( Alpine 版本) +- **权限较宽** — 通常需要加入 `docker` 用户组才能免 sudo 操作 +- **安全面更大** — 容器内能直接操控宿主机所有容器和资源 + +## 方案二:SID — Socket-In-Docker + +### 原理 + +不安装任何 CLI,直接把宿主机的 Docker socket 文件挂载进容器。容器内的进程通过这个 socket 文件和宿主机 daemon 通信。 + +```yaml +steps: + - name: Deploy with socket mount + # 关键:runner 配置中将 docker.sock 挂载进工作容器 + env: + DOCKER_BUILDKIT: 1 + run: | + # 容器内不需要 docker,只需把 docker socket 挂载进来 + docker run -d --name web nginx:alpine # 直接用! + docker cp dist/. web:/usr/share/nginx/html/ +``` + +在 Gitea Actions Runner 层面,需要通过环境变量或配置文件将 socket 挂载: + +```bash +# Runner 启动时的挂载参数 +docker run \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + node:22-alpine \ + pnpm build && docker run ... +``` + +### 优点 + +- **镜像更轻** — 容器内不需要安装 docker-cli,节省空间和时间 +- **资源占用少** — 无额外二进制、无额外进程 +- **简洁干净** — workflow 文件看起来更像"普通脚本" + +### 缺点 + +- **调试困难** — 没有独立 CLI,出错时排查路径更长 +- **安全性极低** — **这是致命缺陷**。拥有 `/var/run/docker.sock` 等价于拥有 root 权限(可轻易逃逸到宿主机) +- **兼容性限制** — 某些需要完整 CLI 特性的操作可能不支持 + +### ⚠️ SID 安全警告 + +``` +mount /var/run/docker.sock → 容器内进程 = root@宿主机 +``` + +攻击者只需一行代码就能从容器逃逸到宿主机: + +```bash +docker run -v /:/host alpine chroot /host sh +``` + +**结论:仅在完全可信的私有 CI/CD 环境中使用 SID,生产环境或有外部贡献者的项目必须用 DooD。** + +## 方案对比总结 + +```mermaid +quadrantChart + title Docker-in-Container 方案对比 + x-axis "低安全性" --> "高安全性" + y-axis "轻量便捷" --> "功能强大" + SID: [0.2, 0.8] + "DooD (socket+CLI)": [0.6, 0.9] + DinD (Daemon inside): [0.9, 0.4] + Kaniko: [0.95, 0.3] +``` + +| 维度 | DooD (CLI) | SID (Socket Only) | DinD (Daemon Inside) | Kaniko | +|------|-----------|-------------------|---------------------|--------| +| 镜像大小 | ~60MB 额外 | +0MB | ~80MB 额外 | 自包含 | +| 调试体验 | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | +| 安全性 | ⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| 配置复杂度 | 低 | 最低 | 高 | 低 | +| 适用场景 | 通用推荐 | 纯可信私有环境 | 需要完整 Docker 特性 | 仅构建镜像 | + +## 选择建议 + +| 你的场景 | 推荐方案 | +|---------|---------| +| Slidev 部署(当前项目) | DooD,简单够用 | +| 多人在跑 CI 的开源项目 | DinD 或 Kaniko | +| 极致追求镜像体积的私有环境 | SID(但要评估风险) | +| 只需要构建镜像不部署 | Kaniko(无需 docker daemon) | + +## 关联笔记 + +- [[CICD 在 Slidev 中的应用]] +- [[deployment-strategy]] +- [[workflow-configuration]] diff --git a/hzh/CICD/CICD 在 Slidev 中的应用/deployment-strategy.md b/hzh/CICD/CICD 在 Slidev 中的应用/deployment-strategy.md new file mode 100644 index 0000000..ad889af --- /dev/null +++ b/hzh/CICD/CICD 在 Slidev 中的应用/deployment-strategy.md @@ -0,0 +1,125 @@ +--- +tags: + - CI/CD + - Docker + - Nginx + - 部署策略 +create time: 2026-05-27 19:30 +--- + +# 部署策略深入 + +## 概述 + +深入分析 Slidev 项目的部署架构,包括 Docker-out-of-Docker 通信模式、Nginx 静态文件服务机制、以及 Pipeline 步骤间的依赖关系。 + +> [!NOTE] 如需了解 Workflow YAML 逐块配置说明,详见 [[workflow-configuration]]。 + +## 正文 + +### 为什么需要 docker-cli?DooD vs SID + +> [!QUESTION] 思考:workflow 已经在 `node:22-alpine` 容器里运行了,为什么还要装 Docker 相关工具?这不是"套娃"吗? + +**关键理解**:你的命令在 **container A(构建容器)** 中执行,但你要操控的是 **宿主机的 Docker daemon** 来启动 container B(Nginx)。这是经典的 **Docker-out-of-Docker (DooD)** 模式。 + +```mermaid +flowchart LR + HOST["宿主机\nDocker Daemon"] <-->|通信| CLI["container A 中的\ndocker-cli 命令"] + CLI --> OP["docker run nginx → 新建 container B"] + + style HOST fill:#e8f4fd + style CLI fill:#fff4e8 + style OP fill:#e8ffe8 +``` + +这比另一种方案 **SID (Socket-In-Docker)** 更安全:SID 直接把 `/var/run/docker.sock` 挂载进容器,等于给了 root 权限——攻击者一行代码就能逃逸到宿主机。而 DooD 通过独立的 docker-cli 通信,安全边界更清晰。 + +> [!TIP] 想深入了解两种方案的完整对比?详见 [[Docker-in-Container 部署模式详解]]。 + +### Nginx 作为静态文件服务器的运作方式 + +```mermaid +flowchart LR + REQ["浏览器请求 :8080/index.html"] --> NGINX["Nginx 监听端口 80\n(容器内映射到宿主机 8080)"] + NGINX --> FS["读取文件系统
/usr/share/nginx/html/"] + FS --> FILES["HTML / CSS / JS / 图片
(来自 dist/ 的构建产物)"] + FILES --> RESP["返回给浏览器渲染"] + + style REQ fill:#e8f4fd + style RESP fill:#e8ffe8 +``` + +核心机制其实非常简单——**Nginx 不做任何处理,只是当个"搬运工"**: + +| 概念 | 说明 | +|------|------| +| 为什么选 Nginx | 业界标准、极致轻量(alpine 版仅 ~4MB)、零配置开箱即用 | +| 为什么每次重建 | 无状态设计:删掉旧容器 = 彻底清除历史,避免残留配置或缓存问题 | +| 为什么用 `docker cp` | `cp dist/. nginx:/usr/share/nginx/html/` 把构建产物直接铺进容器的网页根目录 | +| 为什么不重启 Nginx | `cp` 操作不涉及进程热重载,Nginx 自动读最新文件 | + +关键参数 `-p 8080:80`:容器内 Nginx 监听 80 端口,将其映射到宿主机的 8080。访客访问 `http://服务器IP:8080` 即可看到 slides。这里没有直接用 80 端口,是为了避免和宿主机上其他可能的 HTTP 服务冲突。 + +### Workflow 步骤顺序:谁保证了执行的先后? + +答案很直接:**YAML 中 `steps` 的顺序就是执行顺序**。Gitea Actions(与 GitHub Actions 一致)对每个 job 内的 steps 按从上到下 **串行执行**,且某一步失败后后续步骤自动跳过。 + +```mermaid +flowchart LR + S1[Install tools] --> S2[Checkout] + S2 --> S3[Install pnpm] + S3 --> S4[pnpm install] + S4 --> S5[Build] + S5 --> S6[Deploy] + + S1 -.→|.git (代码可用)| S2 + S2 -.→|pnpm CLI| S3 + S3 -.→|node_modules/| S4 + S4 -.→|dist/ 产物| S5 + S5 -.→|dist/ 产物| S6 + + style S1 fill:#fff4e8 + style S2 fill:#fff4e8 + style S3 fill:#fff4e8 + style S4 fill:#fff4e8 + style S5 fill:#fff4e8 + style S6 fill:#e8ffe8 +``` + +每一步的输出自然成为下一步的输入——不需要额外的依赖声明,文件系统天然承载了这一步到那一步的数据传递。这正是 CI/CD pipeline "线性流水线"的设计哲学。 + +### 部署命令拆解 + +```yaml +- name: Deploy to nginx + run: | + docker rm -f slides-nginx 2>/dev/null || true + docker run -d \ + --name slides-nginx \ + --restart unless-stopped \ + -p 8080:80 \ + nginx:alpine + docker cp dist/. slides-nginx:/usr/share/nginx/html/ +``` + +这一阶段做了三件事: + +1. **清理旧容器** — `docker rm -f` 强制删除可能存在的同名容器,`2>/dev/null || true` 确保不存在时不报错退出 +2. **启动新容器** — 基于 `nginx:alpine` 镜像创建轻量级 web 服务器 +3. **复制构建产物** — 将 `dist/` 下的内容拷贝到 Nginx 的默认网页目录 `/usr/share/nginx/html/` + +### 关键设计决策 + +| 决策 | 选择 | 理由 | +|------|------|------| +| 包管理器 | pnpm | 依赖安装速度快,磁盘占用小 | +| 容器基础镜像 | Alpine | 镜像仅几 MB,适合快速构建 | +| Web 服务器 | Nginx (每次重建) | 无状态、零配置,构建产物即静态文件 | +| 端口映射 | 8080:80 | 避免宿主机占用标准 HTTP 端口 80 | + +## 关联笔记 + +- [[CICD 在 Slidev 中的应用]] +- [[workflow-configuration]] +- [[Docker-in-Container 部署模式详解]] diff --git a/hzh/CICD/CICD 在 Slidev 中的应用/workflow-configuration.md b/hzh/CICD/CICD 在 Slidev 中的应用/workflow-configuration.md new file mode 100644 index 0000000..245bbc1 --- /dev/null +++ b/hzh/CICD/CICD 在 Slidev 中的应用/workflow-configuration.md @@ -0,0 +1,170 @@ +--- +tags: + - CI/CD + - Gitea Actions + - Slidev +create time: 2026-05-27 19:30 +--- + +# Workflow 配置详解 + +## 概述 + +本文档对 `CICD 在 Slidev 中的应用` 中的 Workflow YAML 进行逐块解析,涵盖从触发条件到构建部署的每一步配置逻辑。 + +> [!NOTE] 如需了解部署架构和容器通信模式,详见 [[deployment-strategy]]。 + +## 正文 + +### 1. 触发条件 + +```yaml +on: + push: + branches: [main] + pull_request: + branches: [main] +``` + +向 `main` 分支推送代码或提交 PR 时都会触发。这意味着开发者通过特性分支提 PR,CI 会先验证构建是否通过——只有合并到主干后才执行部署。 + +> [!TIP] PR 事件只适合跑验证性步骤(build、lint),实际部署仍保留在 `push: main` 中,因为 PR 上下文通常没有写入权限。如果 Gitea Actions 的 PR 事件无法读取 secret,可改用 `pull_request_target`,但需避免直接使用 PR 代码中的敏感操作以防止 token 泄露。 + +> [!QUESTION] 思考:如果你的 Slidev 项目需要同时支持 staging 和 production 环境,你会如何设计触发规则? + +### 2. 运行环境与容器 + +```yaml +jobs: + build-and-deploy: + runs-on: aliyun + container: node:22-alpine +``` + +- **`runs-on: aliyun`** — 使用了自定义的 Gitea runner,运行在一台阿里云机器上(而非官方 GitHub-hosted runner)。 +- **`container: node:22-alpine`** — 在 Alpine Linux 上的 Node.js 22 容器中执行,体积小、启动快,适合纯前端构建场景。 + +### 3. 安装系统工具 + +```yaml +- name: Install tools + run: | + sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories + apk add --no-cache docker-cli git +``` + +Alpine 默认的软件源在海外,先用 `sed` 替换为阿里云镜像源加速下载。然后安装三个必要的工具: + +| 工具 | 用途 | +|------|------| +| `docker-cli` | 指挥宿主机 Docker daemon,管理其他容器的生命周期 | +| `git` | 版本控制操作所需 | +| `apk` | Alpine 的包管理器(已内置) | + +> [!NOTE] 这里只安装了 `docker-cli`(客户端),没有安装完整的 `docker`。完整的 Docker 包包含 daemon 本身(约 80MB),而我们需要的是轻量级 CLI(约 50MB on Alpine)——因为 daemon 已经在宿主机上运行了。 + +### 4. 检出代码 (Checkout) + +```yaml +- name: Checkout + uses: http://47.121.181.112:3000/wonder/checkout@v4 + with: + github-server-url: http://47.121.181.112:3000 +``` + +使用私有 Gitea 实例的 checkout action,指向地址 `http://47.121.181.112:3000`。通过 `github-server-url` 参数告知 action 正确的 Gitea 服务器地址。 + +#### Checkout 到底做了什么? + +很多开发者误以为 workflow 启动后工作目录里已经有代码了——实际上 runner 初始环境是**空的**。Checkout action 会在幕后执行一系列 git 操作: + +```mermaid +flowchart LR + A["Runner 启动
空工作目录"] --> B["git clone 仓库"] + B --> C["git fetch 指定 commit 的引用"] + C --> D["git reset --hard 精确 SHA"] + D --> E[".git 隐藏目录
pnpm install 可用"] +``` + +| 步骤 | Git 命令等价物 | 作用 | +|------|---------------|------| +| Clone | `git clone ` | 拉取全量历史到 action 工作区 | +| Fetch | `git fetch origin ` | 获取触发 workflow 的具体 commit 引用 | +| Reset | `git reset --hard ` | 将工作树精确还原到目标提交(而非默认分支最新) | + +> [!TIP] PR 场景下尤其要注意:如果 workflow 被 pull_request 事件触发,Checkout 会自动检出 **PR 合并后的结果**(即 `refs/pull//merge`),而不是 PR 分支本身。这确保了 CI 验证的是"合并后能否构建",而非"分支本身能否构建"。 + +#### 常用配置参数 + +实际项目中经常需要调整 checkout 的行为,以下是高频参数及适用场景: + +| 参数 | 默认值 | 用途 | +|------|--------|------| +| `ref` | 触发 workflow 的 commit SHA | 指定检出的分支、标签或 commit | +| `clean` | `true` | 检出前是否清理工作目录残留文件 | +| `persist-credentials` | `true` | 是否在后续步骤中保持 git 认证信息 | +| `fetch-depth` | `1` | 只拉取最近一次提交(fast clone) | + +> [!QUESTION] 什么时候应该设置 `fetch-depth: 0`? +> +> 答案:当你需要完整的 git 历史时——比如用 `git log` 生成 changelog、执行语义化版本计算 (`conventional-changelog`),或者需要 `git blame` 追溯代码来源。`fetch-depth: 1` 是默认值,因为它更快更省空间;只有确实需要历史记录时才改为 `0`。 + +> [!WARNING] 安全注意:CI 中的 checkout 会拿到完整的 repo token。如果 workflow 包含不可信的第三方 action(如开源社区提供的 step),建议设置 `persist-credentials: false`,防止 token 被泄露给非官方代码。 + + +### 5. 安装依赖与构建 + +```yaml +- name: Install pnpm + run: npm install -g pnpm + +- name: Install dependencies + run: pnpm install --frozen-lockfile + +- name: Build all decks + run: pnpm run build +``` + +三步完成从包管理到构建的过程: + +1. **全局安装 pnpm** — 容器初始状态只有 Node.js,需要先装包管理器 +2. **安装项目依赖** — `--frozen-lockfile` 确保依赖版本精确锁定(详见下方说明) +3. **执行构建** — `pnpm run build` 对应 `slidev build`,将所有 `.md` deck 编译为静态站点 + +> [!TIP] Slidev 的 `build` 命令输出目录默认为 `dist/`,每个 slide deck 一个子目录。这就是为什么最后一步可以直接 `cp dist/. nginx:/usr/share/nginx/html/`。 + +#### --frozen-lockfile 的 CI 哲学 + +```bash +# 不加参数:lockfile 被悄悄更新 → 下次构建可能用不同版本 +pnpm install + +# 加 --frozen-lockfile:严格一致,否则立即失败 +pnpm install --frozen-lockfile +``` + +| 场景 | 不加 `--frozen-lockfile` | 加上 `--frozen-lockfile` | +|------|------------------------|-------------------------| +| lockfile 正常 | ✅ 安装 | ✅ 安装 | +| lockfile 缺失 | 🔧 自动生成一个新的 | ❌ **报错退出** | +| lockfile 与 package.json 不匹配 | 🔧 悄悄更新 lockfile | ❌ **报错退出** | + +CI 环境追求的是 **构建可复现性 (Build Reproducibility)**。同一个 commit,不同的人、不同的时间、不同的机器上构建出来的结果必须完全一致——否则线上 bug 将无法定位是"代码问题"还是"环境差异"。 + +```mermaid +flowchart LR + A["开发者修改 package.json"] --> B{"push"} + B --> C["CI: frozen-lockfile 检查"] + C -->|lockfile 已同步| D["✅ 构建通过"] + C -->|lockfile 未同步| E["❌ 立即失败
提示手动运行 pnpm install"] + + style E fill:#ffe8e8 + style D fill:#e8ffe8 +``` + +> [!TIP] 正确的工作流:开发者改完依赖 → `pnpm install` 自动更新 lockfile → 同时提交两个文件。CI 只负责验证一致性,不负责修锁。 + +## 关联笔记 + +- [[CICD 在 Slidev 中的应用]] +- [[deployment-strategy]]