/** * deploy.mjs — 依具名目標部署 console-ui 到 Cloudflare Pages * * 用法:npm run deploy:personal * npm run deploy:personal -- --dry-run (只產出並驗產物,不推) * * 為什麼不直接用 `wrangler pages deploy`(2026-07-22 leo 立,實際踩到才補): * **兩個帳號都有名為 arcrun-console-ui 的 Pages 專案** * wrangler 若 OAuth 登入在別的帳號,`--project-name arcrun-console-ui` 會部到別人的站上。 * 本腳本強制帶目標的 accountId,並在部署前印出目標,避免部錯帳號。 * * 同時把 profile/apiBase 綁進目標(deploy.targets.json),不再靠部署者記得帶環境變數—— * 帶漏過三次:漏 profile 顯示成錯的版本、漏 apiBase 導致登入 405。 * * 🔴 三道閘,全部**讀磁碟上真的要被推的那份**,不看本腳本自己印了什麼 * (2026-08-08 事故的形狀正是「印的是 A、推的是 B」): * ① 產物閘 :宣告值有沒有真的寫進產物(apiBase / VIEWS / HOME) * ② 世代閘 :產物是不是當代(指紋+t160 的文字指紋) * ③ 線上閘 :推完回頭抓線上,組態+世代都要對上,否則本次部署算失敗 * 三閘都過才寫 .deploy-state.json(那份紀錄是「經過線上實測」的意思,不是「我跑過指令」)。 */ import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { ROOT, assertArtifact, buildArtifact, loadTargets, resolveTarget, writeState } from './targets.mjs'; import { printReport, verifyTarget } from './verify-live.mjs'; const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); const name = args.find((a) => !a.startsWith('--')); let t; try { if (!name) throw Object.assign(new Error('沒有指定部署目標'), { usage: true }); t = resolveTarget(name); } catch (e) { console.error(`✘ ${e.message}`); if (e.usage) console.error(`用法:npm run deploy:\n可用目標:${loadTargets().active.join(' / ')}`); process.exit(1); } if (t.frozen) { console.error(`✘ 目標 ${name} 已凍結,拒絕部署。\n ${t.frozen}`); console.error(' (要解凍是人的決定:改 deploy.targets.json 拿掉 frozen 欄位,並說明理由。)'); process.exit(1); } console.log(`\n部署目標:${name}`); console.log(` 說明 :${t.description}`); console.log(` 帳號 :${t.accountId}`); console.log(` 專案 :${t.projectName}`); console.log(` profile :${t.profile}`); console.log(` apiBase :${t.apiBase}`); // ── ①② 產出 + 驗產物 ──────────────────────────────────────────────── const outDir = join(ROOT, '.staging', name); try { buildArtifact(t, outDir); } catch (e) { console.error(`\n✘ 產出失敗:${e.message}`); process.exit(1); } const gate = assertArtifact(t, outDir); console.log(`\n產物:${outDir}`); console.log(` 世代指紋:${gate.generation.slice(0, 12)}`); if (!gate.ok) { console.error('\n✘ 產物閘不通過——推上去的會跟宣告的不一樣,拒絕部署:'); for (const p of gate.problems) console.error(` · ${p}`); process.exit(1); } console.log(' ✅ 產物閘:宣告值確實寫進產物,且是當代。'); if (dryRun) { console.log('\n(--dry-run:到此為止,沒有推任何東西。)'); process.exit(0); } // ── 推 ─────────────────────────────────────────────────────────────── const env = { ...process.env, DEPLOY_TARGET: name, CLOUDFLARE_ACCOUNT_ID: t.accountId }; // --commit-dirty:本地部署常有未提交變更,不因此中斷 const deploy = spawnSync( 'npx', ['wrangler', 'pages', 'deploy', outDir, '--project-name', t.projectName, '--commit-dirty=true'], { stdio: 'inherit', cwd: ROOT, env }, ); if (deploy.status !== 0) { console.error('\n✘ wrangler 部署失敗。'); process.exit(deploy.status ?? 1); } // ── ③ 線上閘 ───────────────────────────────────────────────────────── console.log('\n── 回頭驗線上(組態+世代)──'); const report = await verifyTarget(name, { wait: true }); printReport([report]); if (!report.ok) { console.error('\n✘ 推上去了,但線上跑的 ≠ 我們手上這一份。**本次部署視為失敗**。'); console.error(' (wrangler 說成功不代表對外網址就對——這正是要被擋掉的那個病。)'); process.exit(1); } writeState(name, { generation: gate.generation, apiBase: t.apiBase, profile: t.profile, urls: t.verifyUrls, verifiedAt: new Date().toISOString(), }); console.log('\n✅ 部署完成,且線上實測=宣告值+當代世代。已記入 .deploy-state.json。');