[{"content":"","date":"2026-05-01","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"2026-05-01","externalUrl":null,"permalink":"/tags/ci/cd/","section":"Tags","summary":"","title":"CI/CD","type":"tags"},{"content":"","date":"2026-05-01","externalUrl":null,"permalink":"/tags/github-actions/","section":"Tags","summary":"","title":"GitHub Actions","type":"tags"},{"content":"","date":"2026-05-01","externalUrl":null,"permalink":"/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"适合人群：已有 Hugo 博客 + 独立 VPS，想摆脱手动 scp 部署，实现 push 自动发布的开发者。\n最终效果：push 到 main 分支 → GitHub Actions 自动构建 → rsync 传到 VPS → 20 秒内上线。\n为什么要写这篇 # 我在搭这套流程时，前后踩了 4 个坑，每个单独看都不难，但文档里找不到，只能靠报错日志一点点排查。这篇把全流程梳理一遍，重点把容易翻车的地方标出来，省得你重蹈覆辙。\n架构设计 # push main └─ GitHub Actions ├─ checkout + submodules ├─ Hugo 0.161.1 extended 构建 ├─ rsync → VPS /var/www/releases/\u0026lt;timestamp\u0026gt;/ ├─ ln -snf 切换 symlink（原子操作） └─ curl healthcheck 关键设计决策：symlink 原子切换\n不要用 rsync --delete 直接覆盖线上目录。Hugo 生成的静态文件之间有引用关系（JS 引 CSS hash），rsync 传输到一半如果中断，VPS 上会出现新旧文件混合的状态，导致 404。\n正确做法：rsync 传到一个新的 timestamp 目录，成功后用 ln -snf 切换 symlink。ln -snf 是原子操作，nginx 读到的永远是完整的某一版，不存在中间态。\n准备工作 # VPS：Ubuntu 22.04，已有 nginx 服务博客 博客仓库：github.com/你的org/blog，主分支 main 本地：macOS 或 Linux 第一步：VPS 准备（root 登录执行） # 1.1 创建 deploy 专用用户 # useradd -m -s /bin/bash deploy ⚠️ 常见错误：shell 设成 nologin\n很多教程建议用 /usr/sbin/nologin 作为 deploy 用户的 shell，看起来更安全。但这行不通。\nOpenSSH 在执行 authorized_keys 里的 command= 强制命令时，是通过 shell -c command 来调用的。如果 shell 是 nologin，它会打印 \u0026ldquo;This account is currently not available.\u0026rdquo; 然后退出，rsync 客户端收到这段文字就报 \u0026ldquo;protocol version mismatch\u0026rdquo;。\n正确做法：shell 设为 /bin/bash，安全性通过 forced command + no-pty 限制来保证，效果是一样的。\n1.2 创建 SSH 目录 # mkdir -p /home/deploy/.ssh chmod 700 /home/deploy/.ssh chown deploy:deploy /home/deploy/.ssh 1.3 安装 rrsync # Ubuntu 22.04 不预装 rrsync，需要手动安装：\napt-get update \u0026amp;\u0026amp; apt-get install -y rsync # 验证 which rrsync # 输出：/usr/bin/rrsync ⚠️ 不要跳过这步。rsync 命令和 rrsync 是两个东西：\nrsync：数据传输工具（两端都需要） rrsync：restricted rsync，是服务端的\u0026quot;守门员\u0026quot;，限制 rsync 只能写入指定目录 1.4 编写部署脚本 # cat \u0026gt; /usr/local/bin/blog-deploy.sh \u0026lt;\u0026lt; \u0026#39;SCRIPT\u0026#39; #!/bin/bash # 以 forced command 方式运行：rrsync 接收文件 + symlink 原子切换 set -euo pipefail umask 022 RELEASES_DIR=\u0026#34;/var/www/releases\u0026#34; SYMLINK=\u0026#34;/var/www/laodao-ai\u0026#34; # 改成你的实际路径 KEEP=5 TIMESTAMP=$(date +%Y%m%d%H%M%S) DEST=\u0026#34;${RELEASES_DIR}/${TIMESTAMP}\u0026#34; mkdir -p \u0026#34;${DEST}\u0026#34; # ⚠️ 关键：set +e 包裹 rrsync，否则清理逻辑不可达（见下方说明） set +e /usr/bin/rrsync -wo \u0026#34;${DEST}\u0026#34; RSYNC_EXIT=$? set -e if [ \u0026#34;${RSYNC_EXIT}\u0026#34; -eq 0 ]; then ln -snf \u0026#34;${DEST}\u0026#34; \u0026#34;${SYMLINK}\u0026#34; ls -dt \u0026#34;${RELEASES_DIR}\u0026#34;/[0-9]*/ 2\u0026gt;/dev/null | tail -n +\u0026#34;$((KEEP + 1))\u0026#34; | xargs -r rm -rf else rm -rf \u0026#34;${DEST}\u0026#34; fi exit \u0026#34;${RSYNC_EXIT}\u0026#34; SCRIPT ⚠️ set -e 陷阱\n脚本顶部的 set -euo pipefail 很好，但会带来一个副作用：任何命令以非零退出码结束，脚本立即退出，后面的代码不执行。\n如果你这样写：\n/usr/bin/rrsync -wo \u0026#34;${DEST}\u0026#34; RSYNC_EXIT=$? # ← 永远不会到达这里（rrsync 失败时） rrsync 失败 → set -e 触发脚本退出 → $? 捕获失败 → else 分支的 rm -rf \u0026quot;${DEST}\u0026quot; 永远不执行 → 目录垃圾堆积。\n正确做法：用 set +e / set -e 临时关闭，手动捕获退出码。\n1.5 设置脚本权限 # chown root:deploy /usr/local/bin/blog-deploy.sh chmod 750 /usr/local/bin/blog-deploy.sh ⚠️ 属组必须是 deploy，不能是 root:root\nchmod 750 的权限分布：\n7（rwx）：属主 root 可读写执行 5（r-x）：属组可读执行 0（\u0026mdash;）：其他人无权限 deploy 用户不在 root 组，如果属主是 root:root，deploy 落入\u0026quot;其他人\u0026quot;，没有执行权限，CI 会报 Permission denied。\n属组设为 deploy → deploy 用户进入\u0026quot;属组\u0026quot;一栏 → 有读+执行权限 → 脚本可以运行。\n1.6 创建 releases 目录并设置权限 # mkdir -p /var/www/releases chown -R deploy:deploy /var/www/releases/ # 关键：/var/www/ 本身需要 deploy 组可写（用于创建/替换 symlink） chown root:deploy /var/www chmod 775 /var/www # 验证 ls -la /var | grep www # 应该显示：drwxrwxr-x root deploy ⚠️ symlink 需要父目录写权限\nln -snf /var/www/releases/xxx /var/www/laodao-ai 这个操作，实际上是在 /var/www/ 目录里创建/替换一个条目。\n要在目录里创建文件（包括 symlink），需要对该目录本身有写权限，不是对 symlink 本身。\ndeploy 用户默认对 /var/www/ 没有写权限 → ln 报 Permission denied → symlink 不更新 → 旧版还在上面，但 CI 显示成功（rsync 其实成功了）。\n解决：给 /var/www/ 开放 deploy 组的写权限。\n第二步：本地生成 deploy key（macOS 执行） # # 生成专用 deploy key，无密码（CI 自动使用） ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -N \u0026#34;\u0026#34; # 查看公钥（后面要用） cat ~/.ssh/deploy_key.pub # 获取 VPS 主机指纹（后面要填入 GitHub Secrets） ssh-keyscan -H 你的VPS_IP 第三步：配置 GitHub Secrets # 在 github.com/你的org/blog → Settings → Secrets and variables → Actions → 停留在 Secrets tab（不是 Variables）。\n⚠️ Secrets vs Variables 的区别\nSecrets：加密存储，在 Actions 日志中自动脱敏显示为 *** Variables：明文存储，日志里可见 SSH 私钥和 IP 都是敏感信息，必须用 Secrets。\n点击 New repository secret，依次添加三个：\nDEPLOY_SSH_KEY\ncat ~/.ssh/deploy_key 把完整输出粘贴进去，包括首尾的 -----BEGIN OPENSSH PRIVATE KEY----- 和 -----END OPENSSH PRIVATE KEY----- 两行。\nVPS_HOST\n直接填 VPS IP，例如 1.2.3.4。\nKNOWN_HOSTS\nssh-keyscan -H 你的VPS_IP 把完整输出（通常是 3 行，分别是 ecdsa、rsa、ed25519 三种算法）全部粘贴进去。\n第四步：VPS 配置 authorized_keys # 先把公钥传到 VPS：\nscp ~/.ssh/deploy_key.pub root@你的VPS_IP:/tmp/deploy_key.pub SSH 进 VPS，写入 authorized_keys：\n# ⚠️ 分两步写，避免引号地狱 echo -n \u0026#39;command=\u0026#34;/usr/local/bin/blog-deploy.sh\u0026#34;,no-pty,no-agent-forwarding,no-X11-forwarding,no-port-forwarding \u0026#39; \\ \u0026gt; /home/deploy/.ssh/authorized_keys cat /tmp/deploy_key.pub \u0026gt;\u0026gt; /home/deploy/.ssh/authorized_keys # 验证：应该是一整行 cat /home/deploy/.ssh/authorized_keys ⚠️ 公钥内容有空格，不能直接拼在单引号字符串里\ndeploy_key.pub 的内容是 ssh-ed25519 AAAA... user@host，中间有空格。如果你试图把它直接拼在 echo '...前缀... \u0026lt;公钥\u0026gt;' 里，shell 的引号规则会让你头大。\n最简单的方法：用两步拼接。先用 echo -n（-n 去掉换行）写入前缀，再用 cat \u0026gt;\u0026gt; 追加公钥内容，这样两部分在同一行，不需要处理任何引号。\n设置权限：\nchmod 600 /home/deploy/.ssh/authorized_keys chown deploy:deploy /home/deploy/.ssh/authorized_keys 验证 certbot 不受影响（如果你用 Let\u0026rsquo;s Encrypt）：\ncertbot renew --dry-run 第五步：创建 GitHub Actions Workflow # 在博客仓库本地创建文件：\nmkdir -p .github/workflows 写入 .github/workflows/deploy.yml：\nname: Deploy Blog on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: \u0026#34;true\u0026#34; # 提前兼容 Node.js 24 steps: - name: Checkout uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - name: Setup Hugo uses: peaceiris/actions-hugo@v3 with: hugo-version: \u0026#34;0.161.1\u0026#34; # 固定版本，与本地一致 extended: true - name: Build run: hugo --minify --gc - name: Configure SSH env: DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} KNOWN_HOSTS: ${{ secrets.KNOWN_HOSTS }} run: | mkdir -p ~/.ssh echo \u0026#34;$DEPLOY_SSH_KEY\u0026#34; \u0026gt; ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key echo \u0026#34;$KNOWN_HOSTS\u0026#34; \u0026gt; ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts - name: Deploy via rsync env: VPS_HOST: ${{ secrets.VPS_HOST }} run: | rsync -avz --delete \\ -e \u0026#34;ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=yes\u0026#34; \\ ./public/ deploy@$VPS_HOST: - name: Health check run: | curl -f --retry 3 --retry-delay 2 https://你的域名.com 几个关键点：\nsubmodules: recursive：Hugo 主题通常是 git submodule，必须加 hugo-version：固定版本，不要用 latest，本地和 CI 版本不一致会导致构建结果差异 StrictHostKeyChecking=yes：不要图省事用 StrictHostKeyChecking=no，那等于在 CI runner 上关掉 MITM 检测 FORCE_JAVASCRIPT_ACTIONS_TO_NODE24：GitHub Actions 在 2026 年 6 月强制切换 Node.js 24，提前加这个 env var 现在就能测试兼容性 commit 并 push：\ngit add .github/workflows/deploy.yml git commit -m \u0026#34;ci: add GitHub Actions deploy workflow\u0026#34; git push origin main 第六步：验证 # 6.1 查看 CI 运行结果 # push 后约 30 秒，在 GitHub → Actions 页面看到运行记录。全绿代表成功：\n✓ Checkout ✓ Setup Hugo ✓ Build ✓ Configure SSH ✓ Deploy via rsync ✓ Health check 6.2 验证 VPS 目录结构 # SSH 进 VPS：\nls -la /var/www/releases/ # 应该有 20260501XXXXXX 格式的 timestamp 目录 ls -la /var/www/laodao-ai # 应该显示：lrwxrwxrwx ... /var/www/laodao-ai -\u0026gt; /var/www/releases/20260501XXXXXX 6.3 回滚安全验证（必做） # 在任意文章里加一个不存在的 shortcode，触发 Hugo 构建失败：\n{{\u0026lt; this_shortcode_does_not_exist \u0026gt;}} push 后观察：\nCI 在 Build 步骤失败，rsync 步骤不执行 curl https://你的域名.com 仍返回 200，线上内容完好 VPS 的 symlink 指向没有变化 验证通过后删掉这行，再 push，CI 恢复全绿。\n踩坑汇总 # 错误现象 根因 修复 protocol version mismatch deploy shell 是 nologin，forced command 无法执行 usermod -s /bin/bash deploy Permission denied 执行 blog-deploy.sh 脚本属组是 root，deploy 用户无执行权限 chown root:deploy + chmod 750 ln: failed to create symbolic link: Permission denied deploy 对 /var/www/ 目录无写权限 chown root:deploy /var/www \u0026amp;\u0026amp; chmod 775 /var/www No such file or directory: /usr/bin/rrsync Ubuntu 22.04 未预装 rrsync apt install rsync rsync 失败后 releases/ 留有空目录 set -e 使清理逻辑不可达 set +e / set -e 包裹 rrsync 调用 安全性说明 # 搭完之后，deploy 用户的权限边界是这样的：\nforced command：authorized_keys 里写死 command=blog-deploy.sh，SSH 连接建立后只能执行这一个脚本，不能执行任何其他命令 no-pty：不能获得交互式终端 no-agent-forwarding / no-X11-forwarding / no-port-forwarding：关掉所有转发能力 rrsync -wo：限制 rsync 只能写入指定目录，不能读取其他文件 即使 deploy key 泄露，攻击者能做的只有：往 /var/www/releases/ rsync 静态文件。无法获得 shell，无法读取系统文件，无法横向移动。\n下一步 # 如果你有多个环境（staging/production），可以再加一个 deploy user 和对应的 authorized_keys 条目 阶段 2 可以把 check-summary.sh 等质量检查接入 CI，在构建前就拦截不合规的文章 想手动回滚到某个历史版本，在 VPS 上执行： ln -snf /var/www/releases/20260501XXXXXX /var/www/laodao-ai 秒级生效，无需重新部署。\n","date":"2026-05-01","externalUrl":null,"permalink":"/posts/hugo-%E5%8D%9A%E5%AE%A2%E8%87%AA%E5%8A%A8%E9%83%A8%E7%BD%B2github-actions--vps-%E5%85%A8%E6%B5%81%E7%A8%8B%E8%B8%A9%E5%9D%91%E5%AE%9E%E5%BD%95/","section":"Posts","summary":"从零搭建 Hugo 博客的 CI/CD 流水线：push main 自动构建部署，原子回滚保护，附真实踩坑记录。每个步骤都经过实际验证，特别标注了容易翻车的细节。","title":"Hugo 博客自动部署：GitHub Actions + VPS 全流程踩坑实录","type":"posts"},{"content":"","date":"2026-05-01","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"2026-05-01","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"2026-05-01","externalUrl":null,"permalink":"/tags/vps/","section":"Tags","summary":"","title":"VPS","type":"tags"},{"content":"","date":"2026-05-01","externalUrl":null,"permalink":"/categories/%E5%B7%A5%E7%A8%8B%E5%AE%9E%E8%B7%B5/","section":"Categories","summary":"","title":"工程实践","type":"categories"},{"content":"","date":"2026-05-01","externalUrl":null,"permalink":"/","section":"老刀AI码场","summary":"","title":"老刀AI码场","type":"page"},{"content":"","date":"2026-05-01","externalUrl":null,"permalink":"/tags/%E8%BF%90%E7%BB%B4/","section":"Tags","summary":"","title":"运维","type":"tags"},{"content":"老刀AI码场的第一篇博客就这样上线了。这是一篇阶段 1 的烟雾测试文——它的主要目的不是讲清某个技术问题，而是把\u0026quot;博客基础设施\u0026quot;这条流水线跑通：archetype 写作模板、check-summary 摘要质量守门、JSON-LD 结构化数据注入、Hugo + Blowfish 渲染、scp 推送到 VPS——任何一环出问题都该在这一篇里暴露。等阶段 2 系列开篇文章上线后，本文将转为 archived 状态。\n这个博客在讲什么 # 围绕 AI 驱动后端开发的融合工作流 做的系列内容。核心工具栈是 Claude Code + OpenSpec + Superpowers + GStack，场景以 Go 后端为主，辅以嵌入式变体。\n后续将按以下三条主线推进：\n主线 视角 起点文章 层级视角 写代码 → 规格驱动 → 路线图 → 高阶评审 ep01 系列开篇（W1） 阶段视角 需求细化 / 代码生成 / 代码审查与归档 ep02 S/M/L 操作手册（W5） 工具对比 同类型 skill 怎么选、什么时候用哪个 special-1 / 2 / 3（W2-W4） 从一个 hello 函数说起 # 每条系列正式开始前，让我们先看一段 Go 代码——这也是老刀的语言主场：\npackage main import \u0026#34;fmt\u0026#34; // Greet returns a greeting message tailored by language code. // 仅支持 zh / en，其他默认 en。 func Greet(name, lang string) string { switch lang { case \u0026#34;zh\u0026#34;: return fmt.Sprintf(\u0026#34;你好，%s。这里是老刀AI码场。\u0026#34;, name) default: return fmt.Sprintf(\u0026#34;Hello %s, welcome to laodao-ai.\u0026#34;, name) } } func main() { fmt.Println(Greet(\u0026#34;读者\u0026#34;, \u0026#34;zh\u0026#34;)) } 输出：\n你好，读者。这里是老刀AI码场。 内容流水线 # 下图是这个博客背后的生产链路，每一步都有对应的工具支撑：\ngraph LR A[读者带问题来] --\u003e B[博客静态页] B --\u003e C[AI 爬虫抓取] C --\u003e D[结构化数据 JSON-LD] D --\u003e E[反向引用 / 推荐] E --\u003e A 四个节点分别对应博客需要解决的四件事：让人能读到（B）、让 AI 能消化（C）、让结构能被识别（D）、让推荐流量回流（E）。GEO（Generative Engine Optimization）这个词的核心，就是把这四件事一次做对——其中 robots.txt 允许爬虫、Article JSON-LD 标识文章、llms.txt 端点提供站点大纲，是阶段 1 已经落地的三件基础设施。\nfront matter 里有什么 # 这个博客的每篇文章都用约束性 archetype 生成，强制约束 6 个必含字段：\n--- title: \u0026#34;文章标题\u0026#34; date: 2026-04-29 draft: false summary: \u0026#34;一段 80-200 字的摘要，会被用作 og:description 和 LLM 引用的主信息源\u0026#34; tags: [\u0026#34;标签1\u0026#34;, \u0026#34;标签2\u0026#34;] categories: [\u0026#34;分类\u0026#34;] --- summary 字段会被 scripts/check-summary.sh 强制校验——长度必须 ∈ [80, 200] 字符、不含字面量 TODO；正文首段也不能含 TODO。这是写作规范层的\u0026quot;质量守门\u0026quot;。\n接下来 # 下一篇是 ep01 系列开篇：《AI 驱动后端开发的四层级工作流：从写对代码到治理演进》——会把\u0026quot;四层级\u0026quot;这个心智模型讲清楚，并带 ai-shorurl 真实工程案例。如果你对这套工作流感兴趣，可以现在就把 RSS 订阅好（/index.xml）或者 follow github.com/laodao-ai 获取系列更新。\n也欢迎通过 GitHub Issues 提问、反驳、或分享你自己跑这套工作流的踩坑——比起写文章，听到你怎么用 更让我有动力继续写下去。\n","date":"2026-04-29","externalUrl":null,"permalink":"/posts/hello-laodao/","section":"Posts","summary":"这是老刀AI码场的第一篇博客文章，本文是阶段 1 的烟雾测试文，用来验证 Hugo + Blowfish 主题对代码块、Mermaid 流程图、表格和 TOC 的渲染能力，同时为系列后续内容做开篇预告。","title":"Hello, 老刀AI码场","type":"posts"},{"content":"","date":"2026-04-29","externalUrl":null,"permalink":"/tags/%E6%B5%8B%E8%AF%95%E6%96%87/","section":"Tags","summary":"","title":"测试文","type":"tags"},{"content":"","date":"2026-04-29","externalUrl":null,"permalink":"/categories/%E5%85%AC%E5%91%8A/","section":"Categories","summary":"","title":"公告","type":"categories"},{"content":" 我是谁 # 我是老刀（laodao），Go 后端工程师，专注于 Spec 驱动开发（SDD） 在真实项目中的工程化实践。\n这里是**老刀AI码场（laodao-ai）**的官方博客，承载我用 Claude Code + OpenSpec + Superpowers + GStack 这一整套工具链做真实项目时积累的笔记、教程与踩坑实录。\n这个博客在讲什么 # 围绕\u0026quot;AI 驱动后端开发的融合工作流\u0026quot;做的系列内容。三条主线：\n层级视角：写代码 → 规格驱动 → 路线图 → 高阶评审的四层级演进 阶段视角：需求细化 / 代码生成 / 代码审查与归档的三阶段协作 场景化裁剪：S/M/L 三档工作流操作手册——一次改动该走哪档 每篇教程类文章会带配套 GitHub 仓库链接（譬如 laodao-ai/shorturl），文章和代码双向互链；每个变更走完都会归档进 OpenSpec spec 主线，可读可审计。\n怎么找到我 # GitHub：github.com/laodao-ai 博客：laodao-ai.com（国际） / laodao-ai.cn（国内，备案中） RSS：/index.xml AI 友好端点：/llms.txt · /llms-full.txt 如果你也在用 SDD + AI 协作开发，欢迎通过 GitHub Issues 交流——比起被动读文章，我更想看到你把这些工作流跑在自己的项目里之后的经验反馈。\n","date":"2026-04-29","externalUrl":null,"permalink":"/about/","section":"老刀AI码场","summary":"我是谁 # 我是老刀（laodao），Go 后端工程师，专注于 Spec 驱动开发（SDD） 在真实项目中的工程化实践。\n","title":"关于老刀","type":"page"},{"content":"","date":"2026-04-29","externalUrl":null,"permalink":"/tags/%E7%B3%BB%E5%88%97%E5%BC%80%E7%AF%87/","section":"Tags","summary":"","title":"系列开篇","type":"tags"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"}]