介绍
之前分别写过在 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: 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
| 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 - 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
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'
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
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; }
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
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
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/ 地址规则保持不变。构建和部署过程换了,之前的文章链接仍然可以继续使用。