Hexo 使用 Gitea Actions 托管和部署

介绍

之前分别写过在 Ubuntu 上部署 Gitea 和 Drone和Hexo 使用 Gitea 和 Drone 托管和部署,用 Gitea 托管博客源码,再通过 Drone 生成页面并上传到服务器。后来原来的博客服务器停了,Gitea 也迁移到了另一台机器,地址、SSH 和反向代理没有完全整理好。

最近重新维护服务器,顺便把这套东西重新搭了起来。现在 Gitea 自己支持 Actions,构建任务可以直接放在仓库里管理,所以这次没有再单独部署 Drone。写文章的方式没有变化,仍然是在本地修改 Markdown、提交并推送,后面的构建和发布交给工作流处理。

除了替换 CI,这次也调整了发布方式。每次构建的结果单独保存成一个版本,检查通过后再切换网站目录。如果新版本有问题,还可以切回上一个版本。

下面从整理 Gitea、配置 Runner 开始,记录这次 Hexo 博客重新部署的完整过程,包括构建、文章时间恢复、SSH 上传、Nginx 接入和回滚。构建示例使用 Node.js 24、Hexo 8.1.2 和 NexT 8.29.1。下面的域名、管理端口、账号和部署路径均为示例,使用时请替换为自己的配置。

部署结构

这次沿用 Docker 部署 Gitea 和 PostgreSQL,Nginx 负责反向代理和 HTTPS。Actions 的任务由单独的 Runner 执行,每次构建再创建一个临时的 Node.js 容器。

1
2
3
4
5
浏览器、HTTPS Git → Nginx :443 → Gitea :3000 → PostgreSQL
SSH Git → 服务器 :22 → Gitea 容器 :22
服务器管理 → 服务器 :22022 → 宿主机 SSH

Gitea Actions → Runner → Node.js 任务容器 → 构建、检查和发布

需要区分的是,Gitea 提供 Actions 页面和任务调度,Runner 才是真正执行工作流的程序。只打开仓库里的 Actions 功能,任务并不会自己运行。官方快速开始对这两部分有介绍。

对资源有限的环境,可以把 Runner 注册到单个博客仓库,并设为一次只执行一个任务。有独立构建机器时,也可以把 Runner 放到另一台机器上。

构建和发布流程

1
2
3
4
5
6
7
8
本地修改文章 → git commit / git push → Gitea Actions
→ 检出完整 Git 历史
→ npm ci 安装锁定依赖
→ 恢复文章修改时间
→ Hexo 构建和检查
→ SSH 上传到独立临时目录
→ 检查产物并切换 current
→ Nginx 提供静态页面

Gitea 仓库保存的是博客源码,生成结果位于 public/,不提交回源码仓库。网站服务器使用 Nginx 提供静态文件,不需要在宿主机安装 Node.js 或 Hexo。

下面的示例采用我现在仓库中的脚本。配套配置和脚本可以在这里下载。解压后按说明合并到现有的 Hexo 仓库,不要覆盖自己的文章和站点配置。配套包提供通用标签检查,旧标签的 Nginx 跳转规则也可按需参考。

整理 Gitea 的数据和配置

先确认迁移和升级是否完整

如果是全新安装,可以参考 Gitea 的 Docker 安装文档。我这次面对的是已经迁移过的服务,所以先检查了数据目录、数据库、仓库地址和原来的配置。

PostgreSQL 是 Gitea 使用的独立数据库容器,并不是 Gitea 镜像自带的一部分。升级 Gitea,不会自动把数据库也升级掉。

这次数据库做了大版本迁移。处理时先做逻辑备份,在隔离的数据库和 Gitea 容器中演练恢复,再停写、备份正式数据并切换。新的数据库使用新的数据目录,保留原来的数据库目录和镜像用于恢复。

除了数据库,还要保留 Gitea 的配置、仓库文件、附件以及 LFS 等存储。备份是否完整,要通过实际恢复来确认。升级之后,我又核对了仓库和用户数量、数据库健康状态,并使用临时仓库测试了克隆、提交、推送和拉取。

统一网站地址和 SSH 地址

Gitea 的网站地址和 Git 的 SSH 入口需要分别配置。即便网站能打开,也会存在 SSH 克隆不能使用的风险。

