Agent 部署验证 Gate 设计:发布后的真实性检查

⚡ 30 秒要点

  • CI/CD 的「部署成功」只验证字节到达,不验证内容正确——这是两类完全不同的检查
  • VERIFIED Gate 通过 10+ 独立维度(HTTP 状态、canonical、hreflang、JSON-LD、安全头、sitemap、首页卡片、commit 一致性)自动化验证线上内容
  • 核心定义:VERIFIED Gate 是一个确定性、幂等的部署后检查,跨 N 个独立维度验证已部署内容,每个维度独立输出 PASS/FAIL,所有维度通过时 Gate 才通过
  • Gate 失败不是灾难——明确的恢复路径(修复 → 重新部署 → 重新验证)比「不知道有没有问题」安全得多

一、引言:为什么 CI/CD 的「部署成功」不够

CI/CD 流水线变绿了。GitHub Actions 显示 ✅ deploy succeeded。rsync 返回码为 0,nginx reload 成功,端口监听正常。从运维视角看,部署没有发生任何错误。

但搜索引擎不这么看。

两天后,你发现新发布的英文文章出现在中文搜索结果中,而中文版本完全未被索引。排查发现:canonical URL 错误地指向了英文版——中文页面的 <link rel="canonical"> 里写的是 /en/posts/...。同时,hreflang 标签只有单向引用:英文页面声明了中文替代版本,但中文页面没有反向声明英文版本。Google 因此无法识别双语结构,中文页面沦为孤立页面。

更糟的是:CI/CD 对此完全不知情。它的职责是验证「字节是否到达了服务器」——文件传输完成、服务重启成功、端口监听就绪。它不解析 HTML,不读 canonical,不管 hreflang 的双向性。这些语义层的错误对 CI/CD 完全透明。

这里藏着两个层级的验证鸿沟:

对于人类作者生产的内容,这个鸿沟通常不明显——因为人类在开发过程中会反复预览和检查,大部分语义错误在本地就被发现并修复了。但对于 Agent 生产的内容,情况完全不同。Agent 可以产出结构完整、语法正确的 HTML,但可能包含微妙的语义错误:canonical URL 指向了 staging 域名而非生产域名,hreflang 只生成了单语言版本,JSON-LD 中的 URL 用了 localhost 前缀。这些错误在本地预览时完全看不出来——页面渲染正常,HTML 结构合法——只有部署到线上、被搜索引擎解析之后才会暴露。

这就是问题的关键:Agent 的职责结束于 commit;但验证的职责开始于部署后。Agent 写完了代码,提交了 PR,合并了分支——它的工作完成了。但部署到线上的内容是否正确,需要在真实的线上环境中、从「内容消费者」的视角进行独立验证。

本文提出的 VERIFIED Gate 正是为此而设计:一个部署后的确定性检查门禁,跨 10+ 个独立维度验证已部署内容的完整性和正确性。它不是 CI/CD 的替代,而是 CI/CD 之后、面向内容语义的补充验证层。只有当所有维度都通过时,部署才被标记为 VERIFIED——真正「已发布且正确」。

这篇文章是 Agent 发布与运营系列的一部分。本系列始于 Agent Release Gate 设计(定义了完整的 8 阶段发布管道——VERIFIED Gate 是其中的 Phase 8,也是最后一个阶段)。VERIFIED Gate 的在线验证能力依赖于 Agent 可观测性 中建立的监控信号体系——部署后检查本质上是可观测性信号的一次集中消费。

以下是 VERIFIED Gate 覆盖的 10 个核心验证维度,每一个维度都独立判定 PASS/FAIL:

维度 检查项 失败模式
HTTP 状态 每个页面返回 200 OK,无重定向循环 404/500/301 循环——CI/CD 显示部署成功但文件路径错误
Content-Type text/html; charset=utf-8 纯文本/二进制输出——nginx mime.types 未加载或路径匹配错误
页面大小 大于 1KB 空页面/错误页面——rsync 中断或占位文件覆盖了正式内容
Canonical 自引用,URL 完整且协议为 https 跨语言/跨域名指向——Agent 生成了 staging URL 或 http 协议
Hreflang 双向互指 + x-default 指向 en 单向/缺失/指向错误——Agent 只生成了 zh 版本或 URL 路径不对
JSON-LD Article + BreadcrumbList + FAQPage 三种类型均存在且格式正确 缺失/格式错误——Agent 省略了某种结构化数据或 JSON 语法错误
FAQ 可见性 details/summary 元素存在且可交互 FAQ 内容存在但被 CSS 隐藏,或使用非标准标记导致不可见
安全头 HSTS / X-Content-Type-Options / X-Frame-Options / Referrer-Policy / Permissions-Policy 全部返回 缺失/nginx 继承陷阱——server 块的头被 location 块覆盖
Sitemap zh/en URL 均存在于 sitemap.xml 中 URL 缺失/格式错——部署后 sitemap 未更新或 rsync 覆盖了旧版
首页卡片 文章链接出现在 zh/index.html 的卡片列表中 链接缺失/断裂——index.html 部署了旧版本

二、验证维度矩阵:从 HTTP 状态到内容一致性

上表的 10 个维度构成了 VERIFIED Gate 的核心检查矩阵。每个维度独立判定 PASS/FAIL,有自己的检查方法、失败模式和修复路径。理解每个维度的诊断价值——它能捕获什么问题、什么信号会导致误判——是设计可靠 Gate 的前提。以下从 HTTP 基础设施到业务语义逐层展开。

2.1 维度一:HTTP 状态 (200)

检查内容:目标 URL 返回 HTTP 200 OK,且不经过任何 3xx 重定向。这是所有后续检查的前置条件——如果页面根本不可达,其他维度的验证没有意义。

为什么单单 200 不够:两个容易被忽视的陷阱:

curl 检查方法:

# 正确:不跟随重定向,直接检查初始响应
curl -fsS -o /dev/null -w '%{http_code}' https://xslyl.com/zh/posts/slug.html

# 期望输出:200(且没有 Location 头)
# 如果输出 301/302/303/307/308:URL 结构问题
# 如果输出 404:文件未部署到正确路径
# 如果输出 500:服务器端错误(nginx 配置、PHP 错误等)
# 如果输出 000:连接失败(DNS、TLS、端口未监听)

# 同时检查是否有 Location 头(重定向方向)
curl -fsSI https://xslyl.com/zh/posts/slug.html 2>&1 | grep -i '^location:'
# 如果有输出:存在重定向,需要排查 nginx rewrite 规则

失败模式:404(文件路径错误或 rsync 未完成)、500(nginx 配置语法错误或后端故障)、301 循环(URL rewrite 规则冲突)。恢复路径:确认文件存在于服务器目标路径 → 检查 nginx location 匹配 → 修正后重新部署。

2.2 维度二:Content-Type

检查内容:HTTP 响应头中 Content-Type 必须包含 text/html,且推荐包含 charset=utf-8

为什么重要:浏览器根据 Content-Type 决定如何渲染响应。如果服务器返回 text/plainapplication/octet-stream,浏览器会显示 HTML 源码而非渲染页面。搜索引擎同样依赖 Content-Type 判断内容类型——返回错误的 MIME 类型可能导致页面被当作纯文本处理,丢失所有 SEO 信号。

常见失败场景:

curl 检查方法:

# 检查 Content-Type 头
curl -fsSI https://xslyl.com/zh/posts/slug.html 2>&1 | grep -i '^content-type:'

# 期望输出:content-type: text/html; charset=utf-8
# 如果只有 text/html 没有 charset:警告级别
# 如果是 text/plain:FAIL——浏览器不会渲染为 HTML
# 如果是 application/octet-stream:FAIL——浏览器可能触发下载

2.3 维度三:页面大小

检查内容:响应体大小必须大于 1KB(1024 字节)。这是一个轻量级但高效的「空壳检测」机制。

为什么 >1KB:任何正常的 HTML 文章页面——仅 HTML 标签、meta 信息、面包屑导航、页脚——就会超过 1KB。一个完整的文章页面(含内联 CSS、结构化 JSON-LD、FAQ 标记、正文内容)通常在 15-80KB 之间。小于 1KB 的响应只有以下几种情况:

阈值调整注意事项:1KB 是一个保守阈值。如果你的站点使用外部 CSS(无内联样式)、最小化 HTML 结构,正常页面可能落在 2-5KB 范围。但如果页面使用了 SPA 架构(内容由 JavaScript 动态渲染),HTML 文件可能确实很小——此时需要配合其他检查(如 <title> 标签存在性、JSON-LD 存在性)进行交叉验证。对于 xslyl.com 的静态文章页面(含内联 CSS 和完整结构化数据),1KB 阈值是安全的。

curl 检查方法:

# 检查响应体大小(字节数)
curl -fsS -o /dev/null -w '%{size_download}' https://xslyl.com/zh/posts/slug.html

# 期望输出:> 1024(通常 15000-80000)
# 如果输出 < 1024:空壳页面的强烈信号
# 如果输出 0-100:文件不存在或传输失败

