#!/usr/bin/env node /** * verify-deploy-consistency.mjs — 发布快照与本地工作树的一致性核对 * * 用法: * node tools/verify-deploy-consistency.mjs # 默认对公网 https://kole-ui.mymoyu.top * node tools/verify-deploy-consistency.mjs --base=http://127.0.0.1:3311 * node tools/verify-deploy-consistency.mjs --only=site/playground # 只核对路径含该子串的文件 * 退出码:0 一致 / 1 有滞后或缺失 / 2 环境问题(连不上 / 目录缺失) * * 为什么需要它:AGENTS §九 要求"打包前后各查一次产物哈希",但那是**手工步骤** —— 实测踩过: * 2026-09-20 的一次发布里,快照与工作树在 6 个文件上不一致(`site/app.js` / `site/style.css` / * `site/dev-server.js` / `site/versions.json` / `tests/_runtime.js` / 生成物 `report.json`), * 其中 `style.css` 的差异直接在线上表现为**缺陷**:375px 下语言切换器被挤出顶栏(实测宽度 0)。 * 逐字节比对只能靠脚本,凭"刚发布过"的印象一定会漏。 * * 判据:按目录递归枚举工作树里**会随发布出去**的文件(与 pack-deploy 的发布集一致), * 逐个拉远端同路径比对 sha256。生成物(tests/report*.json|xml、site/data.js)单独计数, * 差异只提示不算失败 —— 它们本来就随每次运行变化。 * * 注意:比对是**大小写敏感**的(远端是 Linux 容器)。本地 Windows 上写错大小写的路径 * 在本地 dev-server 能过、到线上就是 404 —— 那类问题由这里暴露,不要"顺手"改成不敏感。 */ import { readFileSync, existsSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { collectPublishSet, NOT_WEB_SERVED } from './lib/publish-set.mjs'; const args = process.argv.slice(2); const arg = (name) => { const hit = args.find((a) => a.startsWith(`--${name}=`)); return hit ? hit.slice(name.length + 3) : ''; }; const BASE = (arg('base') || process.env.DEPLOY_BASE || 'https://kole-ui.mymoyu.top').replace(/\/+$/, ''); const ONLY = arg('only'); const CONC = Number(arg('conc') || process.env.DEPLOY_CONC) || 8; const ROOT = process.cwd(); /* 生成物:本来就随每次运行变化(回归报告、precompute 后的 data.js),差异只提示不算失败 */ const GENERATED = /^(tests\/report(-junit)?\.(json|xml)|site\/data\.js)$/; /* 发布集直接复用打包器那份实现(tools/lib/publish-set.mjs,规则真源是 .dockerignore)。 再剔掉不经 HTTP 暴露的构建/运维配置(Dockerfile / nginx.conf / index.html …)—— 它们在镜像里但不在 web 根下,没法用 URL 比对。 */ let targets; try { targets = collectPublishSet(ROOT).kept.filter((f) => !NOT_WEB_SERVED.includes(f)); } catch (e) { console.error(`[deploy] 环境问题:${e.message}(请在仓库根目录运行)`); process.exit(2); } if (ONLY) targets = targets.filter((t) => t.includes(ONLY)); if (!targets.length) { console.error(`[deploy] --only=${ONLY} 没匹配到任何文件`); process.exit(2); } const sha = (buf) => createHash('sha256').update(buf).digest('hex'); const hashOf = new Map(targets.filter((t) => existsSync(t)).map((t) => [t, sha(readFileSync(t))])); /* 先确认 BASE 真的是这个站 */ try { const probe = await fetch(`${BASE}/site/data.json`, { signal: AbortSignal.timeout(30000) }); if (!probe.ok) throw new Error(`HTTP ${probe.status}`); } catch (e) { console.error(`[deploy] 连不上 ${BASE}/site/data.json — ${e.message}`); process.exit(2); } const lagged = []; const missing = []; const generatedDiff = []; let same = 0; /* nginx 会**故意**把这些路径 302 到别处(不是文件内容不一致): - `//` 与 `//index.html` → 该版站点(nginx.conf 的版本裸入口规则) - 根 `/index.html` → `/site/` 这类路径取到的字节是 nginx 的 302 响应体,与磁盘文件天然不同 —— 不排除就会报假滞后 (2026-09-20 实测:`1.4.1/index.html` 被当成「线上滞后」,而线上该 URL 本就是 302)。 */ const NGINX_REDIRECTS = [/^index\.html$/, /^\d+\.\d+\.\d+\/index\.html$/]; let skipped = 0; const queue = [...targets].filter((rel) => { if (NGINX_REDIRECTS.some((re) => re.test(rel))) { skipped++; return false; } return true; }); async function worker() { while (queue.length) { const rel = queue.shift(); let res; try { res = await fetch(`${BASE}/${encodeURI(rel)}`, { signal: AbortSignal.timeout(60000) }); } catch (e) { lagged.push(`${rel}(请求失败 ${e.message})`); continue; } if (!res.ok) { missing.push(`${rel} → HTTP ${res.status}`); continue; } const live = sha(Buffer.from(await res.arrayBuffer())); if (live === hashOf.get(rel)) { same++; continue; } if (GENERATED.test(rel)) generatedDiff.push(rel); else lagged.push(rel); } } await Promise.all(Array.from({ length: CONC }, worker)); console.log(`[deploy] ${BASE}`); console.log(`[deploy] 核对 ${targets.length - skipped} 个文件(并发 ${CONC},另跳过 ${skipped} 个 nginx 重定向路径):一致 ${same} / 滞后 ${lagged.length} / 线上缺失 ${missing.length}`); if (generatedDiff.length) console.log(`[deploy] 生成物差异(随运行变化,不计失败):${generatedDiff.join(', ')}`); for (const l of lagged) console.log(`[deploy] 滞后: ${l}`); for (const m of missing) console.log(`[deploy] 缺失: ${m}`); const bad = lagged.length + missing.length; console.log(`\n[deploy] ${bad === 0 ? 'OK — 快照与工作树一致' : `FAIL — ${bad} 个文件不一致(线上快照落后于工作树,或工作树在打包后被改写)`}`); process.exit(bad === 0 ? 0 : 1);