我希望最终可以直接使用这样的地址:

1
git clone git@git.example.com:example/blog.git

这种写法默认连接 SSH 的 22 端口。下面以管理 SSH 使用 22022、Git SSH 使用 22 为例。

入口 用途
https://git.example.com 网站和 HTTPS Git
git@git.example.com:example/blog.git SSH Git,使用 22 端口
ssh -p 22022 admin@git.example.com 服务器管理

管理端口切换时,先让新旧入口并存,确认安全组放行且新端口可以通过密钥登录,再释放 22。Ubuntu 上还需要检查是否由 ssh.socket 监听端口,不能只修改 sshd_config 就认为迁移完成。

Gitea 的相关 app.ini 配置如下。Docker 部署也可以使用对应的环境变量配置:

1
2
3
4
5
6
7
8
[server]
DOMAIN = git.example.com
ROOT_URL = https://git.example.com/
SSH_DOMAIN = git.example.com
SSH_PORT = 22

[actions]
ENABLED = true

其中 SSH_PORT 是对外展示的端口,还必须有实际的 Docker 端口映射。例如使用带 OpenSSH 的默认 Gitea 镜像时,将宿主机 22 映射到容器 22;Web 端口则只映射到本机:

1
2
3
ports:
- "127.0.0.1:3000:3000"
- "22:22"

如果同一个域名还使用 Cloudflare 的普通网站代理,也要把 SSH 的访问方式考虑进去。例如可以将 Git 域名设为“仅 DNS”,由服务器证书提供 HTTPS。

检查 Gitea 的 Nginx 和 HTTPS

Nginx 代理到本机的 Gitea Web 端口,Gitea 的 3000 端口不需要直接暴露到公网。以下是 HTTP 站点的基础示例,申请证书后再由 HTTPS 配置接管:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server {
listen 80;
server_name git.example.com;
client_max_body_size 512M;

location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
}
}

修改之后,先执行 sudo nginx -t,通过后再 reload。域名已正确解析且 80 端口可达时,可以使用 Certbot 配置证书:

1
2
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d git.example.com

迁移时还要检查是否保留了重复的 server_name 或旧站点配置。我这次也修正了反向代理、仓库钩子和 SSH 授权文件。完成后,分别确认网站 HTTPS、Git HTTPS 和 Git SSH 都能工作。Nginx 的更多配置可以参考官方反向代理说明。

安装 Actions Runner

启用仓库 Actions

Gitea 从 1.21 开始默认启用实例级 Actions,但具体仓库仍需要检查仓库设置里的 Actions 是否开启。接着在这个博客仓库的 Actions Runner 设置里取得注册令牌。

这里使用仓库级令牌,只给博客仓库注册 Runner。实例级或组织级 Runner 的使用范围不同,不要在注册时选错。

准备 Runner 二进制

这次服务器访问 Docker Hub 不太稳定,所以没有依赖首次启动时在线拉取 Runner 镜像。我下载了官方 Runner 二进制,核对 SHA256,再放进服务器已经缓存的运行镜像里执行。

在准备放置 Runner 的目录中创建 bin、data,下载与服务器架构对应的文件。下面以 Linux amd64、2.3.0 为例:

1
2
3
4
5
6
mkdir -p bin data
VERSION=2.3.0
curl -fSLO "https://dl.gitea.com/gitea-runner/$VERSION/gitea-runner-$VERSION-linux-amd64"
curl -fSLO "https://dl.gitea.com/gitea-runner/$VERSION/gitea-runner-$VERSION-linux-amd64.sha256"
sha256sum -c "gitea-runner-$VERSION-linux-amd64.sha256"
install -m 0755 "gitea-runner-$VERSION-linux-amd64" bin/gitea-runner

其他架构需要选择对应文件。二进制安装方式参考官方说明。如果网络正常,也可以使用官方 Runner Docker 镜像。

配置 Runner

下面是面向单任务构建的 config.yaml 示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
log:
level: info
runner:
file: .runner
capacity: 1
timeout: 30m
shutdown_timeout: 5m
insecure: false
labels:
- ubuntu-latest:docker://node:24-bookworm
envs:
NODE_OPTIONS: --max-old-space-size=384
cache:
enabled: false
container:
privileged: false
options: --memory=512m --memory-swap=512m --cpus=1 --pids-limit=256
valid_volumes: []
docker_host: '-'
force_pull: false
require_docker: true
health_check:
enabled: true
min_free_disk_space_mb: 2048

这里把 ubuntu-latest 标签映射到 node:24-bookworm。它只是任务匹配标签,并不意味着任务实际运行在一台 Ubuntu 虚拟机里;本例中的任务运行在 Debian Bookworm 的 Node 容器里。

Runner 并发设置为 1,每个任务容器最多使用 512 MiB 内存和 1 个 CPU,Node 堆上限为 384 MiB。Docker socket 不传入任务容器,也不允许任务额外挂载宿主机目录。配置含义可以对照Runner 配置文档。

接着创建 compose.yaml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
services:
runner:
# Bootstrap with an already cached image providing git and CA certificates.
# Only the separately checksum-verified Runner binary is executed.
image: ${BLOG_RUNNER_RUNTIME_IMAGE:?Set a cached image ID}
pull_policy: never
container_name: example-blog-runner
restart: unless-stopped
user: ${BLOG_RUNNER_UID:-1000}:${BLOG_RUNNER_GID:-1000}
group_add:
- ${BLOG_DOCKER_GID:?Set Docker socket group ID}
working_dir: /data
entrypoint: [/usr/local/bin/gitea-runner]
command: [daemon, --config, /config.yaml]
mem_limit: 192m
cpus: 0.25
pids_limit: 128
security_opt:
- no-new-privileges:true
volumes:
- ./data:/data
- ./config.yaml:/config.yaml:ro
- ./bin/gitea-runner:/usr/local/bin/gitea-runner:ro
- /var/run/docker.sock:/var/run/docker.sock
logging:
driver: json-file
options:
max-size: 5m
max-file: '2'

同目录下的 .env 需要填入本机值:

1
2
3
4
BLOG_RUNNER_RUNTIME_IMAGE=sha256:replace-with-verified-local-image-id
BLOG_RUNNER_UID=1000
BLOG_RUNNER_GID=1000
BLOG_DOCKER_GID=replace-with-docker-socket-group-id

可以用 id -u、id -g 查看运行账户的 UID/GID,用 stat -c '%g' /var/run/docker.sock 查看 Docker socket 的组。data 目录也需要允许这个 UID 写入。

BLOG_RUNNER_RUNTIME_IMAGE 使用已缓存、来源已确认且包含 Git 和 CA 证书的镜像完整 ID。这里可以复用符合条件的本地镜像,由 Compose 指定入口执行 Runner。镜像要有 Git,是因为工作流里会直接使用 Git 检出博客源码。

Runner 容器本身限制为 192 MiB 内存和 0.25 CPU。这里仍然挂载了宿主机 Docker socket,Runner 因而拥有很高的宿主机权限;我只用它运行自己可信仓库的任务。资源上限也需要根据实际机器余量调整。

注册和启动

使用仓库设置中取得的注册令牌进行注册。令牌通过环境变量传入,不写在命令参数中:

1
2
3
4
5
6
7
read -rsp 'Runner registration token: ' GITEA_RUNNER_REGISTRATION_TOKEN
printf '\n'
export GITEA_RUNNER_REGISTRATION_TOKEN
docker compose run --rm -e GITEA_RUNNER_REGISTRATION_TOKEN runner \
--config /config.yaml register --no-interactive \
--instance https://git.example.com/ --name blog-runner
unset GITEA_RUNNER_REGISTRATION_TOKEN

这个 Compose 文件的工作目录是 /data,对应宿主机的 data 目录。注册成功后,应当生成 data/.runner。它保存 Runner 身份和认证信息,需要持久化保存,不能提交进博客仓库。

注册时我遇到过 runner registration token not found,最后发现是 2.3.0 这次实际使用的 --token-file 路径把令牌文件末尾的换行也带了进去。改成不带 CR/LF 的环境变量后注册成功。这里只记录本次遇到的情况,其他版本要以实际行为为准。

之后启动 Runner:

1
2
3
docker compose config --quiet
docker compose up -d
docker compose logs --tail 30

回到仓库设置,确认 Runner 为 online,标签中有 ubuntu-latest。注册和工作目录的说明可以参考官方文档。注册令牌使用完后可以在仓库设置中重置;日常运行使用的是 .runner 中的身份信息。