# 更精确:获取大小并判断
size=$(curl -fsS -o /dev/null -w '%{size_download}' https://xslyl.com/zh/posts/slug.html)
if [ "$size" -lt 1024 ]; then
  echo "FAIL: page size ${size} bytes < 1KB threshold"
fi

2.4 维度四:Canonical 自引用

检查内容:页面中 <link rel="canonical"> 的 href 值必须严格等于当前页面的完整 URL(协议 + 域名 + 路径)。即 canonical URL 是自引用的——页面声称自己就是权威版本。

为什么自引用而非简单「存在」:Agent 生成的 HTML 中最常见的 SEO 缺陷不是缺少 canonical 标签,而是 canonical 指向了错误的目标:

curl 检查方法:

# 提取 canonical href
curl -fsS https://xslyl.com/zh/posts/slug.html | \
  grep -oP '<link\s+rel="canonical"\s+href="[^"]*"' | \
  grep -oP 'href="\K[^"]*'

# 期望输出:https://xslyl.com/zh/posts/slug.html
# 即精确等于被请求的 URL

# 脚本化比对
EXPECTED="https://xslyl.com/zh/posts/slug.html"
ACTUAL=$(curl -fsS "$EXPECTED" | grep -oP '<link\s+rel="canonical"\s+href="[^"]*"' | grep -oP 'href="\K[^"]*')
if [ "$ACTUAL" != "$EXPECTED" ]; then
  echo "FAIL: canonical mismatch — expected $EXPECTED, got $ACTUAL"
fi

2.5 维度五:Hreflang 双向 + x-default

检查内容:双语页面的 hreflang 标签必须满足三个条件:(1) zh 页面包含 hreflang="en" 指向 en 版本、(2) en 页面包含 hreflang="zh" 指向 zh 版本、(3) 两个页面都包含 hreflang="x-default" 指向 en 版本。这被称为「双向互指 + x-default」模式。

为什么必须双向:Google 官方文档明确要求 hreflang 标签必须双向确认——如果页面 A 声明页面 B 是其替代版本,页面 B 也必须声明页面 A 是其替代版本。单向 hreflang 被 Google 忽略。这是 Agent 生成内容中最常见的国际化错误——Agent 在生成 zh 页面时添加了 en 的 hreflang,但在生成 en 页面时遗漏了 zh 的 hreflang。

典型失败模式:

curl 检查方法(需要两个请求 + 交叉比对):

# Step 1: 提取 zh 页面的 hreflang 标签
ZH_HREFS=$(curl -fsS https://xslyl.com/zh/posts/slug.html | \
  grep -oP '<link\s+rel="alternate"\s+hreflang="[^"]*"\s+href="[^"]*"')
# 期望包含:
#   hreflang="zh"  href="https://xslyl.com/zh/posts/slug.html"
#   hreflang="en"  href="https://xslyl.com/en/posts/slug.html"
#   hreflang="x-default" href="https://xslyl.com/en/posts/slug.html"

# Step 2: 提取 en 页面的 hreflang 标签
EN_HREFS=$(curl -fsS https://xslyl.com/en/posts/slug.html | \
  grep -oP '<link\s+rel="alternate"\s+hreflang="[^"]*"\s+href="[^"]*"')
# 期望包含:
#   hreflang="en"  href="https://xslyl.com/en/posts/slug.html"
#   hreflang="zh"  href="https://xslyl.com/zh/posts/slug.html"
#   hreflang="x-default" href="https://xslyl.com/en/posts/slug.html"

# Step 3: 交叉验证
# - zh 页面声明了 en 替代版本 ✓
# - en 页面声明了 zh 替代版本 ✓  (双向确认)
# - 两个页面都有 x-default 指向 en ✓

2.6 维度六:JSON-LD 结构化数据

检查内容:页面必须包含三个独立的 JSON-LD 块——ArticleBreadcrumbListFAQPage——每个都可以被 JSON 解析器正确解析,且包含 Google 要求的必填字段。

三个 JSON-LD 块各自的作用:

常见失败模式:

curl 检查方法:

# 提取所有 JSON-LD 块
curl -fsS https://xslyl.com/zh/posts/slug.html | \
  grep -oP '<script\s+type="application/ld\+json">\K.*?(?=</script>)'

# 管道到 Python 解析验证
curl -fsS https://xslyl.com/zh/posts/slug.html > /tmp/page.html

python3 - <<'PY'
import json, re
with open('/tmp/page.html') as f:
    html = f.read()

# Extract all JSON-LD blocks
blocks = re.findall(
    r'<script\s+type="application/ld\+json">(.*?)</script>',
    html, re.DOTALL
)

types = []
for block in blocks:
    try:
        data = json.loads(block)
        types.append(data.get('@type'))
    except json.JSONDecodeError as e:
        print(f"FAIL: JSON-LD parse error: {e}")

required = {'Article', 'BreadcrumbList', 'FAQPage'}
found = set(types)
missing = required - found
if missing:
    print(f"FAIL: missing JSON-LD block(s): {missing}")
else:
    print("PASS: all 3 JSON-LD blocks present and valid")
PY

2.7 维度七:FAQ 可见性

检查内容:页面中必须存在 <details><summary> HTML 元素,且它们包含与 JSON-LD FAQPage 一致的问答内容。FAQ 内容必须在 HTML 中可见渲染(未被 display:none 隐藏),因为 Google 会惩罚「结构化数据与用户可见内容不一致」的页面。

为什么 JSON-LD 不够——Google 的「内容一致」要求:Google 的结构化数据指南明确要求:JSON-LD 中声明的 FAQ 内容必须在页面上对用户可见。如果 JSON-LD 中有 5 个 FAQ 问答,但 HTML 中只有 3 个(或完全隐藏),Google 可能发出「结构化数据与内容不匹配」的手动操作警告。对于 Agent 生成的页面,典型风险是:Agent 正确生成了 JSON-LD FAQPage 块,但在 HTML body 中使用了 <div class="faq-hidden"> 或其他非标准标记来包裹 FAQ 内容,导致内容虽然存在于 DOM 中但对用户不可见。

正确的 FAQ HTML 标记:

<!-- 正确的 FAQ 标记(使用 details/summary)-->
<details>
  <summary>CI/CD 显示部署成功,为什么还需要 VERIFIED Gate?</summary>
  <p>CI/CD 的「部署成功」只验证了字节是否到达服务器...</p>
</details>

常见失败模式:

curl 检查方法:

# 检查 details/summary 元素是否存在
curl -fsS https://xslyl.com/zh/posts/slug.html | grep -c '<details>'
# 期望:>= 5(与 FAQPage 中的问题数量一致)

curl -fsS https://xslyl.com/zh/posts/slug.html | grep -c '<summary>'
# 期望:>= 5

# 检查是否有 CSS 隐藏 FAQ
curl -fsS https://xslyl.com/zh/posts/slug.html | grep -i 'display:\s*none'
# 如果匹配行包含 details 或 FAQ 相关 class:警告

2.8 维度八:安全头

检查内容:HTTP 响应必须包含以下 5 个安全相关响应头,且每个头的值必须符合安全最佳实践:

安全头 期望值 作用
Strict-Transport-Security max-age=31536000; includeSubDomains 强制浏览器在指定时间内只通过 HTTPS 访问
X-Content-Type-Options nosniff 禁止浏览器 MIME 类型嗅探,防止将 HTML 当 JS 执行
X-Frame-Options SAMEORIGIN 防止页面被嵌入 iframe(clickjacking 防御)
Referrer-Policy strict-origin-when-cross-origin 控制 Referer 头的发送策略,跨域时只发送域名
Permissions-Policy 至少包含 camera=(), microphone=(), geolocation=() 禁用浏览器 API(摄像头、麦克风、地理位置等)

nginx add_header 继承陷阱——安全头检查中最隐蔽的失败模式:

nginx 的 add_header 指令有一个反直觉的行为:当在 server 块中设置了安全头后,如果某个 location 块中也使用了 add_header(即使是添加完全不同的头),server 块中的所有 add_header 都会被该 location覆盖——而不是合并。这是 nginx 文档中明确说明但经常被忽略的行为。

# nginx 配置示例——这个配置有继承陷阱
server {
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=()" always;

    location /zh/posts/ {
        # 这行 add_header 会导致上面 server 块中
        # 所有 5 个安全头在 /zh/posts/ 路径下全部失效!
        add_header Cache-Control "public, max-age=3600";
    }
}

在这个例子中,访问 /zh/posts/any-article.html 时,浏览器只会收到 Cache-Control 头——所有 5 个安全头全部丢失。这个陷阱的隐蔽之处在于:(1) 全局页面(首页、关于页)的安全头正常工作;(2) 运维人员测试首页时看到所有安全头都正确;(3) 问题只影响特定 location 下的页面,可能几周后才被发现。

解决方案:在每个需要自定义头的 location 块中冗余声明所有安全头(而非依赖 server 块的继承),或使用 ngx_headers_more 模块的 more_set_headers 指令(该指令会合并而非覆盖)。VERIFIED Gate 的安全头检查对每个 URL 独立验证,能暴露这种仅影响特定路径的继承陷阱。

curl 检查方法:

# 检查所有 5 个安全头是否返回
curl -fsSI https://xslyl.com/zh/posts/slug.html 2>&1 | grep -iE \
  '^(strict-transport-security|x-content-type-options|x-frame-options|referrer-policy|permissions-policy):'

# 期望输出(5 行):
# strict-transport-security: max-age=31536000; includeSubDomains
# x-content-type-options: nosniff
# x-frame-options: SAMEORIGIN
# referrer-policy: strict-origin-when-cross-origin
# permissions-policy: camera=(), microphone=(), geolocation=()

# 脚本化检查
REQUIRED_HEADERS=(
  "strict-transport-security"
  "x-content-type-options"
  "x-frame-options"
  "referrer-policy"
  "permissions-policy"
)
HEADERS=$(curl -fsSI https://xslyl.com/zh/posts/slug.html 2>&1)
for h in "${REQUIRED_HEADERS[@]}"; do
  if ! echo "$HEADERS" | grep -qi "^$h:"; then
    echo "FAIL: missing security header: $h"
  fi
done

2.9 维度九:Sitemap URL 存在性

检查内容:新部署文章的 zh 和 en URL 都必须出现在 sitemap.xml 中。sitemap.xml 是搜索引擎爬虫发现新页面的主要入口——如果文章 URL 不在 sitemap 中,Google 可能需要数天甚至数周才能通过内部链接发现新页面。

为什么 sitemap 检查不是简单「文件存在」:

curl 检查方法:

# 检查 zh URL 在 sitemap 中
curl -fsS https://xslyl.com/sitemap.xml | \
  grep -c 'https://xslyl.com/zh/posts/slug.html'
# 期望输出:1(恰好一次)

# 检查 en URL 在 sitemap 中
curl -fsS https://xslyl.com/sitemap.xml | \
  grep -c 'https://xslyl.com/en/posts/slug.html'
# 期望输出:1

# 脚本化双 URL 检查
ZH_IN_SITEMAP=$(curl -fsS https://xslyl.com/sitemap.xml | grep -c 'zh/posts/slug.html')
EN_IN_SITEMAP=$(curl -fsS https://xslyl.com/sitemap.xml | grep -c 'en/posts/slug.html')
if [ "$ZH_IN_SITEMAP" -eq 0 ]; then echo "FAIL: zh URL missing from sitemap"; fi
if [ "$EN_IN_SITEMAP" -eq 0 ]; then echo "FAIL: en URL missing from sitemap"; fi

2.10 维度十:首页卡片

检查内容:新部署文章的链接必须出现在 zh/index.htmlen/index.html 的文章卡片列表中。首页是最重要的内部链接——它不仅帮助用户发现内容,更是搜索引擎爬虫评估页面重要性的关键信号。Google 的 PageRank 算法对「从首页可直达」的页面赋予更高的初始权重。

为什么首页卡片检查是必要的:

curl 检查方法:

# 检查文章 slug 出现在 zh 首页
curl -fsS https://xslyl.com/zh/ | grep -c 'slug'
# 期望输出:>= 1(文章链接存在于首页卡片中)

# 检查文章 slug 出现在 en 首页
curl -fsS https://xslyl.com/en/ | grep -c 'slug'
# 期望输出:>= 1

# 更精确:检查完整的 href 属性
curl -fsS https://xslyl.com/zh/ | grep -oP 'href="[^"]*slug[^"]*"'
# 期望输出:href="/zh/posts/slug.html" 或完整 URL

2.11 Tier Classification:四层分级体系

不是所有维度同等重要。根据对用户体验、搜索引擎可见性和安全性的影响程度,10 个维度分为四个优先级(Tier)。这个分级决定了 Gate 失败时的响应策略——哪些维度失败时必须阻断发布,哪些可以降级处理。

Tier 包含维度 Gate 行为 失败后果 典型恢复路径
Tier 1
阻断级
HTTP 200
Canonical 自引用
Hreflang 双向互指
🔴 FAIL
阻断发布
页面不可达(HTTP 失败)、搜索引擎无法正确索引(canonical 错误)、双语结构不被识别(hreflang 缺失)——直接损害发布目的,发布等于没发布 修复 HTML/nginx → 重新部署 → 重新验证
Tier 2
高优先级
JSON-LD 结构化数据
安全头(5 个)
🟠 FAIL
建议阻断
搜索富文本不展示(JSON-LD 问题 → 搜索结果中无 FAQ 展开、无面包屑),站点安全评级下降(安全头缺失 → SSL Labs 评级降低、浏览器控制台警告) 修复后重新部署;紧急时可降级为 VERIFIED_WITH_WARNING 先上线、后修复
Tier 3
中优先级
FAQ 可见性
Content-Type
页面大小
首页卡片
🟡 WARN
不阻断
用户可见体验下降(FAQ 不可展开、首页无入口),但不影响搜索引擎索引和页面基本可访问性。可在后续部署周期中修复 记录警告,纳入下一轮部署修复清单
Tier 4
运营级
Sitemap URL 存在性
robots.txt 一致性
⚪ NOTE
仅记录
搜索引擎爬取效率降低(新页面发现延迟),但已部署内容完全可访问。robots.txt 错误可能阻止搜索引擎爬取,但通常需要数天才会产生可察觉的影响 记录备注,定期批量修复 sitemap

Tier 机制的三个核心原则:

  1. Tier 1 失败 = 发布未完成。HTTP 200、canonical 和 hreflang 这三个维度定义了「页面是否可以被搜索引擎正确索引」。Tier 1 中任意一个 FAIL,Gate 必须输出 FAIL,状态不得进入 VERIFIED。没有例外——即使时间紧迫,Tier 1 失败意味着「发布了等于没发布」。
  2. Tier 2 失败可以降级,但降级必须有记录。JSON-LD 和安全头问题不影响页面的基本可访问性和可索引性。在紧急发布场景(如安全漏洞修复、关键信息更新),可以降级为 VERIFIED_WITH_WARNING 先上线。但降级必须在 verify-report.json 中记录:(a) 哪个维度失败、(b) 降级原因、(c) 计划修复时间、(d) 审批人。不允许无记录的静默降级。
  3. Tier 分类是默认值,可根据站点策略调整。对于安全敏感的站点(如金融、医疗),Tier 2 的安全头检查应该升级为 Tier 1 阻断。对于 SEO 驱动的站点,sitemap 检查也应该从 Tier 4 升级到 Tier 2。分类体系是一个配置项,而非硬编码的规则。xslyl.com 当前的 Tier 分类是基于「技术博客」的定位——内容可访问性优先于搜索优化。

Gate 输出格式:每个维度的检查结果和 Tier 分类都写入 verify-report.json,格式如下:

{
  "status": "VERIFIED",
  "overall": "PASS",
  "checks": {
    "http_status":        {"result": "PASS", "tier": 1, "detail": "200 OK, no redirect"},
    "canonical":          {"result": "PASS", "tier": 1, "detail": "self-referencing https://..."},
    "hreflang":           {"result": "PASS", "tier": 1, "detail": "bidirectional confirmed"},
    "jsonld":             {"result": "PASS", "tier": 2, "detail": "3 blocks: Article, BreadcrumbList, FAQPage"},
    "security_headers":   {"result": "PASS", "tier": 2, "detail": "5/5 headers present"},
    "faq_visibility":     {"result": "PASS", "tier": 3, "detail": "6 details/summary elements"},
    "content_type":       {"result": "PASS", "tier": 3, "detail": "text/html; charset=utf-8"},
    "page_size":          {"result": "PASS", "tier": 3, "detail": "28473 bytes"},
    "homepage_card_zh":   {"result": "PASS", "tier": 3, "detail": "found in zh/index.html"},
    "homepage_card_en":   {"result": "PASS", "tier": 3, "detail": "found in en/index.html"},
    "sitemap_zh":         {"result": "PASS", "tier": 4, "detail": "URL present in sitemap.xml"},
    "sitemap_en":         {"result": "PASS", "tier": 4, "detail": "URL present in sitemap.xml"},
    "robots_txt":         {"result": "PASS", "tier": 4, "detail": "no disallow on /zh/posts/"}
  },
  "tier1_failures": [],
  "tier2_failures": [],
  "tier3_warnings": [],
  "tier4_notes": []
}

这个结构确保了下游系统(如状态面板、告警系统、自动修复流水线)可以根据 tier 字段做出差异化响应——Tier 1 失败触发告警和自动回滚,Tier 4 失败仅生成工单。

三、HTTP 与内容层检查

HTTP 层检查是 VERIFIED Gate 的第一道防线。在解析 HTML、验证 SEO 元数据之前,必须先确认服务器返回了正确的 HTTP 响应。这个看似简单的步骤有几个容易被忽略的细节。

3.1 为什么不跟随重定向

大多数 HTTP 客户端(包括浏览器、curl 默认模式、Python requests 库的默认配置)会自动跟随 301/302 重定向。这在日常使用中是便利的,但在部署验证中是危险的——它掩盖了 URL 配置错误。

典型场景:部署完成后,https://xslyl.com/zh/posts/new-article.html 返回 301 重定向到 https://xslyl.com/zh/posts/new-article.html/(多了一个尾部斜杠),然后该 URL 又返回 301 重定向回来——重定向循环。跟随重定向的客户端会超时或报错,但不会告诉你原因是 URL 配置问题。

VERIFIED Gate 使用 follow_redirects=False,直接检查目标 URL 的初始响应。期望值:200 OK,无重定向。如果返回 301/302,说明以下可能问题:

3.2 为什么检查响应体前 500 字节

HTTP 200 OK 不等于「正确页面」。很多 Web 服务器的默认错误页面返回 200 状态码:

VERIFIED Gate 在收到 200 后,检查响应体前 500 字节中是否包含错误特征字符串("404"、"Not Found"、"nginx"、"Error")。这不是完美的启发式检查——可能会有误判(如果文章标题恰好包含 "404")——但配合其他维度(页面大小、slug 匹配)可以大幅降低误判率。

3.3 页面大小:防「空壳」部署

页面大小检查的目标是防止「空壳部署」——文件存在、HTTP 200、但内容不完整。常见原因:

阈值设置为 1KB 是一个保守但有效的判断。任何正常的中文技术文章,仅 HTML 标签和文本内容就会超过 1KB

3.4 实现:简化版 HTTP/内容验证

以下是一个简化的 Python 实现,展示了 HTTP 与内容层检查的核心逻辑:

# Simplified gate check for basic HTTP/content validation
import httpx

def verify_http_and_content(url: str, slug: str) -> dict:
    """
    Verify basic HTTP response and content sanity.
    Returns a dict of check_name to bool.
    All checks must pass for this dimension to be green.
    """
    checks = {}

    # 1. HTTP layer: do NOT follow redirects
    resp = httpx.get(url, follow_redirects=False, timeout=10)

    checks["status_200"] = resp.status_code == 200
    checks["no_redirect"] = resp.status_code not in (301, 302, 303, 307, 308)
    checks["content_type_html"] = "text/html" in resp.headers.get(
        "content-type", ""
    )

    body = resp.text

    # 2. Content sanity: page is not empty or trivially small
    checks["page_not_empty"] = len(body) > 1024
    checks["page_not_huge"] = len(body) < 500 * 1024  # 500KB sanity cap

    # 3. Error page detection: check first 500 chars for error signatures
    first_chunk = body[:500].lower()
    error_signatures = ["404", "not found", "welcome to nginx", "error"]
    checks["not_error_page"] = not any(
        sig in first_chunk for sig in error_signatures
    )

    # 4. Content identity: slug must appear in page title or early body
    checks["correct_article"] = slug in body[:2000]

    # 5. Robots check: verify robots meta does not block indexing
    checks["robots_allows"] = "noindex" not in body[:2000].lower()

    # 6. Basic HTML structure
    checks["has_html_tag"] = "<html" in body[:500].lower()
    checks["has_body_tag"] = "<body" in body[:3000].lower()

    return checks


# Usage example
if __name__ == "__main__":
    import sys

    url = sys.argv[1]
    slug = sys.argv[2]

    results = verify_http_and_content(url, slug)

    passed = all(results.values())
    failed = [k for k, v in results.items() if not v]

    print(f"URL: {url}")
    print(f"Overall: {'PASS' if passed else 'FAIL'}")
    if failed:
        print(f"Failed checks: {', '.join(failed)}")
    for check, value in results.items():
        status = "✓" if value else "✗"
        print(f"  {status} {check}")

几点设计说明:

3.5 robots.txt 的特殊性

robots.txt 的检查被放在内容层而非 HTTP 层,原因在于它的验证逻辑与页面验证不同:

一个常见的部署事故:robots.txt 中包含 Disallow: /zh/posts/——这是开发环境中用于防止搜索引擎索引测试页面的配置,但被错误地部署到了生产环境。VERIFIED Gate 的 robots.txt 检查应该验证:站点根路径下的文章目录没有被 Disallow,且 Sitemap 指令存在并指向 HTTPS URL。

四、HTTP 与内容层检查 — Python 实现详解

上一节从验证维度矩阵的角度讨论了 HTTP 检查的设计原则——不跟随重定向、检查页面大小、检测错误页签名。本节将这些设计原则转化为可运行的 Python 代码,并深入解释每条检查背后的设计哲学,以及为什么这些检查无法在 CI/CD 中完成。

4.1 完整实现:verify_http_content.py

# verify_http_content.py — Basic HTTP and content-level gate checks
import httpx

CHECKS = {}

def check_http_and_content(url: str, slug: str):
    resp = httpx.get(url, follow_redirects=False, timeout=10)

    CHECKS["status_200"] = resp.status_code == 200
    # Reason: follow_redirects=False catches redirect-to-error-page patterns
    # where a 301→302→200 chain silently "passes" but the final 200 is an error page

    ct = resp.headers.get("content-type", "")
    CHECKS["content_type_html"] = "text/html" in ct
    # Reason: Some CDNs/content servers serve .html files as
    # application/octet-stream or text/plain

    body = resp.text
    CHECKS["page_not_empty"] = len(body) > 1024
    # Reason: A 200 with < 1KB probably means an error page template

    CHECKS["not_error_page"] = all(
        marker not in body[:500]
        for marker in ["404", "Not Found", "Internal Server Error"]
    )
    # Reason: Servers can configure error pages that return 200

    CHECKS["slug_in_title"] = slug in body[body.find("<title>"):body.find("</title>")]
    # Reason: Verify the correct article is served at this URL

    return CHECKS

4.2 设计哲学:每条检查为什么这样设计

4.2.1 status_200 + follow_redirects=False

为什么必须禁用重定向跟随:CI/CD 的 smoke test 通常使用 curl 默认模式或 requests.get() 默认配置——两者都会自动跟随 3xx 重定向。这在日常使用中是便利的,但在部署验证中会产生错误遮蔽。以下是三个经典场景:

为什么 CI/CD 做不到:CI/CD 的 smoke test 目的是验证「服务是否存活」,跟随重定向是合理的行为——只要最终有一个 200 就说明服务在运行。但 VERIFIED Gate 的目的是验证「正确的页面是否在正确的 URL 上返回了正确的状态码」,所以必须检查初始响应的状态码。两个层级的目标截然不同。

4.2.2 content_type_html

为什么 text/htmlcontent-type 中而非严格等于:检查使用 "text/html" in ct 而非 ct == "text/html",是为了兼容包含 charset 声明的 Content-Type 头(如 text/html; charset=utf-8)。但仅限于 text/html 的包含性检查——如果 Content-Type 是 application/xhtml+xml,虽然技术上也是 HTML,但对 xslyl.com 的静态 HTML 文件来说,这通常意味着 mime.types 配置错误。

易被忽视的 CDN 陷阱:某些 CDN 提供商(如 Cloudflare)在开启「Auto Minify」功能后,会修改 Content-Type 头。具体来说,如果源站返回 text/html; charset=utf-8,Cloudflare 的压缩引擎可能剥离 charset 部分,返回 text/html。这通常不是问题(浏览器默认 UTF-8),但如果没有在 VERIFIED Gate 中显式验证 Content-Type,你可能永远不会发现 CDN 修改了响应头——直到某天 CDN 配置变更导致更严重的头篡改。

4.2.3 page_not_empty(>1KB 阈值)

1KB 阈值的选择逻辑:一个有完整 HTML 结构的最小合法页面(包含 DOCTYPE、html、head、title、body 标签,以及面包屑导航和页脚)大约需要 600-800 字节。如果加上任何正文内容(哪怕只有一段话)、meta 标签、以及 JSON-LD 结构化数据,轻松超过 1024 字节。一个小于 1KB 的「页面」只有三种可能:空文件、截断文件、或服务器错误页模板(如 nginx 默认欢迎页约 600 字节)。

误判边界:对于 SPA 应用,HTML 文件可能确实很小(仅包含 <div id="app"></div> 和 script 标签),大小可能落在 500-800 字节。但对于 xslyl.com 的静态文章页面(所有内容内联在 HTML 中),1KB 阈值没有误判风险。如果你的站点使用 SPA 架构,可以将此阈值下调至 256 字节,但必须同时启用其他交叉验证(如 slug_in_title、jsonld 存在性)来弥补小页面的检测盲区。

4.2.4 not_error_page(前 500 字节签名检查)

为什么只检查前 500 字节:错误页面的标识性特征("404"、"Not Found"、"Error"、"nginx")几乎总是出现在 HTML 的头部区域——<title> 标签或页面顶部的大标题。检查前 500 字节已经足够捕获绝大多数服务器默认错误页。检查全文(如 80KB)只会增加 CPU 消耗和误判率——一篇技术文章中很可能包含 "404 Not Found" 这类的技术讨论文字。

签名列表维护的实操建议:错误签名列表不应该硬编码在 Gate 脚本中。更好的做法是维护一个 config/error_signatures.json 文件,包含不同 Web 服务器的特征字符串:

{
  "nginx": ["welcome to nginx", "404 not found", "50x"],
  "apache": ["it works", "apache http server"],
  "generic": ["internal server error", "service unavailable"]
}

部署时根据实际使用的 Web 服务器加载对应的签名列表。这样当你更换 Web 服务器(如从 nginx 迁移到 Caddy)时,只需更新配置文件,无需修改 Gate 代码。

4.2.5 slug_in_title(身份验证)

为什么检查 slug 在 title 中而不检查完整标题:验证「正确文章被返回」的最简单方法是检查文章的唯一标识符(slug)是否出现在页面标题中。为什么不检查完整标题?因为标题可能在本地构建和线上部署之间有细微差异(如标点符号的全角/半角转换、HTML 实体的编码方式)。slug 是 URL 的一部分,是稳定且唯一的标识符——如果 slug 出现在 title 中,几乎可以确定是正确页面。

为什么只在 title 标签内搜索而非全文:全文搜索 slug 会产生大量误判——文章 body 中可能多次出现 slug 词汇(如文章中讨论自己的 URL)。但 title 标签只出现一次,且几乎总包含文章的主要标识。在 title 标签内搜索 slug 是最精确的身份验证方法,同时避免了对 80KB 全文的昂贵的正则搜索。

4.3 为什么 CI/CD 捕获不了这些

CI/CD 流水线运行在隔离的构建环境中,它的验证范围是文件本身——文件是否存在、语法是否正确、构建是否成功。它不触及线上环境,因此无法验证以下问题:

问题类型 具体场景 为什么 CI/CD 无法捕获
部署传输错误 rsync 中断、网络超时导致文件不完整、磁盘满导致文件截断 CI/CD 检查的是源文件(Git 仓库中的文件),不是部署后服务器上的文件。传输层的错误只发生在 CI/CD 和服务器之间——这是 CI/CD 的盲区
Web 服务器配置错误 nginx mime.types 缺失、rewrite 规则错误导致 301、location 块覆盖 Content-Type CI/CD 不管理运行时 Web 服务器配置。nginx 的配置文件可能由运维人员手动修改,与 CI/CD 流水线完全解耦
CDN/缓存层污染 CDN 缓存了旧版错误页、CDN 修改了 Content-Type 头、边缘节点返回过期缓存的 404 CI/CD 运行在内网环境,不经过 CDN。CDN 的行为对 CI/CD 完全透明
环境差异 开发/预发布环境配置泄漏到生产(staging URL、开发用 robots.txt 等) CI/CD 在统一的环境中构建/测试。生产环境的特定配置(域名、HTTPS 证书、robots.txt)可能与构建环境不同,CI/CD 无法模拟

核心洞见:CI/CD 的验证范围止于「构建产物是否正确」,VERIFIED Gate 的验证范围始于「部署结果是否正确」。两者之间横跨着网络传输、文件系统、Web 服务器配置、CDN 缓存——这些都在 CI/CD 的控制范围之外,但直接影响用户和搜索引擎看到的最终结果。

五、SEO 元数据验证:canonical、hreflang、JSON-LD

SEO 元数据是 VERIFIED Gate 中最复杂、也最容易出错的验证维度。HTML 中的 SEO 标签不是独立存在的——它们之间存在交叉引用关系(hreflang 的双向性)、自引用约束(canonical 必须指向自己)、以及跨块一致性要求(JSON-LD FAQPage 必须与 HTML 中的 FAQ 内容一致)。本节给出完整的 Python 验证实现,并深入解释每条规则背后的搜索引擎行为逻辑。

5.1 完整实现:verify_seo.py

# verify_seo.py — SEO metadata gate checks
import re

def check_canonical(html: str, expected_url: str) -> bool:
    """Verify canonical URL is self-referencing and matches expected URL."""
    match = re.search(r'<link\s+rel="canonical"\s+href="([^"]+)"', html)
    return match is not None and match.group(1) == expected_url

def check_hreflang_bidirectional(html: str, lang: str, other_lang: str,
                                 zh_url: str, en_url: str, default_url: str) -> dict:
    """Check bidirectional hreflang + x-default."""
    patterns = re.findall(
        r'<link\s+rel="alternate"\s+hreflang="([^"]+)"\s+href="([^"]+)"',
        html
    )
    result = {
        "zh_to_zh": False,
        "zh_to_en": False,
        "en_to_en": False,
        "en_to_zh": False,
        "x_default": False,
        "bidirectional": False
    }
    for hl, href in patterns:
        if hl == "zh" and href == zh_url:
            result["zh_to_zh"] = True
        if hl == "en" and href == en_url:
            result["en_to_en"] = True
        if hl == "x-default" and href == default_url:
            result["x_default"] = True
    # Bidirectional: zh page declares en, AND en page declares zh
    result["bidirectional"] = all([
        result["zh_to_zh"], result["zh_to_en"],
        result["en_to_en"], result["en_to_zh"]
    ]) if lang == "zh" else all([
        result["en_to_en"], result["en_to_zh"]
    ])
    return result

def check_jsonld_blocks(html: str) -> dict:
    """Verify Article, BreadcrumbList, FAQPage are all present."""
    blocks = re.findall(r'"@type":\s*"([^"]+)"', html)
    types_found = set(blocks)
    return {
        "has_article": "Article" in types_found,
        "has_breadcrumb": "BreadcrumbList" in types_found,
        "has_faqpage": "FAQPage" in types_found,
        "all_three_present": all(
            t in types_found for t in ["Article", "BreadcrumbList", "FAQPage"]
        )
    }

5.2 canonical 必须自引用:防止跨语言 SEO 泄露

什么是「自引用 canonical」:页面 <link rel="canonical"> 的 href 值严格等于当前页面的完整 URL。即页面声称自己就是权威版本——/zh/posts/article.html 的 canonical 指向 https://xslyl.com/zh/posts/article.html,而不是任何其他 URL。

为什么必须精确匹配整个 URL:如果 canonical 指向 /zh/posts/article(无 .html 扩展名),Google 会将其视为不同的 URL。搜索引擎可能在索引中同时保留两个版本,并将排名权重分散到两个 URL 上——这被称为「重复内容稀释」。更致命的是跨语言泄露:

Agent 为什么会犯这个错:Agent 在生成 HTML 时通常从模板或上下文变量中推断 canonical URL。如果模板中的域名变量在开发环境设置为 localhoststaging,部署时没有覆盖,canonical 就会带着错误的域名上线。对于双语站点的 Agent 工作流,Agent 可能在生成 zh 页面后,用 zh 页面的 HTML 作为模板生成 en 页面,但忘记更新 canonical 标签——导致 en 页面的 canonical 仍指向 zh URL。

5.3 hreflang 必须双向:Google 将单向视为未确认

Google 的 hreflang 确认机制:Google 官方文档明确指出——hreflang 注解必须双向确认。如果页面 A 引用页面 B 作为替代版本,页面 B 也必须引用页面 A 作为替代版本。单向的 hreflang 被 Google 完全忽略(不是降级,是忽略)。这是搜索引擎领域少有的「全有或全无」规则。

为什么设计成这样:从 Google 的角度,单向 hreflang 是不可信的——任何人都可以在自己的页面中添加 hreflang 标签声称某个 URL 是自己的替代版本。只有双向确认能证明两个页面的所有者是同一个人(因为你需要修改两个页面),从而防止恶意第三方通过 hreflang 劫持搜索流量。

双向验证的实际执行:Verifying hreflang 不能仅通过检查单个页面的 HTML 完成。必须:

  1. 请求 zh 页面,提取其所有 hreflang 标签
  2. 请求 en 页面,提取其所有 hreflang 标签
  3. 交叉比对:zh 页面是否声明了 en?(zh_to_en)
  4. 交叉比对:en 页面是否声明了 zh?(en_to_zh)
  5. 两者都成立 → 双向确认 → PASS
  6. 任一缺失 → 单向 → FAIL

这要求 VERIFIED Gate 对每个部署任务发起至少 2 个 HTTP 请求(zh 和 en 页面各一个),并进行交叉引用比对。这是所有验证维度中唯一需要跨页面协调的检查——其余 9 个维度都可以独立验证单个页面。

x-default 的特殊作用:hreflang="x-default" 告诉搜索引擎「当用户的语言与所有声明的 hreflang 值都不匹配时,应使用哪个页面」。对于 xslyl.com,x-default 指向 en(英文作为默认回退语言)。缺少 x-default 不会导致 hreflang 完全失效,但当用户使用非中英文浏览器(如日语、法语)访问时,搜索引擎可能随机选择一个语言版本,而非预期的英文版。

Agent 生成的双语页面为什么容易出错:Agent 通常分两步生成双语内容:先生成 zh 页面,然后「翻译」生成 en 页面。如果 en 页面的生成过程使用了简化模板(仅替换了正文内容),可能遗漏了 hreflang 标签的更新。或者 Agent 正确生成了 zh 页面的 hreflang(含 en 引用),但在生成 en 页面时只添加了 zh 的 hreflang(因为 Agent「知道」有中文版本),却没有添加自引用(en 的 hreflang="en")——这同样破坏了双向性。

5.4 JSON-LD 必须包含三种类型:FAQPage 缺失是 Google 政策违规

三种 JSON-LD 块各自不可替代:

JSON-LD 类型 搜索结果效果 缺失后果 Agent 典型错误
Article 搜索结果中显示标题、描述、发布日期、作者;可出现在 Google News 中 搜索结果仅显示 URL 和页面片段(无结构化预览),Google News 不收录 Agent 用 BlogPosting 代替 Article;缺少必填字段(datePublished、author)
BreadcrumbList 搜索结果中显示面包屑导航(xslyl.com > 文章 > 标题),点击率提升约 5-10% 搜索结果仅显示裸 URL(不显示层级结构),用户无法判断页面在站点中的位置 Agent 生成 Article 和 FAQPage 但遗漏 BreadcrumbList;position 编号不连续
FAQPage 搜索结果中显示可展开的问答列表(FAQs),占据更多搜索版面,点击率最高 FAQ 内容被忽略,搜索结果中不显示问答列表——失去最具视觉冲击力的搜索展示 FAQ JSON-LD 存在但对应的 HTML 无 details/summary;或 FAQ JSON-LD 的 question 文本与 summary 文本不一致

FAQPage 的 Google 政策违规风险:FAQPage 是三个 JSON-LD 块中监管最严格的。Google 明确要求:JSON-LD 中声明的 FAQ 内容必须在页面上对用户可见。如果 JSON-LD 包含 5 个 FAQ,但 HTML 中只有 3 个使用 <details>/<summary> 标记(另外 2 个是普通段落),Google 的结构化数据测试工具会发出警告,严重情况可能导致该页面的所有结构化数据被忽略——包括 Article 和 BreadcrumbList。这是「连带惩罚」——一个 JSON-LD 块的违规影响其他合法的块。

JSON-LD 语法错误的最隐蔽特征:JSON-LD 块的语法错误(多余的逗号、未转义的引号)在浏览器中不可见。浏览器渲染 HTML 时忽略 <script type="application/ld+json"> 标签内的内容,JSON 解析错误不会触发任何浏览器控制台警告。页面看起来完美,但所有结构化数据对搜索引擎静默失效。只有两个方法能发现:Google Rich Results Test(手动工具)和 VERIFIED Gate 的 JSON.parse 验证(自动化)。

5.5 SEO 验证的 CI/CD 盲区

与 HTTP 层检查类似,SEO 元数据检查也无法在 CI/CD 中完成:

这些SEO 验证必须作为部署后的自动化检查运行——这是 VERIFIED Gate 的独特价值。

六、安全头验证与 nginx add_header 继承陷阱

安全头验证在 VERIFIED Gate 中属于 Tier 2(高优先级)——失败时建议阻断发布,因为安全头缺失会直接降低站点安全评级。但安全头检查中最隐蔽的失败模式不是「忘记配置」,而是 nginx 的 add_header 继承陷阱——运维人员在 server 块中正确配置了所有安全头,测试首页时一切正常,但特定路径下的页面实际上没有任何安全头返回。

6.1 五个必需安全头及其验证方法

VERIFIED Gate 验证以下 5 个 HTTP 安全响应头,每个头都有明确的期望值和失败后果:

  1. Strict-Transport-Security期望值 max-age=31536000; includeSubDomains。强制浏览器在接下来一年内只通过 HTTPS 访问本站,阻断所有中间人降级攻击。缺失时:浏览器允许用户通过 HTTP 访问,存在 SSLStrip 攻击面。
  2. X-Content-Type-Options期望值 nosniff。禁止浏览器进行 MIME 类型嗅探——如果没有这个头,浏览器可能将用户上传的 HTML 文件当作文本渲染而执行其中的脚本。缺失时:存在 MIME 混淆攻击面。
  3. X-Frame-Options期望值 SAMEORIGIN。防止页面被其他域名的 <iframe> 嵌入(clickjacking 防御)。缺失时:任意第三方网站都可以将你的页面嵌入 iframe 并进行点击劫持。
  4. Referrer-Policy期望值 strict-origin-when-cross-origin。控制 Referer 请求头的发送策略——同源时发送完整 URL,跨域时只发送域名(不含路径和查询参数),HTTPS→HTTP 降级时不发送。缺失时:浏览器默认行为(可能泄露完整 URL 给第三方)。
  5. Permissions-Policy期望值至少包含 camera=(), microphone=(), geolocation=()。显式禁用浏览器敏感 API(摄像头、麦克风、地理位置),防止恶意脚本或 XSS 注入后调用这些 API。缺失时:页面中的任何脚本(包括第三方广告脚本)都可以请求这些权限。

6.2 nginx add_header 继承陷阱:问题解剖

nginx 的 add_header 指令有一条文档中写明但常被忽视的规则:当子级上下文(location 块)中出现任何 add_header 指令时,父级上下文(server 块)中所有 add_header 都会被丢弃——不是合并,是完全覆盖。

这个行为的文档来源是 nginx 官方文档中对 add_header 的描述:"There could be several add_header directives. These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level." 翻译:「当且仅当当前层级没有定义任何 add_header 指令时,才会从上一配置层级继承。」

这意味着:只要 location 块中出现了哪怕一行 add_header,server 块中所有安全头的层层保护就在这个 location 下全部失效。

以下是最常见的陷阱配置:

# ❌ 有陷阱的 nginx 配置
server {
    listen 443 ssl;
    # 运维人员在 server 块中添加了 5 个安全头
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    location / {
        # ✅ 没有 add_header → 继承 server 块的所有头
        try_files $uri $uri/ =404;
    }

    location /stats/ {
        # ❌ 只添加了 Cache-Control,但忘记了安全头
        # 结果:server 块中所有 5 个安全头在此 location 下全部消失!
        add_header Cache-Control "public, max-age=3600";
        # 只有 Cache-Control 被返回——Strict-Transport-Security、X-Content-Type-Options 等全都不存在
    }
}

这个陷阱为什么容易被忽视:

  1. 首页测试正常:location / 块没有 add_header,安全头正常继承。运维人员在浏览器 DevTools 中打开首页,看到所有 5 个安全头返回正确——误以为全局配置生效。
  2. 只影响特定路径:/stats/ 路径下的页面完全没有安全头,但除非有人专门检查这个路径,否则不会被发现。
  3. nginx -t 不会报错:nginx -t 只检查语法合法性,不检查逻辑一致性。这个配置在语法上完全合法。
  4. 延迟发现:问题可能几周甚至几个月后才通过安全扫描工具暴露——而在此期间,特定路径下的页面一直以不安全的方式被访问。

6.3 正确的 nginx 配置

方案一(推荐——冗余声明):在每个需要自定义头(哪怕只是 Cache-Control)的 location 块中重新声明所有 5 个安全头。虽然冗余,但消除了继承陷阱,且不依赖第三方模块。

# ✅ 正确——每个 location 重新声明所有 5 个安全头
server {
    listen 443 ssl;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    location / {
        # ✅ 没有 add_header → 继承 server 块的所有头
        try_files $uri $uri/ =404;
    }

    location /stats/ {
        # ✅ 重新声明所有 5 个安全头 + 此 location 自定义头
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
        add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
        add_header Cache-Control "public, max-age=3600";
        # 现在 /stats/ 路径下的页面既有安全头也有 Cache-Control
    }
}

方案二(需要安装第三方模块——使用 ngx_headers_more):more_set_headers 指令会合并而非覆盖父级的头(类似于 map 的叠加语义),从根本上消除了继承陷阱。但需要额外编译或安装 nginx-extras 包。如果你的部署环境可以安装第三方模块,这是更优雅的解决方案。

方案三(使用 include 文件——适合大型配置):将 5 个安全头提取到单独的配置文件 /etc/nginx/conf.d/security-headers.conf,在每个需要安全头的 location 块中 include 它。这避免了在每个 location 块中重复 5 行 add_header,同时保持了显式声明的好处。

# /etc/nginx/conf.d/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

# 在需要自定义头的 location 块中
location /stats/ {
    include conf.d/security-headers.conf;  # 引入安全头
    add_header Cache-Control "public, max-age=3600";  # 自定义头
}

6.4 VERIFIED Gate 安全头验证——Python 实现

验证脚本对每个目标 URL 独立发起请求,检查 5 个必需安全头是否全部返回且值符合预期。因为 nginx 继承陷阱只影响特定 location,所以必须对每个 URL 独立验证——不能依赖「首页检查通过就全局通过」的假设。

# verify_security_headers.py — 安全头验证
import httpx

REQUIRED_HEADERS = {
    "strict-transport-security": "max-age=31536000",
    "x-content-type-options": "nosniff",
    "x-frame-options": "SAMEORIGIN",
    "referrer-policy": "strict-origin-when-cross-origin",
    # Permissions-Policy 至少包含 camera=(), microphone=(), geolocation=()
}

def check_security_headers(url: str) -> dict:
    """
    验证目标 URL 是否返回所有必需的安全响应头。

    每个头进行子串匹配——例如 'max-age=31536000' in 头的完整值。
    这允许头的值包含额外参数(如 includeSubDomains),同时仍然验证核心要求。

    Returns:
        dict: {header_name: bool} — True 表示该头存在且值包含预期内容
    """
    resp = httpx.get(url, follow_redirects=False, timeout=10)
    checks = {}

    lower_headers = {k.lower(): v for k, v in resp.headers.items()}

    for header, expected_value in REQUIRED_HEADERS.items():
        actual = lower_headers.get(header, "")
        checks[header] = expected_value in actual

    # Permissions-Policy 特殊检查:必须包含至少 3 个禁用的 API
    pp = lower_headers.get("permissions-policy", "")
    checks["permissions-policy"] = all(
        f"{api}=()" in pp for api in ["camera", "microphone", "geolocation"]
    )

    checks["all_passed"] = all(checks.values())

    return checks


# 使用示例
if __name__ == "__main__":
    import sys
    url = sys.argv[1] if len(sys.argv) > 1 else "https://xslyl.com/zh/posts/test.html"

    results = check_security_headers(url)

    print(f"URL: {url}")
    print(f"Overall: {'PASS' if results.pop('all_passed') else 'FAIL'}")
    for header, passed in results.items():
        print(f"  {'✓' if passed else '✗'} {header}")

设计要点:

为什么 CI/CD 无法完成安全头验证:

七、站点地图与首页卡片一致性检查

VERIFIED Gate 的最后两个维度——Sitemap URL 存在性和首页卡片——看似简单,但它们在验证体系中扮演着关键角色:确保已部署的内容既能被搜索引擎发现,也能被人类用户找到。这两个维度都属于中低优先级(Tier 3-4),但它们捕获的失败模式能解释大量「文章在线上但没人看」的沉默故障。

7.1 为什么必须同时检查 Sitemap 和首页

Sitemap 和首页服务于两个完全不同的发现路径:

对比维度 Sitemap 首页卡片 不一致后果
使用者 搜索引擎爬虫(Googlebot、Bingbot 等) 人类用户(通过浏览器访问首页) 搜索引擎能发现页面但用户通过首页找不到 → 搜索流量到站后用户困惑
更新频率 部署时重新生成的静态 XML 文件 部署时更新的 HTML 页面(卡片列表) 部署脚本可能更新 sitemap 但未更新 index.html,或反之 → 不一致
失败模式 sitemap.xml 未包含新 URL(生成步骤失败 / 旧版本覆盖) index.html 未更新(rsync 覆盖了旧版 / 生成步骤遗漏) 两个路径的失败模式不同 → 必须独立检查,不能互相替代
影响速度 缺失时:新页面需要数天甚至数周才能被搜索引擎自然发现 缺失时:人类用户访问首页时看不到新文章,影响即时的用户体验 搜索引擎影响滞后数天(难以快速定位根因),用户影响即时(但只有主动访问首页的人发现)

核心洞见:Sitemap 和首页是两扇独立的门。一个可以打开而另一个关闭。如果只检查 Sitemap(认为「搜索引擎能找到就行」),你会漏掉首页卡片断裂——用户通过搜索来到站点后,点击首页想浏览其他文章,却找不到刚刚读完的那篇。如果只检查首页(认为「用户能看到就行」),你会漏掉 Sitemap 缺失——搜索引擎爬虫不会主动遍历你的首页链接列表,它依赖 Sitemap 作为新内容的信号。

VERIFIED Gate 对两者进行独立验证,是因为它们由不同的生成步骤产生、有不同的失败模式、服务于不同的受众。两者同时 PASS 才是真正完整的部署。

7.2 检查一:Sitemap URL 存在性

检查内容:部署目标文章的 zh 和 en URL 都必须出现在 https://xslyl.com/sitemap.xml 中。这是最直接的检查——字符串匹配,不存在歧义。

为什么检查 zh 和 en 两个 URL:对于双语站点,Sitemap 的生成可能是分步的——例如部署脚本先生成了 zh 页面的 sitemap 条目,但生成 en 页面的条目时出错。或 sitemap 生成模板只包含了 /zh/posts/ 目录的文章,遗漏了 /en/posts/ 目录。只检查一个语言版本会漏掉不对称的生成错误。

URL 格式验证的细节:检查时应该验证完整 URL(含 https:// 前缀),而非仅 slug。原因:(1) 如果 sitemap 中意外使用了 http:// 协议,搜索引擎会收到矛盾的信号(sitemap 说 http,但页面 canonical 说 https);(2) 如果 sitemap 中使用了错误的域名(如 staging 域名残留),搜索引擎会尝试爬取不可达的 URL。

7.3 检查二:Sitemap 结构完整性

检查内容:Sitemap XML 必须具有正确的结构——以 <urlset> 开标签开始,以 </urlset> 闭标签结束,且在 </urlset> 之后没有额外的字符或截断内容。

为什么这个检查是必要的:

检查方法:不仅仅是 grep '</urlset>'(这只能证明闭标签存在于文件中的某处)。正确的检查是验证:(1) 闭标签存在;(2) 闭标签之后没有非空白字符。一个简单的检查方法:获取 sitemap 文本,找到 </urlset> 的位置,确认其后所有的字符都是空白(换行、空格、制表符)。

7.4 检查三:首页卡片存在性

检查内容:部署目标文章的链接(至少是 slug)必须出现在 https://xslyl.com/zh/index.htmlhttps://xslyl.com/en/index.html 的文章卡片列表中。

首页卡片检查的微妙之处:

7.5 VERIFIED Gate Sitemap 与首页验证——Python 实现

以下是一个整合的验证脚本,一次性完成三个检查:

# verify_sitemap_and_homepage.py — Sitemap 与首页卡片一致性验证
import httpx
import re


def check_sitemap_and_homepage(slug: str) -> dict:
    """
    验证已部署文章在 sitemap 和首页中的存在性与一致性。

    Args:
        slug: 文章的唯一标识符(URL slug),如 "agent-verified-deployment-gate-design"

    Returns:
        dict: 包含三个检查维度结果的字典,每个维度为 bool
    """
    results = {}

    # ========== 检查一:Sitemap URL 存在性 ==========
    try:
        sitemap_resp = httpx.get("https://xslyl.com/sitemap.xml", timeout=10)
        sitemap = sitemap_resp.text

        zh_url = f"/zh/posts/{slug}"
        en_url = f"/en/posts/{slug}"

        results["zh_url_in_sitemap"] = zh_url in sitemap
        results["en_url_in_sitemap"] = en_url in sitemap

        # 额外验证:URL 使用 https 协议(而非 http)
        # 在 sitemap 中搜索包含该 slug 的完整 URL
        zh_full_pattern = re.findall(
            rf'(https?://[^<]*{re.escape(zh_url)}[^<]*)', sitemap
        )
        en_full_pattern = re.findall(
            rf'(https?://[^<]*{re.escape(en_url)}[^<]*)', sitemap
        )
        results["zh_sitemap_https"] = all(
            url.startswith("https://") for url in zh_full_pattern
        ) if zh_full_pattern else results["zh_url_in_sitemap"]
        results["en_sitemap_https"] = all(
            url.startswith("https://") for url in en_full_pattern
        ) if en_full_pattern else results["en_url_in_sitemap"]

    except Exception as e:
        results["zh_url_in_sitemap"] = False
        results["en_url_in_sitemap"] = False
        results["sitemap_error"] = str(e)

    # ========== 检查二:Sitemap 结构完整性 ==========
    try:
        # 验证 XML 结构:必须有 urlset 闭标签
        # 且闭标签之后不能有非空白字符(XML 1.0 规范要求)
        results["sitemap_closed"] = "</urlset>" in sitemap

        if results["sitemap_closed"]:
            # 检查闭标签之后是否只有空白字符
            closing_pos = sitemap.rfind("</urlset>") + len("</urlset>")
            after_closing = sitemap[closing_pos:]
            results["sitemap_well_formed"] = after_closing.strip() == ""
        else:
            results["sitemap_well_formed"] = False

        # 额外验证:开标签存在(确认不是一个碰巧包含 </urlset> 字符串的文本文件)
        results["sitemap_has_opening"] = " 1 else "agent-verified-deployment-gate-design"

    results = check_sitemap_and_homepage(slug)

    print(f"Slug: {slug}")
    print(f"Overall: {'PASS' if results.pop('all_critical_passed') else 'FAIL'}")
    print()
    print("=== Sitemap ===")
    print(f"  {'✓' if results['zh_url_in_sitemap'] else '✗'} zh URL in sitemap.xml")
    print(f"  {'✓' if results['en_url_in_sitemap'] else '✗'} en URL in sitemap.xml")
    print(f"  {'✓' if results.get('sitemap_closed', False) else '✗'} </urlset> closing tag")
    print(f"  {'✓' if results.get('sitemap_well_formed', False) else '✗'} Well-formed XML")

    print()
    print("=== Homepage ===")
    print(f"  {'✓' if results['zh_slug_in_home'] else '✗'} slug in zh/index.html")
    print(f"  {'✓' if results['en_slug_in_home'] else '✗'} slug in en/index.html")

7.6 设计哲学:为什么检查看似「显而易见」

有人可能质疑:Sitemap 和首页都是部署过程自动生成的,为什么要专门验证?答案在于部署过程的原子性缺陷

部署脚本通常包含多个步骤——rsync 文章文件、重新生成 sitemap、更新 index.html、rsync 更新后的文件——但这些步骤之间没有事务保证。步骤 1(rsync 文章文件)可能成功,但步骤 2(重新生成 sitemap)因为磁盘空间不足而静默失败(sitemap 生成器输出了错误消息到 stderr 但没有返回非零退出码)。步骤 3(更新 index.html)可能因为模板变量未传递而使用了旧版缓存。

CI/CD 看到所有步骤的退出码都是 0(因为每个步骤都「执行了」,即使输出不正确),标记部署成功。但最终用户看到的是一个不一致的站点状态:文章存在但搜索引擎不知道,或文章存在但首页没有入口。

VERIFIED Gate 的 Sitemap 和首页检查正是在验证「部署过程的最终输出是不是一致的」——这是一个整合检查,而非步骤检查。它不关心每个部署步骤是否成功执行,只关心最终状态是否正确。这个区别是 VERIFIED Gate 和 CI/CD 最根本的哲学差异。

7.7 恢复路径:Sitemap 或首页检查失败时

Sitemap 和首页都属于低优先级维度(Tier 3-4),失败时不会阻断发布,但需要记录和跟进:

八、部署完整性验证:commit 一致性 + 紧急部署路径

前七个验证维度关注的是「部署到线上的内容是否正确」——HTTP 状态、SEO 元数据、安全头、Sitemap、首页卡片。但还有一个更根本的问题尚未回答:部署到服务器上的文件,真的是 GitHub 上合并的那个版本吗?

这不是一个理论问题。在 Agent 发布的管道中,从 GitHub 的 merge commit 到 VPS 上实际运行的 HTML 文件之间,存在多个可能引入偏差的环节:git pull 可能因为网络中断只拉取了部分文件、deploy-xslyl.sh 可能使用了错误的源目录、rsync 可能因为磁盘空间不足而截断文件。如果部署的文件与预期不一致,前面七个维度的检查——无论多么精细——都是在检查「错误的内容」。

第八个验证维度正是为此设计:三方一致性模型——GitHub SHA ↔ VPS git log ↔ 在线内容——确保部署链路上的每一个环节都没有引入偏差。

8.1 三方一致性模型

部署完整性的验证基于一个简单但严格的三方比对:

GitHub SHA (merge_commit)
    │
    ├──► VPS git log (vps_commit)
    │        │
    │        └──► 在线内容 (curl 后校验)
    │
    三方必须一致,任一环节不匹配 = 部署不完整

三个锚点的定义:

锚点 来源 含义
merge_commit GitHub PR merge event 用户或 Agent 在 GitHub 上合并的 commit SHA——这是「应该被部署的内容」的权威标识
vps_commit VPS git log -1 --format=%H VPS 上 git pull 后仓库的 HEAD commit——这是「实际被部署工具使用的内容源」
在线内容 HTTP GET 目标 URL 用户和搜索引擎实际看到的内容——这是「最终交付物」

为什么必须三方比对而非两方:

8.2 标准部署路径

标准部署遵循一条确定的命令链:

git pull origin main → deploy-xslyl.sh → rsync to /var/www/html/ → verify

Gate 从这条路径中捕获四个关键信息:

字段 类型 含义 失败模式
merge_commit string (40-char SHA) 在 GitHub 上合并的 commit N/A(来源于 GitHub API,可靠)
vps_commit string (40-char SHA) VPS 上 git pull 后的 HEAD ≠ merge_commit → git pull 失败或未执行
deploy_script_result "success"/"failure" deploy-xslyl.sh 的退出状态 failure → rsync 未执行或脚本错误
dry_run boolean 是否为演习(dry-run)模式 true → 未实际部署,必须为 false

dry_run 检查的重要性:部署脚本通常支持 --dry-run 模式用于测试。Agent 在执行部署时可能错误地使用了 dry-run 模式——脚本输出显示「部署成功」,但实际上没有任何文件被写入 Web 目录。这个错误极其隐蔽:所有输出看起来正常,但线上内容没有任何变化。Gate 必须显式检查 dry_run 字段为 false 才确认部署真正发生了。

8.3 紧急部署路径

当标准部署路径不可用时——SSH 密钥问题、git 服务器宕机、VPS 磁盘 I/O 故障——可能需要使用替代方法将文件传输到生产环境。紧急部署方法不是主路径,但必须被 Gate 识别和正确处理。

三种紧急部署方法:

方法 命令示例 风险
emergency_scp scp file.html user@vps:/var/www/html/zh/posts/ 只传输单个文件,不更新 git 仓库——导致 VPS 仓库与在线内容不一致,后续 git pull 可能产生冲突
emergency_rsync_no_delete rsync -av --no-delete ./zh/ user@vps:/var/www/html/zh/ 不删除目标端多余文件——如果旧版本有额外文件,它们会残留在服务器上,可能导致旧版 URL 仍然可访问
tar pipe tar czf - zh/ | ssh user@vps "tar xzf - -C /var/www/html/" 无删除模式 + 无 git 同步 + 管道错误难捕获——如果 tar 在本地失败但管道已开启,VPS 端可能收到空流并解压出空目录

紧急部署的 Gate 规则:

  1. 紧急部署必须获得用户显式批准。Agent 不能在标准部署失败后自动切换到紧急部署——必须先向用户报告标准部署失败的原因,并提出紧急部署方案,等待用户明确同意("同意"、"执行"、"yes")。这是强制性审批操作。
  2. 紧急部署的结果标记为 VERIFIED_WITH_WARNING,而非 VERIFIED原因:紧急部署绕过了 git 同步,VPS 仓库与 GitHub 仓库从此不一致。后续的标准部署(git pull)可能产生冲突。WARNING 标记提醒运维人员需要在条件恢复后执行一次「同步部署」——即 git pull + deploy 的标准路径——以恢复仓库一致性。
  3. 紧急部署仍需运行完整 VERIFIED Gate。即使部署方法不同,部署后的内容验证(HTTP 状态、SEO 元数据、安全头、Sitemap、首页卡片)必须全部通过。唯一跳过的只有 commit 一致性检查(因为 VPS git log 已与 GitHub 不一致)。

8.4 check_deploy_integrity 实现

以下是部署完整性检查的简化实现:

def check_deploy_integrity(deploy_report: dict, status: dict) -> dict:
    """
    验证部署完整性:commit 一致性 + 部署路径正确性。

    Args:
        deploy_report: 部署过程报告,包含 deploy_method, result, git_pull_result,
                       deploy_script_result, dry_run, emergency_* 等字段
        status: 任务状态对象,包含 task_id 等标识信息

    Returns:
        checks: 每个检查项的 PASS/FAIL 结果字典
    """
    checks = {}

    # 1. 基本身份验证:确认部署报告对应正确的任务
    checks["task_id_match"] = deploy_report.get("task_id") == status.get("task_id")

    # 2. 部署方法是否已知且合法
    checks["method_known"] = deploy_report.get("deploy_method") in [
        "vps_git_pull_main_plus_deploy_script",
        "emergency_scp", "emergency_rsync_no_delete"
    ]

    # 3. 部署结果是否为 PASS
    checks["result_pass"] = deploy_report.get("result") == "PASS"

    # 4. 根据部署方法执行不同的检查
    method = deploy_report.get("deploy_method", "")

    if "emergency" in method:
        # 紧急部署路径:必须获得用户批准
        emergency = deploy_report.get("emergency_direct_transfer", False)
        approval = deploy_report.get("emergency_approval", {})
        checks["emergency_approved"] = (
            emergency
            and approval.get("approved_by_user") is True
        )
        # 紧急部署不检查 git 一致性
        checks["git_consistency"] = "SKIPPED"
    else:
        # 标准部署路径:git pull 和 deploy 脚本均须成功
        checks["git_pull_ok"] = (
            deploy_report.get("git_pull_result") == "success"
        )
        checks["script_ok"] = (
            deploy_report.get("deploy_script_result") == "success"
        )
        # dry_run 必须为 false(确认不是演习)
        checks["not_dry_run"] = (
            deploy_report.get("dry_run") is False
        )
        # commit 一致性
        checks["commit_match"] = (
            deploy_report.get("merge_commit")
            == deploy_report.get("vps_commit")
        )

    return checks

设计要点:

8.5 commit 一致性的价值:防止「部署了错误的版本」

commit 一致性检查回答了部署完整性中最重要的问题:「部署的版本,就是合并的版本吗?」以下是一个真实场景:

  1. Agent 完成了一篇文章的修改,提交了 PR,用户审核后合并到 main。
  2. GitHub Actions 触发部署流程。Agent 在 VPS 上执行 git pull origin main
  3. 网络瞬时中断导致 git pull 只拉取了部分对象。git 没有报错(因为已有的对象覆盖了大部分),但 HEAD 停留在旧 commit。
  4. deploy-xslyl.sh 从旧 commit 部署了旧版本的文章。
  5. 前七个维度的验证全部通过——因为旧版本的文章 HTML 结构也是完整的,SEO 元数据也存在,安全头也正常。
  6. 但用户看到的不是最新版本。新的修改(如修正了一个错误的 canonical URL)没有被部署。

在这个场景中,如果没有 commit 一致性检查,VERIFIED Gate 会标记 VERIFIED——但部署的实际上是旧版本。commit 一致性检查通过比对 GitHub SHA 和 VPS HEAD SHA,在部署流程的「最上游」验证了内容源的完整性。这是所有下游检查(HTTP、SEO、安全头等)有效的前提。

九、元检查:Gate 版本一致性 + 策略确认

前面八个维度检查的是「部署到线上的内容是否正确」。但还有一个更深层的问题:执行这些检查的 Gate 脚本本身,是正确的版本吗?

这是一个自指问题(self-referential problem):验证系统需要验证自身。如果 Gate 脚本被意外降级到旧版本——缺少最新检查维度、使用过时的阈值、或跳过了新发现的失败模式——它会返回 PASS,但这个 PASS 是基于不完整的检查得出的。一个过期的 Gate 脚本比没有 Gate 更危险:它给人「已验证」的虚假信心,而实际上验证根本没有发生。

第九个维度——元检查——正是为了解决这个问题而存在。它包含两个独立的子检查:Gate 脚本版本一致性和策略确认。

9.1 Gate 脚本版本一致性

问题定义:VERIFIED Gate 不是一个单一的脚本,而是一套脚本的组合:

scripts/agent/
├── check_ready_gate.py       # Phase 7: 部署前准备验证
├── check_verified_gate.py    # Phase 8: 部署后在线验证
├── check_vm_evidence.py      # VM 执行证据收集
└── check_policy_ack.py       # 策略确认验证

这些脚本在逻辑上是协同工作的——check_ready_gate.py 验证部署前的条件,check_verified_gate.py 验证部署后的结果,check_vm_evidence.py 收集执行证据,check_policy_ack.py 验证流程合规性。它们必须协同演化——当 check_verified_gate.py 新增了一个检查维度时,相关的证据收集逻辑可能也需要更新。

版本一致性规则:所有 Gate 脚本必须声明相同的 GATE_SCRIPT_VERSION。这是一个单调递增的版本号(如 "2026-06-07-v3"),当任何一个 Gate 脚本被修改时,版本号必须更新且所有脚本必须同步到相同版本。

为什么版本必须全局一致:

实现:

# 每个 Gate 脚本的顶部声明
GATE_SCRIPT_VERSION = "2026-06-07-v3"

# check_verified_gate.py 在执行检查之前验证所有脚本版本一致
def check_gate_script_versions(gate_dir: str) -> dict:
    """验证所有 Gate 脚本的版本声明一致。"""
    import ast, os

    gate_scripts = [
        "check_ready_gate.py",
        "check_verified_gate.py",
        "check_vm_evidence.py",
        "check_policy_ack.py"
    ]

    versions = {}
    for script in gate_scripts:
        path = os.path.join(gate_dir, script)
        if not os.path.exists(path):
            versions[script] = None  # 脚本缺失
            continue

        with open(path) as f:
            tree = ast.parse(f.read())

        for node in ast.walk(tree):
            if (isinstance(node, ast.Assign)
                and len(node.targets) == 1
                and isinstance(node.targets[0], ast.Name)
                and node.targets[0].id == "GATE_SCRIPT_VERSION"):
                versions[script] = node.value.value
                break
        else:
            versions[script] = None  # 未声明版本

    unique_versions = set(v for v in versions.values() if v is not None)

    return {
        "consistent": len(unique_versions) <= 1,
        "versions": versions,
        "missing": [s for s, v in versions.items() if v is None],
        "mismatched": len(unique_versions) > 1
    }

版本不一致时的 Gate 行为:

9.2 策略确认

问题定义:check_policy_ack.py 验证任务报告(task report)中是否包含所需的策略确认(policy acknowledgment)字段。这是一个元检查,因为它验证的是「验证过程本身是否遵循了规则」。

策略确认包含的内容:

为什么策略确认是一个元检查:

策略确认不直接验证部署内容——它验证的是内容生产过程的合规性。没有策略确认,你可以得到一个「所有维度都 PASS」的验证结果,但不知道这个结果是否是在遵守安全策略的前提下得出的。例如:

策略确认元检查确保「验证结果的正确性」不掩盖「生产过程的违规」。

check_policy_ack 验证的关键字段:

# task_report.json 中必须包含的 policy_ack 字段
{
  "policy_ack": {
    "command_approval_policy_read": true,
    "auto_allowed_operations": [
      "git status", "git diff", "git log",
      "grep -c pattern file", "read_file(...)", "search_files(...)"
    ],
    "user_approved_operations": [
      {
        "operation": "edit zh/posts/slug.html",
        "approval_time": "2026-06-07T14:30:00Z",
        "approval_method": "user replied '同意'"
      }
    ],
    "blocked_patterns_avoided": true,
    "sub_agent_boundary": {
      "used_sub_agent": true,
      "sub_agent_role": "Codex — Engineering QA Gate Worker",
      "autonomous_operations": ["run check_verified_gate.py", "collect curl outputs"],
      "user_approved_operations": []
    },
    "commit_approved": true,
    "push_approved": false,
    "deploy_approved": true
  }
}

策略确认缺失时的 Gate 行为:

9.3 元检查的整体设计哲学

元检查的核心理念可以概括为一句话:一个过期或不完整的 Gate 脚本,比没有 Gate 更危险。

没有 Gate 时,你至少知道「部署没有被验证」——你会更加谨慎,可能会手动检查关键页面。但一个过期或不完整的 Gate 给你 VERIFIED 的绿色标记,让你以为一切正常,而实际上关键的检查维度被跳过了、失败的信号被忽略了、或者检查本身就不完整。

Gate 版本一致性检查确保「验证引擎」是完好的。策略确认检查确保「验证过程」是合规的。两者结合,构成了对 VERIFIED Gate 本身的信任基础——如果元检查不通过,下游的八个验证维度无论结果如何,都不能被视为可靠的。

元检查项 验证目标 失败后果 恢复路径
Gate 版本一致性 所有 Gate 脚本声明相同的 GATE_SCRIPT_VERSION Gate 套件不一致 → 验证结果不可信 → 状态退回到 NEEDS_HUMAN 同步所有 Gate 脚本到最新版本,重新运行验证
策略确认 任务报告包含完整的 policy_ack 字段,所有敏感操作有审批记录 无法确认安全合规性 → 部署虽正确但生产过程违规 → 状态 FAIL Agent 补充策略确认记录;如果确实存在未批准操作,需要人工审查

将元检查纳入 VERIFIED Gate 后,完整的验证维度矩阵从 10 个扩展到 12 个——前八个验证部署内容,第九个验证验证引擎本身,第十个验证验证过程的合规性。这 12 个维度合在一起,构成了从「字节到达服务器」到「验证系统本身可信」的完整验证闭环。

十、验证 Gate 失败时的恢复路径

VERIFIED Gate 的全部价值体现在它失败的时候。一个只有 PASS 没有 FAIL 的 Gate 是安慰剂——它让人感觉安全,但没有提供实际的保护。真正的 Gate 必须明确回答一个问题:当某个维度失败时,下一步应该做什么?

没有恢复路径的验证结果只是半成品——它告诉你「哪里坏了」,但没有告诉你「怎么修」。本节为每个验证维度定义明确的恢复路径,并建立「fix forward」与「rollback」的决策框架。

10.1 逐维度恢复路径

每个验证维度的失败都有特定的根因和对应的修复方法。以下是完整的恢复路径矩阵:

失败维度 典型根因 修复动作 验证动作
HTTP 200 文件路径错误、nginx location 不匹配、rsync 目标目录错误 修复服务器配置 → 重新部署:确认文件存在于正确的 /var/www/html/zh/posts/ 路径下;检查 nginx try_filesroot 指令;修正后 rsync 重新传输 curl -fsS 确认 200,无重定向
Canonical Agent 使用了错误域名(staging/localhost)、跨语言指向、HTTP 协议 修复文章 HTML → 重新部署:修改 <link rel="canonical"> 的 href 为正确的 https 生产 URL;确认自引用正确 grep canonical → 比对期望 URL
Hreflang 单向引用、缺失 x-default、URL 路径不一致 修复两个语言版本的 head 区域 → 重新部署:补充缺失的 hreflang 标签;确保 zh↔en 双向互指且 x-default 指向 en 交叉比对 zh 和 en 页面的 hreflang 标签
JSON-LD 缺失某个块(如 BreadcrumbList)、JSON 语法错误、URL 协议错误 修复结构化数据 → 重新部署:补充缺失的 JSON-LD 块;修复 JSON 语法(逗号、引号);将 URL 协议改为 https python3 JSON.parse 验证三个块
安全头 nginx add_header 继承陷阱、配置遗漏、CDN 剥离 修复 nginx 配置 → nginx reload → 重新验证:在受影响 location 块中补充安全头声明;执行 nginx -t && nginx reload curl -fsSI 检查 5 个安全头
Sitemap 生成脚本失败、旧版覆盖、zh/en 不对称 修复 sitemap 生成逻辑 → 重新生成 → 重新部署:确保生成脚本包含 zh 和 en 两个目录;验证生成后 sitemap 结构完整 grep sitemap.xml 确认 URL 存在
首页卡片 index.html 未更新、部署了旧版、zh/en 不对称 修复 index.html → 重新部署:更新 zh/index.html 和 en/index.html 的卡片列表;重新 rsync 两个首页文件 grep index.html 确认 slug 出现在链接中

核心原则:所有恢复路径都要求 fix → redeploy → re-verify 的完整循环。不允许跳过重新验证直接标记为 PASS——因为修复动作可能引入新的问题(如修复 canonical 时意外破坏了 hreflang)。重新验证确保修复是完整的,且没有副作用。

10.2 Fix Forward vs Rollback:决策框架

当 VERIFIED Gate 失败时,面临两种恢复策略:fix forward(修正问题并重新部署)和 rollback(回退到上一个已知良好的部署)。选择哪种策略取决于失败的性质和影响范围。

对比维度 Fix Forward Rollback
定义 在现有部署的基础上修正具体问题,然后重新部署修正后的版本 将服务器恢复到上一个通过 VERIFIED Gate 的部署状态,放弃当前部署的全部变更
适用场景 内容级问题:canonical 错误、hreflang 缺失、JSON-LD 语法问题、首页卡片遗漏——这些问题只影响当前部署的文章,不影响站点的其他内容 结构级问题:部署本身损坏(文件截断、目录结构破坏、nginx 配置语法错误导致全局 500)、部署了完全错误的版本(wrong repo、wrong branch)
优点 保留所有内容变更(包括与失败无关的其他修改);修复范围小、速度快;不丢失任何已部署的正确内容 立即恢复站点到已知正确状态;消除所有不确定性;为离线诊断争取时间
缺点 修复过程本身可能引入新问题;需要多次部署+验证循环 丢失自上次成功部署以来的所有内容变更;用户在一段时间内看不到已发布的内容更新
推荐度 首选策略——适用于 90%+ 的 Gate 失败场景 ⚠️ 仅当部署本身结构性损坏时使用

为什么 fix forward 是首选:对于内容级问题(canonical 错误、hreflang 缺失、JSON-LD 问题等),fix forward 是更优的选择,原因是:

  1. 保留所有其他内容变更:一次部署可能包含多篇文章的发布或更新。如果因为一篇文章的 canonical 错误就回滚整个部署,会同时撤销其他正确文章的发布——而这些文章的用户可能已经通过搜索或分享链接在访问了。回滚意味着这些用户看到的是旧版本或 404。
  2. 问题隔离:内容级问题的根因通常是特定文件的特定行(如某个 HTML 标签写错了)。修复这一行不影响部署中的其他文件。fix forward 让你精确地修复问题而不影响正确的内容。
  3. 修复速度:修正一行 HTML 并重新 rsync 通常只需要几十秒。而 rollback 需要:确认上一个正确版本 → git reset → 重新部署 → 验证整站状态(不仅是当前文章,还需确认回滚没有引入其他问题)。
  4. 避免「回滚引起的回归」:回滚到旧版本可能重新引入旧版本中已修复的问题。例如,当前部署修复了一个安全头配置错误,但因为另一篇文章的 canonical 问题触发了回滚,安全头修复也被回退了——导致站点退回到不安全状态。

Rollback 仅应在以下情况使用:

10.3 恢复决策树

以下决策树将上述分析编译为可操作的决策流程。当 VERIFIED Gate 返回 FAIL 时,按照此决策树选择恢复策略:

VERIFIED Gate 返回 FAIL
        │
        ▼
部署结构是否完好?(文件存在、服务器响应、nginx 未报 500)
        │
        ├── YES ──► 内容级问题(canonical/hreflang/JSON-LD/sitemap/首页卡片)
        │              │
        │              └──► Fix Forward:修复内容 → 重新部署 → 重新验证
        │
        └── NO  ──► 问题可在服务器上直接修复吗?
                       │
                       ├── YES(如安全头缺失、单文件路径错误)
                       │      │
                       │      └──► 就地修复:修正 nginx 配置/移动文件 → nginx reload → 重新验证
                       │             (注意:就地修复后,需将修复同步回 Git 仓库,防止下次部署覆盖修复)
                       │
                       └── NO(如整个部署损坏、目录结构错误)
                              │
                              └──► Rollback:回退到上一个 VERIFIED 部署 → 离线修复 → 重新部署

决策树使用的三个关键问题:

  1. 「部署结构是否完好?」——检查服务器是否在响应 HTTP 请求、nginx 是否正常运行、文件系统结构是否完整。如果连基本的 HTTP 服务都不可用,内容级修复没有意义。
  2. 「问题可在服务器上直接修复吗?」——有些问题不需要重新部署整个仓库(如 nginx 配置中的安全头遗漏、单个文件放错了目录)。就地修复更快,但必须将修复提交回 Git 仓库,否则下次 git pull 会覆盖掉修复。
  3. 「Rollback 的目标版本是什么?」——回退到上一个通过 VERIFIED Gate 的部署。这要求部署系统维护一个「已知良好版本」的记录(通常通过 verify-report.json 中的 last_verified_commit 字段)。

与 Tier 体系的协作:恢复路径的选择与验证维度的 Tier 分级(见第二章)密切相关:

十一、在 Agent 发布管道中部署 VERIFIED Gate

VERIFIED Gate 不是一个独立的工具——它是 Agent 发布管道的一个有机组成部分。这一节解释 Gate 如何在管道中运行、由谁执行、以及如何触发状态机的转移。

11.1 管道集成:VERIFIED Gate 作为最终阶段

在 xslyl.com 的 Agent 发布管道(8 阶段 Gate 管线)中,VERIFIED Gate 是 Phase 8——最后一个阶段。它的前置阶段是 Phase 7(READY Gate——部署前准备验证),后置是状态终态(VERIFIED 或 FAILED)。

Agent 发布管道(简化视图)
═══════════════════════════════════════════════════════

Phase 1-6: 内容准备阶段(草稿 → 审核 → 修改 → 审批 → 最终化)
     │
     ▼
Phase 7: READY Gate — 部署前验证
     │  ├── 验证所有 14 个必需章节存在
     │  ├── 验证所有链接可达
     │  ├── 验证 HTML 结构有效
     │  └── 通过后允许进入部署阶段
     │
     ▼
Phase 8: 部署执行 — git pull + deploy-xslyl.sh + rsync
     │
     ▼
Phase 8: VERIFIED Gate — 部署后在线验证 ◀── 本节焦点
     │  ├── HTTP 状态验证
     │  ├── SEO 元数据验证(canonical, hreflang, JSON-LD)
     │  ├── 安全头验证
     │  ├── Sitemap 与首页卡片验证
     │  ├── Commit 一致性验证
     │  └── 元检查(Gate 版本 + 策略确认)
     │
     ▼
状态终态: VERIFIED ✅ 或 FAILED ❌

VERIFIED Gate 不是 CI/CD 流水线的一部分,而是在 CI/CD 完成部署后、由 Agent 在 repo 本地触发的验证阶段。这个设计选择有深层原因:

11.2 执行者:Codex 作为 Gate Worker

在 xslyl.com 的 Agent 架构中,VERIFIED Gate 的执行者是 Codex(Engineering QA & Gate Worker)。为什么是 Codex 而非 Hermes-Agent?

角色 职责 为什么不适合执行 Gate
Hermes-Agent 任务编排、用户交互、内容审核、流程协调 Hermes 负责内容生成和发布决策——如果它也执行验证,就失去了「独立验证」的意义(自己检查自己的工作)
Codex 工程 QA、Gate 执行、在线验证、确定性脚本运行 ✅ 适合——Codex 是工程 QA 角色,有 repo 访问权限和在线验证工具;作为独立角色提供「他检」而非「自检」

Codex 的能力边界:

11.3 状态机集成:从 deployed 到 verified

VERIFIED Gate 的核心作用是在 Agent 状态机中触发状态转换。在任务状态机中,部署完成后状态为 deployed,但这不是终态——deployed 只表示「字节到达了服务器」,不表示「内容正确」。VERIFIED Gate 的执行结果将状态从 deployed 推进到终态:

状态转换图(Deploy → Verify 段)
═══════════════════════════════════════

    [deploying] ──► [deployed] ──► VERIFIED Gate 执行
                                       │
                    ┌──────────────────┼──────────────────┐
                    ▼                  ▼                  ▼
              [verified]     [failed]            [verified_with_warning]
              全部 PASS       Tier 1 失败         Tier 2 降级通过
              终态 ✅         终态 ❌             终态 ⚠️

三个终态的含义:

11.4 完整管道集成代码

以下 Python 代码展示了 Codex 如何执行 VERIFIED Gate 并触发状态机转换。这不是伪代码——它是 Gate 在管道中实际运行逻辑的精简版。

# Pipeline integration: VERIFIED gate as state machine trigger
import json
from pathlib import Path

def run_verified_gate(task_id: str) -> str:
    """
    执行 VERIFIED Gate 的全部验证维度,并触发状态机转换。

    这个函数由 Codex(Engineering QA & Gate Worker)调用。
    它从 repo 根目录运行,确保审计轨迹留在 GitHub。

    Args:
        task_id: 任务标识符(如 "2026-06-07-agent-verified-gate-design")

    Returns:
        str: 状态机的新状态——"VERIFIED", "VERIFIED_WITH_WARNING", 或 "VERIFICATION_FAILED"
    """
    task_dir = Path(f".agent-workspace/tasks/{task_id}")

    # ======== Step 1: 检查部署完整性(commit 一致性) ========
    deploy_report_path = task_dir / "deploy-report.json"
    if not deploy_report_path.exists():
        return "VERIFICATION_FAILED"

    with open(deploy_report_path) as f:
        deploy_report = json.load(f)

    # 加载当前状态
    with open(task_dir / "status.json") as f:
        status = json.load(f)

    # 检查部署完整性:是否所有关键字段都存在且一致
    deploy_ok = all([
        deploy_report.get("result") == "PASS",
        deploy_report.get("dry_run") is False,
        deploy_report.get("deploy_script_result") == "success",
        # 标准部署路径:commit 必须一致
        deploy_report.get("merge_commit") == deploy_report.get("vps_commit")
        if "emergency" not in deploy_report.get("deploy_method", "")
        else True  # 紧急部署跳过 commit 一致性
    ])

    if not deploy_ok:
        return "VERIFICATION_FAILED"

    # ======== Step 2: 验证所有在线维度 ========
    slug = status.get("slug")
    zh_url = f"https://xslyl.com/zh/posts/{slug}.html"
    en_url = f"https://xslyl.com/en/posts/{slug}.html"

    # 调用各验证模块(每个模块独立运行,互不干扰)
    results = {}

    # 2a: HTTP 与内容层
    from verify_http_content import check_http_and_content
    results["http_content_zh"] = check_http_and_content(zh_url, slug)
    results["http_content_en"] = check_http_and_content(en_url, slug)

    # 2b: SEO 元数据
    #     注意:hreflang 验证需要同时获取 zh 和 en 两个页面的 HTML
    import httpx
    zh_html = httpx.get(zh_url, timeout=10).text
    en_html = httpx.get(en_url, timeout=10).text

    from verify_seo import check_canonical, check_hreflang_bidirectional, check_jsonld_blocks
    results["canonical_zh"] = check_canonical(zh_html, zh_url)
    results["canonical_en"] = check_canonical(en_html, en_url)
    results["hreflang"] = check_hreflang_bidirectional(
        zh_html, "zh", "en", zh_url, en_url, en_url
    )
    results["jsonld_zh"] = check_jsonld_blocks(zh_html)
    results["jsonld_en"] = check_jsonld_blocks(en_html)

    # 2c: 安全头
    from verify_security_headers import check_security_headers
    results["security_zh"] = check_security_headers(zh_url)
    results["security_en"] = check_security_headers(en_url)

    # 2d: Sitemap 与首页
    from verify_sitemap_and_homepage import check_sitemap_and_homepage
    results["sitemap_home"] = check_sitemap_and_homepage(slug)

    # 汇总所有维度的 PASS/FAIL
    all_checks = {}
    for module_result in results.values():
        all_checks.update(module_result)

    online_ok = all(all_checks.values())

    if not online_ok:
        # 区分 Tier 1 失败(阻断)和 Tier 2 失败(可降级)
        # 简化版:检查关键维度
        tier1_passed = all([
            results.get("http_content_zh", {}).get("status_200", False),
            results.get("http_content_en", {}).get("status_200", False),
            results.get("canonical_zh", False),
            results.get("canonical_en", False),
            results.get("hreflang", {}).get("bidirectional", False),
        ])
        if not tier1_passed:
            return "VERIFICATION_FAILED"
        # Tier 1 通过但其他维度有失败 → 进入降级判断

    # ======== Step 3: 元检查 ========
    from check_policy_ack import verify_policy_ack
    from check_verified_gate import check_gate_script_versions

    policy_ok = verify_policy_ack(task_id)
    versions_ok = check_gate_script_versions("scripts/agent")["consistent"]

    meta_ok = policy_ok and versions_ok
    if not meta_ok:
        # 元检查失败——验证系统本身不可信
        return "VERIFICATION_FAILED"

    # ======== Step 4: 写入 verify-report.json ========
    # 统一整合所有验证结果、Tier 分类和元检查结果
    verify_report = {
        "status": "VERIFIED" if online_ok else "VERIFIED_WITH_WARNING",
        "overall": "PASS" if online_ok else "PASS_WITH_WARNINGS",
        "checks": all_checks,
        "tier1_failures": [
            k for k, v in all_checks.items()
            if not v and k in {"status_200", "canonical_zh", "canonical_en", "bidirectional"}
        ],
        "tier2_failures": [
            k for k, v in all_checks.items()
            if not v and k not in {"status_200", "canonical_zh", "canonical_en", "bidirectional"}
        ],
        "meta": {
            "policy_ack": policy_ok,
            "gate_versions_consistent": versions_ok
        },
        "deploy_method": deploy_report.get("deploy_method", "standard")
    }

    with open(task_dir / "verify-report.json", "w") as f:
        json.dump(verify_report, f, indent=2, ensure_ascii=False)

    # ======== Step 5: 更新 final-report.md 中的在线 URL ========
    # 最终报告需要包含已部署页面的真实 URL,供用户直接访问验证
    final_report_path = task_dir / "final-report.md"
    if final_report_path.exists():
        final_report = final_report_path.read_text()
        # 在报告末尾追加在线 URL 部分
        urls_section = f"""
        ## 在线 URL

        - 中文版: [{zh_url}]({zh_url})
        - 英文版: [{en_url}]({en_url})
        - Sitemap: [https://xslyl.com/sitemap.xml](https://xslyl.com/sitemap.xml)
        - 验证报告: 见 `verify-report.json`

        **Gate 状态: {verify_report['status']}**
        """
        if "## 在线 URL" not in final_report:
            final_report_path.write_text(final_report + urls_section)

    # ======== Step 6: 返回状态机目标状态 ========
    return "VERIFIED" if online_ok else "VERIFIED_WITH_WARNING"

设计要点:

  1. Step 顺序不可变:部署完整性检查必须在在线维度验证之前——如果部署本身有问题(commit 不一致、dry_run 模式),在线验证没有意义。
  2. 各验证模块独立运行:一个维度的失败不会阻止其他维度的验证。即使 HTTP 检查失败,仍然尝试运行 SEO 验证——这样 verify-report.json 中会包含所有维度的完整快照,而非只有第一个失败维度。
  3. Tier 分类决定终态:Tier 1 失败 → VERIFICATION_FAILED;Tier 2 失败但可降级 → VERIFIED_WITH_WARNING;全部通过 → VERIFIED
  4. audit trail 完整:所有验证结果写入 verify-report.json(结构化数据)和 final-report.md(人类可读),都存放在任务目录中,随 git commit 保留。
  5. 紧急部署的识别:通过 deploy_method 字段识别部署路径类型,从而决定是否跳过 commit 一致性检查。

11.5 Gate 的执行触发方式

VERIFIED Gate 由 Codex 在部署完成后主动触发,而非被动等待。触发的方式有两种:

Gate 脚本的运行位置:Gate 脚本始终从 repo 根目录运行。这是因为:

时间窗口:VERIFIED Gate 应在部署完成后 60 秒内启动。原因:rsync 完成后文件可能尚未被 nginx 的 sendfile 或操作系统缓存刷新(虽然概率极低)。60 秒的缓冲确保文件系统操作完全完成。如果超过了 60 秒仍未启动 Gate(如 Codex 被其他任务阻塞),应在 verify-report.json 中记录延迟时间。


常见问题(FAQ)

Q1: VERIFIED Gate 和 smoke test 有什么区别?

两者有五个本质区别:

  1. 维度数量:smoke test 通常 1-3 个检查(端口监听、健康检查端点返回 200、基本页面渲染),VERIFIED Gate 覆盖 12+ 个维度(HTTP 状态、canonical、hreflang、JSON-LD、安全头、sitemap、首页卡片、commit 一致性、元检查等)。
  2. 检查深度:smoke test 检查「服务活着吗?」——VERIFIED Gate 检查「内容对吗?」。前者的回答是布尔值(活着/死了),后者的回答是多维度的结构化数据(哪些维度 PASS、哪些 FAIL、证据是什么)。
  3. 内容感知:smoke test 不解析 HTML——它只看状态码和响应时间。VERIFIED Gate 解析 canonical、hreflang、JSON-LD、FAQ 结构,进行语义级别的验证。
  4. 跨页面一致性:smoke test 检查单个端点。VERIFIED Gate 检查页面间的交叉引用——hreflang 的双向性要求同时验证 zh 和 en 两个页面并交叉比对;sitemap URL 需要与在线 URL 一致。
  5. 结果语义:smoke test 的结果是「可部署」(可以上线了),VERIFIED Gate 的结果是「已发布且正确」(上线后验证通过)。一个是前置条件检查,一个是后置结果验证。

简单类比:smoke test 是「房子建好了,灯能亮吗?」,VERIFIED Gate 是「房子建好了,每个房间的门牌号对吗?厨房在正确的位置吗?消防通道的指示灯亮吗?」

Q2: 如果一个维度失败但其他维度全部通过,Gate 应该给什么结论?部分通过还是全部失败?

结论取决于失败维度的 Tier 级别,而非失败维度的数量:

  • Tier 1 维度(HTTP 200 / canonical / hreflang)中任意一个失败 → Gate 结论为 FAIL。即 11 个维度 PASS 但 1 个 Tier 1 维度 FAIL = Gate FAIL。原因:Tier 1 维度定义了「页面是否可以被搜索引擎正确索引」——如果 canonical 指向了错误的域名,即使其他所有维度完美,搜索引擎也不会正确索引该页面。发布等于没发布。
  • Tier 2 维度(JSON-LD / 安全头)失败但 Tier 1 全部 PASS → Gate 结论可为 VERIFIED_WITH_WARNING。即页面基本可访问且可索引,但在搜索富文本展示或安全评级上有缺陷。Gate 通过但附带警告,警告记录在 verify-report.json 中并设定修复期限。
  • Tier 3-4 维度失败 → Gate 结论为 VERIFIED(附注警告)。如 sitemap 中缺少 URL 或首页卡片未更新,不阻断发布,但在 verify-report.json 中记录,纳入下一轮部署的修复清单。

核心原则:Gate 不是简单多数投票(majority vote)。它采用「瓶颈判定」(bottleneck judgment)——最关键的维度(Tier 1)具有一票否决权。这个设计与航空安全中的「no-go item」概念一致——即使飞机的 99% 系统正常,只要一个关键系统(如发动机)故障,飞机就不能起飞。

Q3: 紧急部署(scp/rsync)在什么场景下会被允许?为什么结果只能是 VERIFIED_WITH_WARNING?

紧急部署的允许场景(需同时满足以下条件):

  1. 标准部署路径不可用:SSH 密钥问题导致 git pull 失败、GitHub 服务宕机、VPS 磁盘 I/O 故障导致 git 操作超时。
  2. 内容需要紧急上线:安全漏洞修复、关键信息更正(如错误的联系方式或价格)、时效性内容(如活动公告)。
  3. 用户显式批准:Agent 必须向用户报告标准部署失败的原因,提出紧急部署方案(具体方法、传输文件列表、风险说明),并等待用户明确说「同意」或「执行」。沉默不等于批准。

为什么结果只能是 VERIFIED_WITH_WARNING:

  • Git 仓库不一致:紧急部署绕过 git pull,VPS 上的仓库 HEAD 与 GitHub 上的 merge_commit 从此不同步。下次标准部署(git pull)时可能产生合并冲突,或覆盖掉紧急部署的手动修改。
  • Sitemap/首页可能不同步:紧急部署通常只传输单个文章文件,不触发 sitemap 重新生成和首页更新。这意味着新文章在线但搜索引擎发现延迟,且首页没有入口。
  • 审计轨迹不完整:标准部署通过 deploy-xslyl.sh 执行,该脚本会记录完整的部署日志(deploy-report.json)。紧急部署是手动操作,可能缺少部分审计信息。

WARNING 标记的含义:VERIFIED_WITH_WARNING 不是一个失败状态——它表示「内容已正确部署且在线可见,但部署过程未遵循标准路径,存在已知的技术债务需要在条件恢复后清偿」。具体地,运维人员需要在标准部署恢复后执行一次「同步部署」(git pull + deploy-full)来恢复仓库一致性。

Q4: 如果我的网站不在 xslyl.com,VERIFIED Gate 的检查项需要调整吗?

VERIFIED Gate 的核心框架(12 维度验证矩阵)是通用设计——不依赖 xslyl.com 的特定配置。但以下参数需要根据你的站点进行调整:

参数 xslyl.com 的值 你的站点需要修改为
域名 xslyl.com 你的生产域名(用于 canonical、hreflang、sitemap URL 比对)
语言维度 zh + en(双语) 单语言站点可移除 hreflang 检查;三语言以上需扩展交叉比对逻辑
JSON-LD 类型 Article + BreadcrumbList + FAQPage 如果站点不使用 FAQ,移除 FAQPage 检查并调整 Tier 分类
安全头期望值 5 个特定安全头(HSTS 等) 根据你的安全策略调整;如果使用 CSP,添加 Content-Security-Policy 检查
页面大小阈值 1KB(静态文章站) SPA 站点可降到 256 字节;多媒体重型站点可提高到 10KB
Web 服务器类型 nginx 如果使用 Apache/Caddy,调整错误页签名检测和部署完整性检查(git pull 逻辑)

架构不变的部分:12 个维度的验证思路(HTTP → SEO → 安全 → 发现性 → 完整性 → 元检查)、Tier 分级体系、fix forward vs rollback 决策框架——这些都是跨站点通用的设计模式,不依赖具体技术栈。

Q5: 每个检查项的执行频率是多少?每次部署都跑所有维度吗?

是的——每次部署都必须运行 VERIFIED Gate 的全部 12 个维度。

原因:

  1. 部署是「整体快照」的更新:即使你只修改了一篇文章的一个段落,部署过程(git pull + rsync)会刷新服务器上的整个仓库内容。任何部署步骤的失败都可能影响站点的任意部分——不是你修改的那篇文章,而是看似不相关的角落(如 sitemap 生成失败、nginx 配置被意外覆盖)。每次部署都需要全维度验证。
  2. 维度之间的依赖关系:一个维度的 PASS 不能推断其他维度的 PASS。HTTP 200 不保证 canonical 正确;canonical 正确不保证 hreflang 双向;hreflang 正确不保证安全头存在。每个维度验证的是部署的不同方面,必须独立验证。
  3. 「增量验证」的误区:理论上可以只验证「可能受影响」的维度——如只修改了文章内容就只验证 HTTP 和内容层,跳过高风险的 SEO 和安全头验证。但这个优化在 Agent 发布场景中是危险的——Agent 在生成内容时可能无意中破坏了 JSON-LD 块、修改了 canonical 标签、或影响了 hreflang 结构。你不知道 Agent 的修改的「爆炸半径」,所以唯一安全的选择是全维度验证。

性能考量:12 个维度的全量验证大约需要 10-15 秒(主要是 HTTP 请求的往返时间——sitemap、zh/en 页面、两个首页共约 5-6 个 HTTP 请求)。对于每次部署,这 15 秒的延迟是值得的——它用 15 秒换来了「内容正确」的确定性。相比之下,发现一个 SEO 错误并修复所需的编排时间通常以小时计。

可选优化(不建议启用):如果你确实需要在性能与覆盖面之间做权衡,可以将 Tier 3-4 维度的验证降级为异步——即先通过 Tier 1-2 维度验证后允许发布,然后在后台异步运行 Tier 3-4 维度(sitemap、首页卡片),如果发现问题则追加警告。但这个优化的收益很小(节省约 2-3 秒),却增加了流程的复杂性和状态管理的难度。对于 xslyl.com 的规模,不推荐做这个优化。

Q6: 如果 GitHub 和 VPS 的 commit SHA 不一致但线上内容是正确的,问题出在哪?

这是一个典型的「正确的假象」场景。虽然线上内容恰好正确,但 commit SHA 不一致意味着部署管道中存在一个尚未暴露的故障点。常见的根因:

  1. git pull 部分失败:网络中断导致 git pull 只拉取了部分对象。恰好拉取到的部分包含了当前文章的正确文件,但 HEAD 停留在旧 commit。下次部署时,这个故障点可能表现为「部署了旧版本」。
  2. VPS 上的 git 仓库处于 detached HEAD 状态:某次手动操作(如 git checkout <commit>)使仓库进入了 detached HEAD,但 deploy 脚本仍然从这个状态部署了文件。内容碰巧是正确的(因为 deploy 脚本使用了工作目录中的正确文件),但仓库状态与 GitHub 不同步。
  3. deploy 脚本从错误的源目录部署:deploy-xslyl.sh 可能从另一个源目录(如 /tmp/staging/)而非 git 工作目录(/var/repo/)复制文件。这个源目录的内容碰巧是正确的(因为之前的某次手动 scp),但 git 仓库本身是旧的。
  4. 使用了紧急部署但未正确记录:之前的部署使用了 scp 紧急传输文件,绕过了 git pull。内容是正确的,但 deploy_method 标记错误(仍然写着 "standard" 而非 "emergency_scp"),导致 Gate 期望 commit 一致但实际上不一致。

为什么这仍然是一个问题——即使线上内容正确:

  • 下一次部署的风险:如果故障点未被修复,下一次标准部署(git pull)可能在尝试同步时产生合并冲突,或覆盖掉手动部署的修复。
  • 回滚能力丧失:如果无法确定 VPS 上当前运行的是哪个版本,就无法执行精确回滚——你不知道回退到哪个 commit 才是安全的。
  • 审计链断裂:当线上出现问题时,你无法通过 git log 追溯变更历史——因为线上的版本不在任何 commit 中。

修复方法:不要满足于「线上内容恰好正确」。必须:

  1. 定位 commit 不一致的根因(git pull 日志、deploy 脚本输出、手动操作历史)。
  2. 修复根因(重新配置 git remote、修复 deploy 脚本的源目录引用、清理 detached HEAD 状态)。
  3. 执行一次强制同步git fetch origin && git reset --hard origin/main(在 VPS 上),然后重新部署。这会丢弃 VPS 上的本地差异,与 GitHub 完全对齐。
  4. 重新运行 VERIFIED Gate 确认 commit 一致性恢复。

下一步阅读

VERIFIED Gate 是 Agent 发布管道 8 层 Gate 体系的最后一层。以下是理解整个体系所需的关联文章: