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>
270 lines
13 KiB
JavaScript
270 lines
13 KiB
JavaScript
/**
|
||
* targets.mjs — 部署目標的唯一讀取點(deploy.mjs 與 verify-live.mjs 共用)。
|
||
*
|
||
* 存在的理由:宣告值(deploy.targets.json)只准被解讀一次。
|
||
* 「部署時印在終端機的值」「寫進產物的值」「事後驗線上的值」若各自去讀、各自算,
|
||
* 三者就會漂移——2026-08-08 那場事故的形狀正是「印的是 A、推的是 B」。
|
||
* 這支把「一個目標展開成期望的產物長相」定死成一個函式,三邊共用同一個答案。
|
||
*
|
||
* 🔴 2026-08-08 第二層(leo:「已經發生過一次這個錯誤,把舊版界面上到 prod,
|
||
* 你要確定不可再犯」):組態對 ≠ 世代對。
|
||
* 一個網址可以 apiBase/profile 全部正確,卻對外展示一套早就被淘汰的介面,
|
||
* 而所有只驗組態的檢查都說它綠。故本檔另外定義「世代指紋」(見下半段):
|
||
* 把「線上這一份是不是當代的」變成一個可機械比對的值。
|
||
*/
|
||
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 缺 _profiles(profile → views/home 對照)');
|
||
const names = Object.keys(raw).filter((k) => !k.startsWith('_'));
|
||
const active = names.filter((n) => !raw[n].frozen);
|
||
return { raw, profiles, names, active };
|
||
}
|
||
|
||
export function resolveTarget(name) {
|
||
const { raw, profiles, names } = loadTargets();
|
||
const t = raw[name];
|
||
if (!t) {
|
||
const err = new Error(`未知的部署目標:"${name}"。可用:${names.join(' / ')}`);
|
||
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(
|
||
`目標 ${name} 的 profile="${t.profile}" 在 _profiles 裡沒有定義(可用:${Object.keys(profiles).join(' / ')})。` +
|
||
'\n宣告了一個沒人知道怎麼落地的 profile ⇒ 拒絕部署,不要猜。',
|
||
);
|
||
}
|
||
if (!t.apiBase) throw new Error(`目標 ${name} 沒有 apiBase——空值會讓前端安靜地連不上,拒絕部署。`);
|
||
if (!t.accountId) throw new Error(`目標 ${name} 沒有 accountId——不指定帳號可能部到別人的站上,拒絕部署。`);
|
||
if (!Array.isArray(t.verifyUrls) || t.verifyUrls.length === 0) {
|
||
throw new Error(`目標 ${name} 沒有 verifyUrls——沒有對外網址就無法驗「站上跑的=宣告的」,拒絕部署。`);
|
||
}
|
||
return { name, ...t, views: p.views, home: p.home };
|
||
}
|
||
|
||
/** 這個目標「應該長成什麼樣」——產物閘與線上閘都比對這一份。 */
|
||
export function expected(t) {
|
||
return {
|
||
configJs: configJsFor(t),
|
||
apiBase: t.apiBase,
|
||
viewsLine: ` var VIEWS = ${JSON.stringify(t.views)};`,
|
||
homeLine: ` var HOME = ${JSON.stringify(t.home)};`,
|
||
};
|
||
}
|
||
|
||
export function configJsFor(t) {
|
||
return (
|
||
'// 由 console-ui/scripts/deploy.mjs 於部署時依 deploy.targets.json 產生——請勿手改,也不進 git。\n' +
|
||
`// 目標:${t.name}(${t.description})\n` +
|
||
`window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(t.apiBase)} };\n`
|
||
);
|
||
}
|
||
|
||
/** 從 config.js 的文字裡取出 apiBase(線上/產物共用同一個解析法)。 */
|
||
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 那一代的 portal(82,911 bytes、
|
||
// 金色 serif「Arcrun」品牌、Songti 12 處),repo 是 343,969 bytes 的
|
||
// 「arc >> run」新代——**組態全對、介面整整落後半個月,機械檢查一片綠**。
|
||
//
|
||
// 判準:「線上這一份,是不是我們手上這一份?」不加解釋、不留模糊地帶——
|
||
// 逐一抓下線上資產、遮掉「本來就該隨部署目標不同」的那幾行,其餘按位元組比對。
|
||
//
|
||
// 為什麼是位元組而不是「找幾個關鍵字」:
|
||
// 關鍵字清單要人維護,而人只會在「這次剛好想到」時更新它。舊世代之所以能無聲上線,
|
||
// 正是因為沒有人記得去更新那張清單。位元組比對不需要任何人記得任何事:
|
||
// repo 改了一個字,指紋就不同,線上沒跟上就是 ❌。
|
||
//
|
||
// 誠實的 trade-off(mindset §7,不假裝完美):
|
||
// ① 只要 repo 動過而還沒部署,這個檢查就會說「線上落後」——那是**正確的**,
|
||
// 因為那時線上確實不是當代的。它會吵,但吵的是真的。
|
||
// ② 若哪天 CF 邊緣開始改寫 HTML(Rocket Loader 之類),會出現假 ❌。
|
||
// 2026-08-08 實測 mira.uncle6.me 與 pages.dev 回傳位元組完全相同(sha 一致),
|
||
// 證明目前沒有改寫。真出現時它會大聲壞掉、有人來查——
|
||
// **假 ❌ 的代價遠低於假 ✅**(假 ✅ 就是這次事故本身)。
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** 納入世代指紋的資產:file=public/ 底下的路徑,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/HOME:public/ 裡那兩行只是本機 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 實撞:原版直接對全文比對「登記新庫」,而 66f1b59(08-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');
|
||
}
|