console-ui:把「線上跑的是不是當代的」變成機械判準,並接進部署鏈

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>
This commit is contained in:
uncle6me-web
2026-08-08 20:36:18 +08:00
parent e730b3f831
commit 42cb1d7aa9
11 changed files with 531 additions and 81 deletions
+203 -2
View File
@@ -5,19 +5,28 @@
* 「部署時印在終端機的值」「寫進產物的值」「事後驗線上的值」若各自去讀、各自算,
* 三者就會漂移——2026-08-08 那場事故的形狀正是「印的是 A、推的是 B」。
* 這支把「一個目標展開成期望的產物長相」定死成一個函式,三邊共用同一個答案。
*
* 🔴 2026-08-08 第二層(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,
* 你要確定不可再犯」):組態對 ≠ 世代對。
* 一個網址可以 apiBase/profile 全部正確,卻對外展示一套早就被淘汰的介面,
* 而所有只驗組態的檢查都說它綠。故本檔另外定義「世代指紋」(見下半段):
* 把「線上這一份是不是當代的」變成一個可機械比對的值。
*/
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export const PUBLIC_DIR = join(ROOT, 'public');
export function loadTargets() {
const raw = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
const profiles = raw._profiles;
if (!profiles) throw new Error('deploy.targets.json 缺 _profilesprofile → views/home 對照)');
const names = Object.keys(raw).filter((k) => !k.startsWith('_'));
return { raw, profiles, names };
const active = names.filter((n) => !raw[n].frozen);
return { raw, profiles, names, active };
}
export function resolveTarget(name) {
@@ -28,6 +37,9 @@ export function resolveTarget(name) {
err.usage = true;
throw err;
}
// 凍結目標:連讀都不准碰(frozen.reason 說明是誰、何時、為什麼)。
// 這不是「壞掉所以跳過」,是「這個帳號的資源不歸我們動」——工具自己守,不靠人記得。
if (t.frozen) return { name, ...t, frozen: t.frozen, views: profiles[t.profile]?.views, home: profiles[t.profile]?.home };
const p = profiles[t.profile];
if (!p) {
throw new Error(
@@ -66,3 +78,192 @@ export function parseApiBase(text) {
const m = text.match(/apiBase\s*:\s*"([^"]*)"/);
return m ? m[1] : null;
}
// ─────────────────────────────────────────────────────────────────────────────
// 世代指紋(2026-08-08 第二層)
//
// 問題:verify-live 原本只驗組態(apiBase / VIEWS / HOME)。實測當天三個對外網址
// 這三項全綠,但線上跑的是 2026-07-22 那一代的 portal82,911 bytes、
// 金色 serif「Arcrun」品牌、Songti 12 處),repo 是 343,969 bytes 的
// 「arc >> run」新代——**組態全對、介面整整落後半個月,機械檢查一片綠**。
//
// 判準:「線上這一份,是不是我們手上這一份?」不加解釋、不留模糊地帶——
// 逐一抓下線上資產、遮掉「本來就該隨部署目標不同」的那幾行,其餘按位元組比對。
//
// 為什麼是位元組而不是「找幾個關鍵字」:
// 關鍵字清單要人維護,而人只會在「這次剛好想到」時更新它。舊世代之所以能無聲上線,
// 正是因為沒有人記得去更新那張清單。位元組比對不需要任何人記得任何事:
// repo 改了一個字,指紋就不同,線上沒跟上就是 ❌。
//
// 誠實的 trade-offmindset §7,不假裝完美):
// ① 只要 repo 動過而還沒部署,這個檢查就會說「線上落後」——那是**正確的**,
// 因為那時線上確實不是當代的。它會吵,但吵的是真的。
// ② 若哪天 CF 邊緣開始改寫 HTMLRocket Loader 之類),會出現假 ❌。
// 2026-08-08 實測 mira.uncle6.me 與 pages.dev 回傳位元組完全相同(sha 一致),
// 證明目前沒有改寫。真出現時它會大聲壞掉、有人來查——
// **假 ❌ 的代價遠低於假 ✅**(假 ✅ 就是這次事故本身)。
// ─────────────────────────────────────────────────────────────────────────────
/** 納入世代指紋的資產:filepublic/ 底下的路徑,urlPath=線上要抓的位址。 */
export const GENERATION_ASSETS = [
{ file: 'index.html', urlPath: '/' },
{ file: 'portal/index.html', urlPath: '/portal/' },
{ file: 'console/index.html', urlPath: '/console/' },
{ file: 'favicon.svg', urlPath: '/favicon.svg' },
];
/**
* 「本來就該隨部署目標不同」的行——比世代時遮掉,否則個人版與企業版永遠指紋不同。
* 遮的只有這兩行;其餘全部按原樣比對。
* config.js 整支不納入世代(它是純產物,由 apiBase 那一項單獨驗)。
*/
const TARGET_DEPENDENT_LINES = [
{ file: 'console/index.html', re: /^[ \t]*var VIEWS = .*$/m, tag: '«VIEWS:由部署目標決定»' },
{ file: 'console/index.html', re: /^[ \t]*var HOME = .*$/m, tag: '«HOME:由部署目標決定»' },
];
/** 遮掉目標相依的行。抓不到就原樣回傳(線上是舊世代時本來就可能沒有那幾行 → 該判 ❌)。 */
export function maskTargetValues(file, bytes) {
const rules = TARGET_DEPENDENT_LINES.filter((r) => r.file === file);
if (!rules.length) return bytes;
let text = Buffer.from(bytes).toString('utf8');
for (const r of rules) text = text.replace(r.re, r.tag);
return Buffer.from(text, 'utf8');
}
export function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
/**
* 由「檔名 → 位元組(抓不到給 null)」算出世代指紋。
* @param {Array<{file:string, bytes:Buffer|null}>} entries
*/
export function fingerprintOf(entries) {
const assets = {};
const lines = [];
for (const { file, bytes } of entries) {
if (bytes == null) {
assets[file] = { sha: null, size: null, missing: true };
lines.push(`${file}\tMISSING`);
continue;
}
const masked = maskTargetValues(file, bytes);
const sha = sha256(masked);
assets[file] = { sha, size: Buffer.from(bytes).length, missing: false };
lines.push(`${file}\t${sha}`);
}
return { assets, digest: sha256(Buffer.from(lines.join('\n'), 'utf8')) };
}
/** repo(或某個產物目錄)現在這一代長什麼樣。這就是「當代」的定義。 */
export function generationOfDir(dir = PUBLIC_DIR) {
return fingerprintOf(
GENERATION_ASSETS.map(({ file }) => {
let bytes = null;
try {
bytes = readFileSync(join(dir, file));
} catch {
bytes = null;
}
return { file, bytes };
}),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 產物:把宣告值真的寫進去(e730b3f 標的 WIP,本次收掉)
// ─────────────────────────────────────────────────────────────────────────────
/**
* 依目標把 public/ 展開成「要推上去的那一份」。
* 🔴 覆寫沒命中就中止——宣告了卻沒寫進產物,正是這串事故的根。
*/
export function buildArtifact(t, outDir) {
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
cpSync(PUBLIC_DIR, outDir, { recursive: true });
const exp = expected(t);
// ① config.js:產物,不是原始碼(public/ 裡不留)
writeFileSync(join(outDir, 'config.js'), exp.configJs, 'utf8');
// ② console 的 VIEWS/HOMEpublic/ 裡那兩行只是本機 preview 的預設值
const consolePath = join(outDir, 'console', 'index.html');
let html = readFileSync(consolePath, 'utf8');
for (const [re, line, what] of [
[/^[ \t]*var VIEWS = .*$/m, exp.viewsLine, 'VIEWS'],
[/^[ \t]*var HOME = .*$/m, exp.homeLine, 'HOME'],
]) {
if (!re.test(html)) {
throw new Error(
`產物覆寫沒命中:console/index.html 找不到 ${what} 那一行 ⇒ 中止部署。\n` +
'(前端改版把那行換了寫法時會發生。宣告值寫不進去就不准推——這正是 2026-08-08 事故的形狀。)',
);
}
html = html.replace(re, line);
}
writeFileSync(consolePath, html, 'utf8');
return outDir;
}
/**
* 產物閘:推之前,回頭讀「真的要被推上去的那些檔案」,確認=宣告值。
* 不看 deploy.mjs 自己印了什麼——只看磁碟上那份。
*/
export function assertArtifact(t, outDir) {
const exp = expected(t);
const problems = [];
const cfg = readFileSync(join(outDir, 'config.js'), 'utf8');
const gotApiBase = parseApiBase(cfg);
if (gotApiBase !== t.apiBase) problems.push(`config.js 的 apiBase:宣告 ${t.apiBase},產物 ${gotApiBase}`);
const html = readFileSync(join(outDir, 'console', 'index.html'), 'utf8');
const gotViews = html.match(/^[ \t]*var VIEWS = .*$/m)?.[0];
const gotHome = html.match(/^[ \t]*var HOME = .*$/m)?.[0];
if (gotViews !== exp.viewsLine) problems.push(`console VIEWS:宣告 ${exp.viewsLine.trim()},產物 ${gotViews?.trim()}`);
if (gotHome !== exp.homeLine) problems.push(`console HOME:宣告 ${exp.homeLine.trim()},產物 ${gotHome?.trim()}`);
// 世代閘(產物側):注入不得改動世代相關位元組
const src = generationOfDir(PUBLIC_DIR);
const art = generationOfDir(outDir);
if (src.digest !== art.digest) {
problems.push(`產物世代指紋 ${art.digest.slice(0, 12)} ≠ public/ 的 ${src.digest.slice(0, 12)}(注入改到了不該改的位元組)`);
}
// 世代閘(內容側,沿用 t160 的文字指紋——擋「整份 public 被換成舊代」)
//
// 🔴 只看「使用者看得到的內容」,比對前先剝掉 HTML 註解。
// 2026-08-08 實撞:原版直接對全文比對「登記新庫」,而 66f1b5908-03)在 portal 裡
// 加了一則**說明「已經把登記新庫拿掉了」的註解** ⇒ 這道閘從那天起每次都誤判,
// `npm run deploy:personal` 連續五天推不出去、而錯誤訊息說的是「你的 UI 是舊代」。
// ⇒ 手工維護的關鍵字清單會腐爛,這就是實例;世代的主判準因此改用位元組指紋,
// 這道文字閘只留來擋「整份 public 被換成舊代」,且必須剝註解才不會自傷。
const portalRaw = readFileSync(join(outDir, 'portal', 'index.html'), 'utf8');
const portal = portalRaw.replace(/<!--[\s\S]*?-->/g, '');
if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) {
problems.push('portal/index.html 不是現行世代(可見內容缺「不需要人工新增」或仍有「登記新庫」)');
}
return { ok: problems.length === 0, problems, generation: art.digest };
}
/** 部署狀態記錄檔(只在「線上實測通過」之後才寫,見 deploy.mjs)。 */
export const STATE_FILE = join(ROOT, '.deploy-state.json');
export function readState() {
try {
return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
} catch {
return {};
}
}
export function writeState(name, record) {
const state = readState();
state[name] = record;
writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}