一次发布,只有在所需公共边缘检查与选定产物一致时才算完成,而不是上传退出码为零就完成。本教程串起构建、校验、发布与验证,同时区分本地检查和部署变更。发布会改变服务器;结尾的探针才是只读网络观察。

命令假定使用 Bash、GNU find/sort/sha256sum、rsync、Python 3 与项目固定版本的 Hugo。构建步骤从仓库根运行。接触线上主机前,先在一次性目录演练目录切换示例。明确实际服务根、部署负责人、上一版目标与回滚过程。下面的 /srv 布局要求部署已配置完成,不是仓库会自动安装的东西。

构建并验证一份准确产物,最后一次构建后生成清单,核验全新的空暂存目录。选择前失败停止,旧版不变;选择时替换发布指针。之后对公共响应与产物文件进行采样核对,失败时新版仍被选中,直到显式回滚到所记录上一版并重新探测。原子命名空间替换不能保证跨请求一致或断电持久性。磁盘检查、交付字节、TLS 身份、到期、DNS 缓存变化与独立备份各有证据要求。
指针切换改变失败边界:之前停止可保留旧版,之后需要显式回滚并重验。磁盘检查与边缘样本证明不同事实。

1. 从已记录输入构建一份产物

从干净源码版本开始,运行项目构建并记录已安装生成器版本:

set -eu
git status --short
git rev-parse HEAD
hugo version
make build

短状态为空可支持干净工作树的判断,但命令本身不会强制执行。在称其为某个提交的发布前,应检查所有变更。make build 将短提交号和当前 UTC 构建时间写入 data/buildinfo.toml,再生成 public/。页面中的标记说明记录了哪些输入,不能证明有未提交修改的工作树只含那个提交。

固定 Hugo 与 vendored 主题可减少漂移,但构建时间字段使不同时间的普通构建不一定逐字节相同。应构建选定发布一次、验证后传输这份准确产物,而不是在服务器另行重建。

2. 检查完整离线契约

make check 检查当前产物的生成视觉资源、离线包新鲜度、内部链接、无脚本输出、来源链接及下载校验和。完整仓库关卡则使用:

set -eu
make verify
make check-upstream

完整关卡还运行回归测试、双语文章检查和审阅清单新鲜度校验,再重新构建并检查输出。因此要在最后一次构建后才生成发布清单。构建成功及审阅记录未过期,不能证明每篇文章技术正确,也不证明公共服务器使用了预期头部。页数与耗时会变化,应保留实际输出,不与文章里复制来的计数作比较。

3. 用清单标识产物字节

计算哈希前,确认 public/ 只包含目录与常规文件,并停止所有写入,直到暂存完成。下面的命令不会创建快照,也不会检查每种特殊文件类型。清单放在 public/ 外,避免把自身纳入哈希。NUL 分隔文件名可保留空格及其他 shell 敏感字符:

set -euo pipefail
manifest="$(pwd)/release.sha256"
(
  cd public
  find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum
) > "$manifest"
(cd public && sha256sum -c "$manifest")
sha256sum "$manifest"

GNU 校验行的摘要在文件名前。sha256sum -c 成功说明已列出的文件匹配,不证明没有额外文件。全新的空发布目录可避免目标端残留旧文件,但不能发现计算哈希后在源端新增的额外文件。要证明常规文件清单完整一致,应按相同路径、排序与哈希规则重算暂存清单,在选择指针前比较完整列表。另行检查符号链接:find -type f 不包含它们,而意外链接可能暴露预期根外的文件。将清单及其自身摘要留在发布记录中;校验和证明相对于清单的一致性,不能证明不可信清单的真实性。

4. 暂存并选择完整发布

假定 Caddy 已服务 /srv/www/example-site/current,它是指向 releases/ 内部的符号链接,所有权与读取权限正确。releases 父目录须事先存在。发布者必须串行执行,并选择唯一发布 ID。把此块作为独立 Bash 脚本,在部署机器上包含已验证 public/ 与 release.sha256 的目录中运行。运行前按部署策略检查产物的所有权与读取权限。此块自行设置清单路径和遇错停止策略;前置条件、复制或校验和失败时,必须在选择指针前停止。它拒绝暂存产物内的符号链接;这是会修改部署的步骤。

set -euo pipefail
base=/srv/www/example-site
manifest="$(pwd)/release.sha256"
test -f "$manifest"
test -d public
release_id=20261015T120000Z
release="$base/releases/$release_id"
test -L "$base/current"
previous=$(readlink "$base/current")
mkdir "$release"                         # Fail if this release already exists.
rsync -a public/ "$release/"
(cd "$release" && sha256sum -c "$manifest")
links=$(find "$release" -type l -print -quit)
if [ -n "$links" ]; then
    echo "Refusing symlinks in the staged artifact" >&2
    exit 1