准备构建镜像

Runner 能启动,不代表任务镜像也已经准备好。工作流使用的 node:24-bookworm 必须能够被宿主机 Docker 获取,而且任务容器还需要能访问 Gitea 和 npm。

网络正常时,可以提前拉取并确认运行环境:

1
2
3
docker pull node:24-bookworm
docker run --rm node:24-bookworm node --version
docker run --rm node:24-bookworm npm --version

我这次先从 Docker 官方 registry 查询对应平台的镜像摘要,再经可达来源按摘要取得镜像,核对后赋予本地标签。Runner 不强制拉取,每次构建优先使用本机已缓存的镜像。后续更新也要重新核对来源和摘要,不能只看第三方镜像站上的可变标签。

这部分没有修改 Docker 的全局镜像源,也没有为了拉取构建镜像去重启已经在运行的 Gitea 和数据库。

验证 Runner 是否能执行任务

可以先创建 .gitea/workflows/runner-smoke.yml,用一个最小任务验证标签匹配和 Node 环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
name: Runner smoke test
on:
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
steps:
- name: Check environment
run: |
node --version
npm --version
git --version

提交并推送之后,在 Actions 页面手动执行。如果任务一直排队,先检查 Runner 是否在线、标签是否匹配;如果卡在创建容器阶段,检查镜像;如果已经进入任务但无法访问仓库或安装依赖,再检查容器内的网络。

准备博客仓库

博客仓库的大致结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.gitea/workflows/blog.yml
source/_posts/
source/images/
tools/
restore-mtime.cjs
check-site.cjs
check-tags.cjs
install-deploy-tools.sh
deploy.sh
release.sh
test-release.sh
test-deploy.sh
package.json
package-lock.json
_config.yml
_config.next.yml

package-lock.json 也需要提交。CI 使用 npm ci,按锁文件安装依赖;升级依赖时,在本地更新并验证锁文件,再一起提交。

package.json 中需要有以下脚本。它们是这个博客仓库定义的命令,检查脚本需要一起放到 tools/,并不是 Hexo 自带功能:

1
2
3
4
5
6
7
8
9
{
"scripts": {
"build": "hexo generate",
"clean": "hexo clean",
"restore:mtime": "node tools/restore-mtime.cjs",
"check:site": "node tools/check-site.cjs",
"check:tags": "node tools/check-tags.cjs"
}
}

check-tags.cjs 使用 js-yaml 读取文章头部,所以还需要安装并提交这个开发依赖。配套示例使用 5.5.0。不要在 CI 安装依赖时省略开发依赖,否则标签检查会缺少模块。

保留正确的文章更新时间

旧的 Drone 文章里提到过这个问题:每次构建都重新检出源码,文件的修改时间会变成检出时间。如果直接用这个时间生成页面,所有文章都会看起来像刚刚更新过。

这次仍然从 Git 历史恢复时间。文章的 date 字段保存发布时间;没有显式 updated 字段时,Hexo 根据文件修改时间生成更新时间。对应配置为:

1
2
timezone: Asia/Shanghai
updated_option: mtime

在 tools/restore-mtime.cjs 中放入以下脚本,在构建之前执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
// A full checkout is required so unchanged articles keep their real update dates.
const { execFileSync } = require('node:child_process');
const { utimesSync } = require('node:fs');
if (execFileSync('git', ['rev-parse', '--is-shallow-repository'], { encoding: 'utf8' }).trim() !== 'false') {
throw new Error('Restore timestamps requires full history (fetch-depth: 0).');
}
const files = execFileSync('git', ['ls-files', '-z', '--', 'source'], { encoding: 'utf8' }).split('\0').filter(Boolean);
for (const file of files) {
const stamp = execFileSync('git', ['log', '-1', '--format=%ct', '--', file], { encoding: 'utf8' }).trim();
if (!/^\d+$/.test(stamp)) throw new Error(`No commit timestamp: ${file}`);
utimesSync(file, Number(stamp), Number(stamp));
}
console.log(`Restored timestamps for ${files.length} source files.`);

脚本读取每个源文件最后一次提交的时间,再恢复文件时间戳。这样只会改变真正被修改过的文章的更新时间。需要完整 Git 历史,浅克隆不能提供所有文件的最后一次提交记录。

NexT 默认只在跨天修改时单独显示“更新于”。如果文章当天发布、当天修改,页面可能只显示发布日期,鼠标停在日期上可以看到修改时间。这是主题的显示条件,不能据此判断时间恢复失败。

配置发布账户和目录

创建专用账户

构建任务通过 SSH 发布,但不使用服务器的日常管理账户。这里用一个权限限于博客目录的专用账户 example-blog-deploy 演示。

在服务器上执行一次以下准备。示例面向一个尚未创建该用户的新环境;已有账户时先检查其家目录和权限:

1
2
3
4
5
6
sudo useradd --system --create-home \
--home-dir /var/lib/example-blog-deploy --shell /bin/bash example-blog-deploy
sudo install -d -m 0755 -o example-blog-deploy -g example-blog-deploy \
/srv/example-blog /srv/example-blog/releases
sudo install -d -m 0700 -o example-blog-deploy -g example-blog-deploy \
/var/lib/example-blog-deploy/.ssh

为这个账户单独生成 Ed25519 部署密钥,把公钥放入 /var/lib/example-blog-deploy/.ssh/authorized_keys,并设置所有者为 example-blog-deploy、权限为 0600。公钥行加上 restrict,关闭不需要的转发和终端等功能;此处仍然需要允许 SSH 执行发布命令。

1
restrict ssh-ed25519 REPLACE_WITH_DEPLOY_PUBLIC_KEY blog-actions-deploy

私钥保存在 Gitea 的仓库 Secret 中,不能放进仓库。还需要通过可信渠道核对服务器 SSH 主机指纹,将对应的 known_hosts 内容保存为另一个 Secret。单独运行一次 ssh-keyscan 只能取得候选公钥,并不能完成指纹核验。

在本例中,部署账户通过宿主机的管理 SSH 入口连接,Git SSH 则由 Gitea 单独提供。

发布目录

最终目录结构如下:

1
2
3
4
5
/srv/example-blog/
.incoming.XXXXXXXXXX/
releases/
<commit>-<run>-<attempt>/
current -> releases/<commit>-<run>-<attempt>

文件先上传到随机临时目录,检查通过后移动到 releases,最后切换 current。Nginx 始终读取 current,上传过程中继续提供旧版本。

这里要求临时目录、版本目录和链接都位于同一个文件系统,以便通过重命名原子替换链接。上传或校验失败不会替换当前版本;历史版本保留下来供回滚使用。

配置 Secrets 和 Variables

进入博客仓库的 Actions 设置,分别添加 Secret 和 Variable:

类型 名称 内容
Secret DEPLOY_HOST 网站服务器地址,例如 web.example.com
Secret DEPLOY_PORT 服务器管理 SSH 端口,本例为 22022
Secret DEPLOY_USER example-blog-deploy
Secret DEPLOY_SSH_KEY 专用部署私钥的完整内容
Secret DEPLOY_KNOWN_HOSTS 核对过指纹的 known_hosts 内容
Variable DEPLOY_ROOT /srv/example-blog
Variable DEPLOY_ENABLED 初期设为 false,构建验证后改为 true
Variable BLOG_APT_MIRROR 可选,任务容器可访问的 HTTPS Debian 镜像站地址

非默认端口的 known_hosts 条目使用 [web.example.com]:22022 这样的形式,并且必须与工作流连接的主机名一致。

GITEA_TOKEN 不在上表中手动创建。工作流使用的是 Gitea 为任务提供的内置令牌,用来读取当前仓库。相关说明见官方任务令牌文档和Secrets 文档。

创建 Actions 工作流

