42cb1d7aa9
leo 2026-08-08:「已經發生過一次這個錯誤,把舊版界面上到 prod, 你要確定不可再犯。」 實測(repo 對線上,純文字資產比對): repo portal/index.html 343,969 bytes 線上 mira.uncle6.me 82,911 bytes(Songti 12 處,舊金色 serif 品牌) 線上 pages.dev 82,911 bytes(同上) 而e730b3f那版 verify-live 對這兩站**三項檢查全過**——因為它驗的是 組態(apiBase、profile 的 views/home),不是世代。 ⇒ 一個網址可以組態完全正確、卻對外展示一套早就被淘汰的介面, 而所有機械檢查都說它是綠的。這就是要消滅的狀態。 本次落地: 一、世代指紋(targets.mjs) 逐一取線上/產物的資產(index / portal / console / favicon.svg), 遮掉本來就該隨部署目標不同的那兩行(VIEWS/HOME),其餘按位元組比對。 刻意不用關鍵字清單——清單要人維護,而舊世代能無聲上線正是因為沒人記得維護它。 誠實 trade-off 寫在檔內:repo 改了沒部署就會判紅,那是正確的(那時線上確實不當代)。 二、宣告值真的寫進產物(收掉e730b3f標的 WIP) deploy.mjs 改為由 targets.mjs 產出 .staging/<目標> 再推: config.js 由宣告值即時產生、console 的 VIEWS/HOME 依 profile 覆寫, **覆寫沒命中就中止部署**;推之前回頭讀磁碟上那份驗一次(不看腳本印了什麼)。 public/config.js 刪除——它是產物不是原始碼。 三、修好一道從 08-03 起就在誤判的閘 t160 的世代閘比對 portal 全文含「登記新庫」即拒部,而 66f1b59(08-03) 加了一則**說明「已經把它拿掉了」的 HTML 註解** ⇒ 該閘自那天起每次誤判, npm run deploy:personal 連續五天推不出去。改成剝掉註解後只看可見內容, 並降級為輔助(主判準是指紋)。這正是「手工關鍵字閘會腐爛」的實例。 四、讓它在該跑的時候真的被跑到(不再生出沒人記得執行的腳本) · deploy.mjs 推完自動回頭驗線上,不過就算本次部署失敗 · .deploy-state.json 只在線上實測通過後才寫,且不進版控 (新 checkout 沒紀錄=狀態未知=該被提醒,而不是繼承別人的綠燈) · Stop hook 每回合離線比對「手上這一代 vs 最後一次驗過的部署」, 在要說「做完了」的那一刻出聲(實測 0.096s,不連網) 五、uncle6 邊界寫進工具本身(leo 08-08:「要看範例只在 youlin 網站,不要去碰 uncle6」) deploy.targets.json 的 enterprise 標 frozen:deploy 拒絕部署、verify 連抓都不抓。 目標本身保留不刪——刪掉就變成下一個 AI 眼中「從來沒有過這個站」的失憶。 同源清掉兩處還活著的舊記錄:README 的線上 demo 連結、public/index.html 的註解。 驗收證據見 commit 後的實測輸出(舊世代樣本取自 git 歷史 ad367e4,本機起站餵判準, 未碰任何線上資源)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
221 lines
9.3 KiB
JavaScript
221 lines
9.3 KiB
JavaScript
/**
|
||
* verify-live.mjs — 驗「線上網址現在真的在跑的那一份」=「我們手上這一份」。
|
||
*
|
||
* 用法:
|
||
* node scripts/verify-live.mjs 驗全部服役中目標的全部對外網址
|
||
* node scripts/verify-live.mjs personal 只驗某個目標
|
||
* node scripts/verify-live.mjs --wait 容忍 CF Pages 生效延遲(重試)
|
||
* node scripts/verify-live.mjs --url <網址> 只對某個網址驗世代(不需要是宣告目標)
|
||
* npm run verify
|
||
*
|
||
* 兩層,缺一不可:
|
||
* ① 組態層:apiBase/profile 的 views/home = deploy.targets.json 宣告值
|
||
* ② 世代層:線上資產的位元組指紋 = repo public/ 的指紋
|
||
*
|
||
* 為什麼要第二層(2026-08-08,leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,
|
||
* 你要確定不可再犯」):當天實測三個對外網址,第一層**三項全過**,
|
||
* 而它們跑的是 07-22 那一代的 portal(82,911 bytes、金色 serif 舊品牌),
|
||
* repo 是 343,969 bytes 的新品牌世代。
|
||
* ⇒ **組態可以完全正確,同時展示一套早就被淘汰的介面,而機械檢查一片綠。**
|
||
* 第二層就是為了讓這個狀態不可能無聲存在。
|
||
*
|
||
* 🔴 一律帶 no-cache(快取害人誤判過)。curl|grep 不算驗前端,但 config.js/VIEWS/HOME
|
||
* 與世代指紋都是**純文字資產比對**,抓原始碼比對是這幾項的正確驗法;
|
||
* 「頁面真的能用」另外走瀏覽器實載。
|
||
* 🔴 frozen 目標(見 deploy.targets.json)連抓都不抓——不是我們的帳號,不碰。
|
||
*/
|
||
import {
|
||
GENERATION_ASSETS,
|
||
fingerprintOf,
|
||
generationOfDir,
|
||
loadTargets,
|
||
parseApiBase,
|
||
readState,
|
||
resolveTarget,
|
||
} from './targets.mjs';
|
||
|
||
const NOCACHE = { 'Cache-Control': 'no-cache', Pragma: 'no-cache' };
|
||
|
||
async function get(url) {
|
||
const res = await fetch(`${url}${url.includes('?') ? '&' : '?'}_nc=${Date.now()}`, {
|
||
headers: NOCACHE,
|
||
cache: 'no-store',
|
||
redirect: 'follow',
|
||
});
|
||
const buf = Buffer.from(await res.arrayBuffer());
|
||
return { status: res.status, bytes: buf, text: buf.toString('utf8') };
|
||
}
|
||
|
||
/** 抓線上的世代資產,算指紋。抓不到的當 MISSING(照樣算,缺檔本來就是另一代)。 */
|
||
async function liveGeneration(base) {
|
||
const entries = [];
|
||
const detail = {};
|
||
for (const { file, urlPath } of GENERATION_ASSETS) {
|
||
try {
|
||
const r = await get(`${base.replace(/\/$/, '')}${urlPath}`);
|
||
const ok = r.status === 200;
|
||
entries.push({ file, bytes: ok ? r.bytes : null });
|
||
detail[file] = { status: r.status, text: ok ? r.text : null };
|
||
} catch (e) {
|
||
entries.push({ file, bytes: null });
|
||
detail[file] = { status: `連線失敗:${e.message}`, text: null };
|
||
}
|
||
}
|
||
return { ...fingerprintOf(entries), detail };
|
||
}
|
||
|
||
/** 驗一個網址。t 給 null=只驗世代(ad-hoc 模式)。 */
|
||
export async function verifyUrl(t, url, want) {
|
||
const checks = [];
|
||
const base = url.replace(/\/$/, '');
|
||
const live = await liveGeneration(base);
|
||
|
||
// ── 世代層 ──────────────────────────────────────────────
|
||
const genOk = live.digest === want.digest;
|
||
const diffs = Object.entries(want.assets)
|
||
.filter(([f, a]) => live.assets[f]?.sha !== a.sha)
|
||
.map(([f, a]) => {
|
||
const l = live.assets[f] ?? {};
|
||
const st = live.detail[f]?.status;
|
||
return `${f}:repo ${a.size ?? '缺'} bytes / 線上 ${l.missing ? `抓不到(${st})` : `${l.size} bytes`}`;
|
||
});
|
||
checks.push({
|
||
name: '世代',
|
||
ok: genOk,
|
||
want: `${want.digest.slice(0, 12)}(repo public/)`,
|
||
got: genOk
|
||
? `${live.digest.slice(0, 12)}`
|
||
: `${live.digest.slice(0, 12)}\n 不同的資產:\n ${diffs.join('\n ')}`,
|
||
});
|
||
|
||
if (!t) return { url, ok: genOk, checks };
|
||
|
||
// ── 組態層 ──────────────────────────────────────────────
|
||
try {
|
||
const cfg = await get(`${base}/config.js`);
|
||
const got = cfg.status === 200 ? parseApiBase(cfg.text) : `HTTP ${cfg.status}`;
|
||
checks.push({ name: 'apiBase', ok: got === t.apiBase, want: t.apiBase, got: got ?? '(config.js 裡找不到 apiBase)' });
|
||
} catch (e) {
|
||
checks.push({ name: 'apiBase', ok: false, want: t.apiBase, got: `連線失敗:${e.message}` });
|
||
}
|
||
|
||
const con = live.detail['console/index.html'];
|
||
const conText = con?.text;
|
||
const views = conText?.match(/var VIEWS = (\[[^\]]*\]);/);
|
||
const home = conText?.match(/var HOME = "([^"]*)";/);
|
||
const gotViews = conText ? (views ? views[1] : '(找不到 VIEWS)') : `HTTP ${con?.status}`;
|
||
const gotHome = conText ? (home ? home[1] : '(找不到 HOME)') : `HTTP ${con?.status}`;
|
||
checks.push({
|
||
name: `profile(${t.profile}).views`,
|
||
ok: gotViews === JSON.stringify(t.views),
|
||
want: JSON.stringify(t.views),
|
||
got: gotViews,
|
||
});
|
||
checks.push({ name: `profile(${t.profile}).home`, ok: gotHome === t.home, want: t.home, got: gotHome });
|
||
|
||
return { url, ok: checks.every((c) => c.ok), checks };
|
||
}
|
||
|
||
export async function verifyTarget(name, { wait = false } = {}) {
|
||
const t = resolveTarget(name);
|
||
if (t.frozen) return { name, target: t, skipped: true, ok: true, results: [] };
|
||
const want = generationOfDir();
|
||
const attempts = wait ? 8 : 1;
|
||
let results = [];
|
||
for (let i = 1; i <= attempts; i++) {
|
||
results = [];
|
||
for (const url of t.verifyUrls) results.push(await verifyUrl(t, url, want));
|
||
if (results.every((r) => r.ok) || i === attempts) break;
|
||
process.stdout.write(` … 尚未生效,5s 後重試(${i}/${attempts - 1})\n`);
|
||
await new Promise((r) => setTimeout(r, 5000));
|
||
}
|
||
return { name, target: t, ok: results.every((r) => r.ok), results };
|
||
}
|
||
|
||
export function printReport(reports) {
|
||
for (const r of reports) {
|
||
console.log(`\n【${r.name}】${r.target.description}`);
|
||
if (r.skipped) {
|
||
console.log(` ⏸️ 已凍結,不抓不驗:${r.target.frozen}`);
|
||
continue;
|
||
}
|
||
console.log(` 宣告:profile=${r.target.profile} apiBase=${r.target.apiBase}`);
|
||
for (const u of r.results) {
|
||
console.log(` ${u.ok ? '✅' : '❌'} ${u.url}`);
|
||
for (const c of u.checks) {
|
||
if (c.ok) console.log(` ✓ ${c.name} = ${c.got}`);
|
||
else console.log(` ✗ ${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function verifyAll(names, opts) {
|
||
const reports = [];
|
||
for (const n of names) reports.push(await verifyTarget(n, opts));
|
||
return reports;
|
||
}
|
||
|
||
const isCli = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
||
if (isCli) {
|
||
const args = process.argv.slice(2);
|
||
const wait = args.includes('--wait');
|
||
const urlIdx = args.indexOf('--url');
|
||
|
||
if (args.includes('--offline-lag')) {
|
||
// 不連網,只問一句:「我手上這一代,有沒有真的送出去過?」
|
||
// 給 Stop hook 用(每回合都跑,所以不准連網、不准慢)。
|
||
// 唯一的事實來源是 .deploy-state.json,而它**只在線上實測通過後**才被寫(見 deploy.mjs)
|
||
// ⇒ 它說綠就是真的有人驗過線上,不是「我跑過部署指令」。
|
||
const here = generationOfDir().digest;
|
||
const state = readState();
|
||
const stale = [];
|
||
for (const n of loadTargets().active) {
|
||
const s = state[n];
|
||
if (!s) stale.push(`${n}:沒有任何一次通過線上實測的部署紀錄(線上是哪一代,現在沒人知道)`);
|
||
else if (s.generation !== here) {
|
||
stale.push(`${n}:最後一次驗過的是 ${s.generation.slice(0, 12)}(${s.verifiedAt.slice(0, 10)}),現在手上是 ${here.slice(0, 12)}`);
|
||
}
|
||
}
|
||
if (stale.length) {
|
||
console.log(stale.join('\n'));
|
||
process.exit(1);
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
if (urlIdx !== -1) {
|
||
// ad-hoc:只問「這個網址上跑的是不是當代的」——不需要它是宣告過的目標。
|
||
const url = args[urlIdx + 1];
|
||
if (!url) {
|
||
console.error('用法:node scripts/verify-live.mjs --url <網址>');
|
||
process.exit(2);
|
||
}
|
||
const want = generationOfDir();
|
||
const r = await verifyUrl(null, url, want);
|
||
console.log(`\n【世代檢查】${url}`);
|
||
for (const c of r.checks) {
|
||
if (c.ok) console.log(` ✅ ${c.name} = ${c.got}`);
|
||
else console.log(` ❌ ${c.name}\n 我們手上:${c.want}\n 線上跑的:${c.got}`);
|
||
}
|
||
if (!r.ok) {
|
||
console.error('\n❌ 這個網址上跑的不是當代的前端——它展示的是一套已經被淘汰的介面。');
|
||
process.exit(1);
|
||
}
|
||
console.log('\n✅ 這個網址上跑的=我們手上這一份。');
|
||
process.exit(0);
|
||
}
|
||
|
||
const picked = args.filter((a) => !a.startsWith('--'));
|
||
const names = picked.length ? picked : loadTargets().names;
|
||
const reports = await verifyAll(names, { wait });
|
||
printReport(reports);
|
||
const bad = reports.filter((r) => !r.ok);
|
||
if (bad.length) {
|
||
console.error(`\n❌ ${bad.length} 個目標與宣告/當代不符:${bad.map((b) => b.name).join('、')}`);
|
||
console.error(' (線上實際在跑的 ≠ 我們手上這一份——這正是要被擋掉的那個病)');
|
||
process.exit(1);
|
||
}
|
||
console.log('\n✅ 所有服役中目標:線上組態=宣告值,線上世代=repo 當代。');
|
||
}
|