fi
# Ownership and readable permissions must match the deployment policy.
python3 - "$base" "$release_id" <<'PYTHON'
from pathlib import Path
import os, sys, uuid
base = Path(sys.argv[1])
release = base / "releases" / sys.argv[2]
current = base / "current"
if not current.is_symlink() or not release.is_dir():
    raise SystemExit("Expected current symlink and a complete release directory")
pending = base / (".current-" + uuid.uuid4().hex)
pending.symlink_to(os.path.relpath(release, base), target_is_directory=True)
os.replace(pending, current)
print("selected:", os.readlink(current))
PYTHON
printf 'Previous target: %s\n' "$previous"

临时链接与 current 共用父目录,因此替换通过同文件系统的一次 rename 完成。普通 mv new existing-directory 不会交换两个非空目录。不要用此片段转换已有的真实目录服务根,应单独规划迁移。在预发布环境验证真实服务器的根解析及文件系统行为。

旧目标仍在磁盘上,不等于切换后旧资源 URL 仍可访问。应在新服务命名空间或稳定资源库中保留旧版指纹资源字节。浏览器多个请求可能跨越两个发布,缓存也可能持有旧 HTML。原子命名空间替换不是跨请求快照隔离,也不是断电持久性保证。

5. 验证实际交付字节与策略

切换后,用固定表示选择比较公共响应样本与相应产物文件:

set -euo pipefail
url=https://www.xbcatm.com/
status=$(curl -sS --max-time 20 -H 'Accept-Encoding: identity' \
  -D served.headers -o served.html -w '%{http_code}' "$url")
test "$status" = 200
cmp public/index.html served.html
sh public/downloads/check-http-headers.sh "$url"

从暂存时使用的同一产物目录运行此块。头部辅助脚本使用该可信产物内的副本;此步骤无需源码工作树。cmp 成功证明这个首页样本逐字节匹配。遇到意外重定向、返回 200 的代理错误页、CDN 转换或编码响应时,需要调查。检查 served.headers 中的 Content-Encoding、缓存年龄、CSP 与内容类型。头部脚本打印响应及最终状态,不会强制所有预期条件。

对中文内容、嵌套文章、CSS、SVG、下载项、重定向及错误路由重复适用检查。记录 URL 到文件的映射,包括目录索引。示例的逐项校验只证明所列文件的一致性,不证明暂存清单完整,也不证明所有公共缓存节点的交付结果。完整边缘论断需要更广泛抓取和表示感知检查;少数探针始终只是样本。

切换后的检查失败时,新版仍被选中,直到你采取行动。用新临时链接指向已记录的 previous 值,再次原子替换 current,随后重做探针。不要只移走新树,诊断期间也不要删除证据或上一版。

6. 分别检查 TLS 身份和到期窗口

从仓库根运行到期辅助脚本:

sh static/downloads/check-tls-expiry.sh www.xbcatm.com 30

它提取证书、打印日期、检查剩余到期间隔,却不是完整 TLS 身份测试:当前实现没有让主机名或信任验证失败成为致命错误。应按照公共边缘信任教程执行显式验证握手与 SAN 检查。不能从“至少还有 30 天有效期”推断信任链或吊销检查成功。

临近过期是另一项发布风险。保留实际 notAfter、信任结果与客户端环境,不要把历史签发者或日期复制到发布日志。

7. DNS 变化时纳入缓存窗口

纯内容发布不一定改变 DNS。若主机或边缘变化,应在缓存过期期间继续维护旧目标服务。变更时才降低 TTL,不会缩短已经按旧 TTL 缓存的条目。解析器 serve-stale、负缓存和客户端缓存会让窗口更复杂;TTL 不是保证所有客户端都完成迁移的统一截止点。CAA 变化影响签发策略,不负责把流量路由到新内容。

8. 保留演练过的恢复路径

上一版目录有利于快速回滚,但不是独立备份。磁盘故障、误清理或部署账户被攻破,都可能影响所有本地发布。应保留源码、构建输入、配置及适当的独立产物备份,并在隔离环境演练恢复。一次成功演练证明的是那个时刻的那条恢复路径,不会永久保证之后的变更。

9. 精确记录结果

保留源码版本与干净工作树状态、构建元数据和工具版本、清单摘要、选中与上一版目标、探针时间、HTTP/TLS 观察及任何回滚。说明覆盖的是样本、全部映射 URL,还是多个观测点。关卡失败时,应记录失败与恢复过程,而不是“它工作过”。

切换前失败可以让旧版保持不变;切换后恢复则是需要单独验证的显式操作。区分这两点,才能让发布路径可审计,而不假装每次失败都会自动恢复旧树。

来源