在 .gitea/workflows/blog.yml 中放入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
name: Build and deploy blog
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
steps:
- name: Checkout full history
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CHECKOUT_SERVER: ${{ gitea.server_url }}
CHECKOUT_REPOSITORY: ${{ gitea.repository }}
CHECKOUT_SHA: ${{ gitea.sha }}
run: |
set -eu
test -n "$GITEA_TOKEN"
git init .
git remote add origin "${CHECKOUT_SERVER%/}/${CHECKOUT_REPOSITORY}.git"
# Auth exists only in this process environment, never in .git/config.
auth=$(printf 'x-access-token:%s' "$GITEA_TOKEN" | base64 | tr -d '\n')
export GIT_TERMINAL_PROMPT=0
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0=http.extraheader
export GIT_CONFIG_VALUE_0="Authorization: Basic $auth"
git fetch --no-tags origin "$CHECKOUT_SHA"
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 auth GITEA_TOKEN
git checkout --detach "$CHECKOUT_SHA"
- name: Install locked dependencies
run: npm ci --no-audit --no-fund
- name: Verify release scripts
run: |
bash tools/test-release.sh
bash tools/test-deploy.sh
- name: Build and validate
run: |
npm run restore:mtime
npm run clean
npm run build
npm run check:site
npm run check:tags
# Explicit opt-in; merely pushing this workflow cannot publish the site.
- name: Deploy master
if: ${{ (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch') && gitea.ref == 'refs/heads/master' && vars.DEPLOY_ENABLED == 'true' }}
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
DEPLOY_ROOT: ${{ vars.DEPLOY_ROOT }}
RELEASE_ID: ${{ gitea.sha }}-${{ gitea.run_id }}-${{ gitea.run_attempt }}
BLOG_APT_MIRROR: ${{ vars.BLOG_APT_MIRROR }}
run: |
bash tools/install-deploy-tools.sh
bash tools/deploy.sh

这个仓库的主分支叫 master。如果使用 main,需要修改发布步骤中的分支判断。所有分支的推送和 PR 都会构建,但只有主分支推送或手动执行主分支工作流,且 DEPLOY_ENABLED 为字符串 true 时,才会进入发布步骤。

为什么直接用 Git 检出

最开始我也使用了 actions/checkout。第一次构建通过之后,在后续普通推送时,下载 GitHub 上的 Action 遇到了 unexpected EOF。任务卡在检出阶段,甚至没有及时响应取消。

所以最后改成直接从自己的 Gitea 检出当前提交。认证信息只放在 Git 子进程的环境变量中,拉取完成后清除,不写入远端 URL 或 Git 配置文件。git fetch 没有设置浅克隆深度,后面的文件时间恢复脚本也会检查仓库是否完整。

这样减少了每次构建对 GitHub Action 下载的依赖,但任务仍然需要能访问自己的 Gitea、npm,以及首次运行或更新时所需的构建镜像。本例没有使用 Git 子模块和 LFS;如果仓库有这些内容,还需要相应补充检出步骤。

构建后检查什么

check-site.cjs 检查首页、404 页面、sitemap、文章目录和 HTML 文件,防止缺文件或异常产物进入发布。

check-tags.cjs 检查标签大小写以及对应文章、归档和 sitemap。我之前的 Python、python 在 Windows 上会指向同一目录,到了 Linux 又会变成两个目录,所以统一了名称并加上检查。配套脚本保留通用的标签大小写检查。文章、归档和 sitemap 的兼容性断言可以按自己的站点补充。

两个 Bash 测试脚本在本地临时目录里验证发布、回滚、失败上传和路径检查,SSH/rsync 使用本地替身,不连接正式服务器。它们在真正发布之前执行。

上传和切换版本

tools/deploy.sh 负责准备临时 SSH 配置、上传产物,再调用服务器上的版本切换逻辑。工作流中的环境变量由这个脚本读取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/usr/bin/env bash
# Client-side uploader; only called by the opt-in Actions deployment step.
set -euo pipefail
for name in DEPLOY_HOST DEPLOY_USER DEPLOY_SSH_KEY DEPLOY_KNOWN_HOSTS DEPLOY_ROOT RELEASE_ID; do
[[ -n ${!name:-} ]] || { echo "Missing $name" >&2; exit 1; }
done
port=${DEPLOY_PORT:-22}
[[ $DEPLOY_HOST =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ && $DEPLOY_USER =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]] || { echo 'Invalid SSH destination' >&2; exit 1; }
[[ $port =~ ^[0-9]{1,5}$ ]] && ((10#$port > 0 && 10#$port <= 65535)) || { echo 'Invalid SSH port' >&2; exit 1; }
[[ $DEPLOY_ROOT =~ ^/[a-zA-Z0-9_./-]+$ && $DEPLOY_ROOT != / && $DEPLOY_ROOT != */ && $DEPLOY_ROOT != *..* ]] || { echo 'Unsafe deployment root' >&2; exit 1; }
[[ $RELEASE_ID =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$ ]] || { echo 'Unsafe release ID' >&2; exit 1; }
cd -- "$(dirname "$0")/.."
node tools/check-site.cjs
umask 077
tmp=$(mktemp -d)
trap 'rm -rf -- "$tmp"' EXIT
printf '%s\n' "$DEPLOY_SSH_KEY" > "$tmp/key"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$tmp/known_hosts"
cat > "$tmp/ssh" <<'SSH'
#!/usr/bin/env bash
exec ssh -i "$DEPLOY_SSH_DIR/key" -p "$DEPLOY_SSH_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$DEPLOY_SSH_DIR/known_hosts" -o ConnectTimeout=15 "$@"
SSH
chmod 700 "$tmp/ssh"
export DEPLOY_SSH_DIR=$tmp DEPLOY_SSH_PORT=$port
remote=$DEPLOY_USER@$DEPLOY_HOST
# ROOT must be provisioned by an administrator before the first deployment.
staging=$("$tmp/ssh" "$remote" "mktemp -d '$DEPLOY_ROOT/.incoming.XXXXXXXXXX'")
[[ $staging == "$DEPLOY_ROOT"/.incoming.* && ${staging#"$DEPLOY_ROOT"/} != */* && $staging != *..* ]] || { echo 'Unexpected remote staging path' >&2; exit 1; }
# Failed uploads are kept for inspection; they never replace current.
rsync -rpt --chmod=D755,F644 -e "$tmp/ssh" -- public/ "$remote:$staging/"
"$tmp/ssh" "$remote" "bash -s -- '$DEPLOY_ROOT' publish '$RELEASE_ID' '$staging'" < tools/release.sh

这里使用 rsync 的 public/ 形式上传目录内容,隐藏文件也会一起处理。SSH 严格检查主机指纹,不使用关闭验证的方式绕过连接问题。

如果构建镜像里没有 rsync 或 ssh,工作流会先运行 tools/install-deploy-tools.sh,只在临时 Debian 任务容器里安装这两个工具。服务器上的 Docker 全局镜像源和其他服务不会因此改变。可选的 apt 镜像配置也只作用于这个任务容器,并保留 Debian 的签名校验。

服务器上的发布逻辑由 tools/release.sh 执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#!/usr/bin/env bash
# Run on the Linux web host. Usage: release.sh ROOT publish ID STAGING | ROOT rollback ID
set -euo pipefail
fail() { echo "release: $*" >&2; exit 1; }
[[ $# -ge 3 ]] || fail 'expected ROOT publish|rollback ID [STAGING]'
root=$1 mode=$2 id=$3
[[ $root =~ ^/[a-zA-Z0-9_./-]+$ && $root != / && $root != */ && $root != *..* ]] || fail 'unsafe root'
[[ $id =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$ ]] || fail 'unsafe release ID'
[[ $mode == publish || $mode == rollback ]] || fail 'unknown mode'
[[ -d $root && ! -L $root && $(realpath "$root") == "$root" ]] || fail 'root must exist and be canonical'
[[ ! -L $root/releases ]] || fail 'releases cannot be a symlink'
mkdir -p -- "$root/releases"
mkdir -- "$root/.deploy-lock" 2>/dev/null || fail 'another deployment holds .deploy-lock'
link=''
cleanup() {
[[ -z $link ]] || rm -f -- "$link"
rmdir -- "$root/.deploy-lock"
}
trap cleanup EXIT
[[ ! -e $root/current || -L $root/current ]] || fail 'current must be a symlink'
if [[ -L $root/current ]]; then
old=$(readlink "$root/current")
[[ $old == releases/* && $old != *..* && -d $root/$old && ! -L $root/$old ]] || fail 'invalid current target'
fi
validate() {
[[ -d $1 && ! -L $1 ]] || fail 'missing release directory'
[[ -z $(find "$1" -type l -print -quit) ]] || fail 'symlinks are not allowed in a release'
for page in index.html 404/index.html sitemap.xml; do
[[ -s $1/$page && -f $1/$page ]] || fail "missing/empty $page"
done
}
target=$root/releases/$id
if [[ $mode == publish ]]; then
[[ $# == 4 ]] || fail 'publish requires STAGING'
staging=$4
[[ $staging == "$root"/.incoming.* && ${staging#"$root"/} != */* && $staging != *..* ]] || fail 'unsafe staging path'
[[ ! -e $target && ! -L $target ]] || fail 'release already exists'
validate "$staging"
mv -- "$staging" "$target"
else
[[ $# == 3 ]] || fail 'rollback expects ID only'
validate "$target"
fi
# rename(2) on the same filesystem replaces the link without an empty serving window.
link=$root/.current-$id-$$
ln -s -- "releases/$id" "$link"
mv -Tf -- "$link" "$root/current"
link=''
echo "Active release: $id"

这个脚本同时用于发布和回滚。切换前检查产物,遇到重复版本、非法路径或已有发布锁会失败。current 通过同一文件系统内的 mv -T 替换,避免先删掉旧链接再创建新链接造成访问空档。

历史版本和失败的暂存目录不会自动清理,需要定期检查磁盘空间。若进程被强制终止留下 .deploy-lock,先确认没有发布或回滚进程,再处理残留锁。

配置博客的 Nginx 和 HTTPS

先保持 DEPLOY_ENABLED=false,推送一次确认 Gitea 正式构建通过。准备好发布账户、目录和 Secrets 后,将它改为 true,手动执行一次主分支工作流。

确认首个版本已生成,并且 current 指向有效目录后,再将网站接入这个目录。旧网站还在运行时,原来的目录可以先保留,等新版本就绪再切换 Nginx。

HTTP 配置示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
server {
listen 80;
server_name blog.example.com;
root /srv/example-blog/current;
index index.html;
charset utf-8;

location / {
try_files $uri $uri/ =404;
}
error_page 404 /404/index.html;
}

检查配置通过后 reload,再使用 Certbot 配置 HTTPS:

1
2
3
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d blog.example.com

这里的 try_files 不把所有路径都交给首页处理,访问不存在的文章应返回真正的 HTTP 404。本站还有旧 Python 标签链接的 301 规则,放在单独的 Nginx snippet 中;只改静态文件不能完成这些重定向。

第一次切换还需要停用这个博客仓库对应的 Drone 发布任务,检查是否有遗留 webhook,避免两套流水线同时操作网站目录。确认新流程稳定后,再移除旧 .drone.yml。

验证发布和回滚

Actions 显示成功之后,还需要访问实际网站,检查首页、文章、图片、sitemap、HTTPS 和 404。第一次切换还要核对旧文章地址、标签重定向和文章时间是否保持正确。

例如可以检查:

1
2
3
4
curl -I https://blog.example.com/
curl -I https://blog.example.com/posts/your-article-id/
curl -I https://blog.example.com/sitemap.xml
curl -I https://blog.example.com/this-page-does-not-exist

最后一个地址应返回 404。文章和图片使用实际已经发布的路径,避免在文件上传之前请求新资源,让 CDN 缓存尚未发布时的错误响应。

配套脚本的回滚命令如下,其中版本 ID 必须是 releases 下已经存在的成功版本:

1
2
ssh -p 22022 example-blog-deploy@web.example.com \
'bash -s -- /srv/example-blog rollback EXISTING_RELEASE_ID' < tools/release.sh

回滚只切换 current,不重新构建,也不需要 reload Nginx。我这次在正式使用之前实际验证了上传、切回旧版本、再切回新版本,之后才开启日常自动发布。

后续使用

以后写文章时,先在本地预览和检查,再提交并推送:

1
2
3
4
5
6
npm run build
npm run check:site
npm run check:tags
git add source/
git commit -m "Add a new post"
git push origin master

commit 只会保存到本地,push 才会触发远端任务。需要重新部署时,可以在 Gitea Actions 手动执行主分支工作流,不用为了触发发布制造空提交。临时停止自动发布则将 DEPLOY_ENABLED 改为 false。

这个仓库通过 Actions 发布静态产物,不使用 hexo deploy。这里的限制来自当前仓库的部署方式,并不是说所有 Hexo 项目都不能使用 deploy 命令。

原来文章使用的 posts/:abbrlink/ 地址规则保持不变。构建和部署过程换了,之前的文章链接仍然可以继续使用。