跳到正文

2026-11-13 7 分钟阅读

从写完到上线:我的 Hugo 博客发布流程

记录我的 Hugo 博客发布流程:本地写作与预览、Gitee 推送、服务器 webhook 自动构建,以及用 Claude Code skill 把整套流程自动化。

博客写了几年,发布方式换过好几次。最早是本地 hugo 生成后手动上传,后来改成 Git 推送、服务器拉取,再到现在的 Gitee webhook 自动部署。最近又把整套流程整理成了一个 Claude Code skill:文章可以以任何形式丢过来——聊天里的一段话、Obsidian 笔记、Markdown 文件、几张截图——它会帮我整理、配图、预览、提交、推送,剩下的交给服务器。

这篇文章记录这套流程的具体步骤,以及中间踩过的坑。

一、整体链路

先看全貌:

写作 / 笔记 / 截图
      ↓
整理、脱敏、配图
      ↓
写入 content/posts/<slug>/
      ↓
hugo server 本地预览
      ↓
hugo -d /tmp/... 生产构建验证
      ↓
git commit + git push origin master
      ↓
Gitee webhook → 服务器 git pull + hugo
      ↓
Nginx 站点 https://eatta.cn

技术栈很简单:

  • Hugo(extended)
  • Macchiato 主题(vendored 在 themes/macchiato,不再用 submodule)
  • Gitee 仓库
  • Linux 服务器 + Nginx
  • Gitee webhook 触发部署脚本

核心原则只有一句:Git 仓库是唯一的内容源,服务器只负责构建,不在服务器上改内容。

二、文章目录:page bundle 优先

文章放在 content/posts/ 下。带图片的文章用 page bundle:

content/posts/
└── hugo-blog-publishing-workflow/
    ├── index.md
    └── cover.jpg

不带图片的可以用单文件 content/posts/<slug>.md,但默认还是 bundle,后面加图方便,图片和正文也不会走散。

Front matter 用 TOML:

+++
date = '2026-11-13T18:11:21+08:00'
draft = false
title = '从写完到上线:我的 Hugo 博客发布流程'
slug = 'hugo-blog-publishing-workflow'
tags = ['Hugo', 'Gitee', '博客', '自动化']
categories = ['技术分享']
keywords = ['Hugo', 'Gitee', 'Webhook', '自动部署', '博客发布']
description = '记录我的 Hugo 博客发布流程:本地写作与预览、Gitee 推送、服务器 webhook 自动构建。'
summary = '从 Markdown 草稿到 eatta.cn 上线,我的博客发布已经跑成一条固定流水线。'
+++

几个约定:

  • slug 用英文小写连字符,文章 URL 是 /posts/<slug>/。
  • 已有文章的 slug 绝对不改。改 slug 等于换 URL,搜索引擎索引和外部链接都会断。
  • description 给搜索引擎和社交卡片用;summary 给列表页和 RSS 用。两个都手写,不依赖 Hugo 自动截断。
  • categories 尽量复用已有分类,tags 可以中英文混用。
  • 更新旧文章时保留原 date,只加 lastmod。

三、图片:HEIC 先转 JPEG,顺手去掉 EXIF

我用 iPhone 拍照,原图是 HEIC。Hugo 的图片处理不直接支持 HEIC,所以先转成 JPEG:

magick input.HEIC -auto-orient -resize '1600x1600>' -strip -quality 90 \
  content/posts/<slug>/cover.jpg

三个参数很关键:

  • -auto-orient:按 EXIF 方向摆正,避免照片横着。
  • -resize '1600x1600>':长边限制在 1600px,网页够用,文件也小。
  • -strip:去掉 EXIF、GPS、相机信息。这一步是隐私保护,别省。

正文里直接引用文件名:

![四姑娘山长坪沟](cover.jpg)

不要加 ./。站点里有一个渲染钩子 layouts/_default/_markup/render-image.html,它会自动把 JPEG/PNG 转成 WebP(q85),加上 loading="lazy"、width、height。WebP 和 GIF 原样输出。

图片放 page bundle,不要放 static/:static/ 里的图片不经过 Hugo 的图片处理,不会自动转 WebP。

四、本地预览:一定要加 --renderToMemory

写完先本地看:

cd /path/to/eatta-blog
hugo server --bind 127.0.0.1 --port 1313 --renderToMemory

--renderToMemory 是我踩过坑之后加的。hugo server 默认会把构建结果写进 public/,而构建产物本来就不该进仓库,一跑预览就冒出一堆改动,提交时很容易混进去。加了 --renderToMemory,预览只在内存里渲染,不碰工作区。

--bind 127.0.0.1 是只监听本机,不把预览暴露到局域网。

预览时我会检查:

  • 页面返回 200,标题和 description 正确
  • 目录、表格、代码块、上下篇正常
  • 图片能打开(直接 curl 图片 URL,确认 200)
  • 手机宽度下没有横向滚动

小坑:headless Chrome 截图时,loading="lazy" 的图片可能还没加载,截图里是空白。这不代表页面有问题,直接请求图片 URL 验证就行。

五、构建验证:先构建到 /tmp

预览没问题后,再做一次生产构建,但输出到临时目录:

hugo -d /tmp/eatta-build --cleanDestinationDir

检查三件事:

ls /tmp/eatta-build/posts/<slug>/index.html
grep 'https://eatta.cn/posts/<slug>/' /tmp/eatta-build/sitemap.xml
ls /tmp/eatta-build/posts/<slug>/*.webp
  • 文章页生成了
  • sitemap 包含新 URL
  • 图片 WebP 生成了

不要把生产构建直接跑进 public/。服务器会做最终构建,本地 public/ 里的东西不参与部署,提交它们只会制造噪音。

六、提交与推送:只提交内容源文件

确认无误后提交:

git status --short -- content/
git add content/posts/<slug>/
git commit -m "new post: <slug>"
git push origin master

更新旧文章时,提交信息用 update post: <slug>。

这里有一条纪律:只 git add 文章目录/文件,不要 git add -A。 本地可能残留 public/、resources/_gen/ 的构建缓存,git add -A 会把它们一起带进去。部署由服务器构建,这些文件不需要进仓库。

七、服务器:webhook 自动构建

推送之后就不需要再登录服务器了。Gitee 的 webhook 打到服务器上的一个部署脚本,脚本只做三件事:拉最新代码、跑一次构建、把产物放进 Nginx 的站点目录。所以整个发布动作可以概括为:

push 即发布。

具体命令、站点目录和 webhook 配置就不展开了——那些都是服务器上的信息,公开出来等于给扫描器递地图。比“抄脚本”更值得记住的是两条:

  • webhook 必须校验 secret(或至少限制来源 IP),否则任何人都能伪造一次 push 事件触发构建。
  • 部署脚本用最小权限账号跑,只需要读仓库、写站点目录,别给它多余权限。

以前用 submodule 引主题时,脚本里还有一步 git submodule update --init --recursive;后来把主题 vendored 进仓库,这一步就去掉了,少了一个可能失败的外部依赖。

八、线上验证:推送成功不等于发布成功

推送成功只说明代码到了 Gitee,不代表服务器已经构建完成。所以每次发布后我都会验一遍:

curl -sS -o /tmp/live.html -w '%{http_code}\n' \
  https://eatta.cn/posts/<slug>/
grep -c '文章里的独特短语' /tmp/live.html
curl -s https://eatta.cn/sitemap.xml | grep <slug>

检查:

  • HTTP 200
  • 标题、description 正确
  • 文章里的关键内容已经出现
  • 图片 URL 200,类型是 image/webp
  • sitemap 里有新 URL

webhook 构建通常几秒到几十秒。如果页面还是旧的,等一会儿再试;如果一直不更新,就去服务器看部署日志和 Hugo 的构建输出。

九、把这套流程交给 Claude Code

流程固定下来之后,我把它写成了一个 Claude Code skill,叫 publish-blog-post。现在发布文章可以变成一句话:

把这篇发到博客。

然后把文章、笔记或者截图丢过去。skill 会按上面的步骤执行:

  • 判断是新文章还是更新旧文章;更新时保留原 slug 和 URL
  • 整理正文,检查并脱敏密钥、用户名、主机名、内网 IP、手机号
  • 处理图片:HEIC 转 JPEG、限制 1600px、去掉 EXIF/GPS
  • 补全 front matter:description、summary、keywords、标签、分类
  • 本地预览,验证 200、图片、目录、表格
  • 构建到临时目录,检查 sitemap 和 WebP
  • 只提交 content/ 源文件,推送 Gitee
  • 线上验证,确认关键内容已经出现

默认是直接发布,不会每一步都停下来问。如果我想先看,就说“先给我看”或者“先别发”,它会停在预览这一步。

当然,自动化不代表无脑。遇到这几类情况,它应该停下来问我:

  • 文章里有无法安全脱敏的密钥、密码、token
  • 图片或截图里有账号、聊天记录、内部系统信息
  • 内容是整段搬运别人的文章,版权不确定
  • 价格、时间、技术细节没法确认

十、几个踩过的坑

最后列一下这套流程里踩过的坑,算是给以后的自己留个备忘:

  1. 别提交开发服务器构建的 public/。 里面可能有 localhost:1313 的 canonical、sitemap、搜索索引和 livereload 脚本。
  2. hugo server 默认会写 public/。 用 --renderToMemory。
  3. 不要改旧文章的 slug。 URL 会变,索引会丢。
  4. HEIC 一定要 -strip。 照片里的 GPS 坐标比想象中多。
  5. 图片放 page bundle。 static/ 不参与 Hugo 图片处理。
  6. 只提交 content/。 构建产物交给服务器。
  7. 推送后要验线上。 webhook 也会失败,别假设一定成功。
  8. 笔记里的密钥先撤销再发布。 脱敏只能去掉文本,已经泄露的密钥要轮换。

结尾

这套流程最大的变化不是“快了多少”,而是发布不再需要决策。写完之后,整理、预览、提交、部署、验证都按固定路径走,我只需要判断内容本身值不值得发。

发布不该是负担,写作才是。

分类 标签