promote.sh 第一次真正攔下那個動作,是我在本地跑完整流程的時候。dev 準備往 staging 走,沒有腳本的話,業主前一天晚上在 staging 上設好的促銷橫幅就要被 dev 的版本蓋掉——不報錯,build 成功,推上線,業主的設定消失,沒有任何跡象。這不是假設。在任何保護機制存在之前,每次部署都在默默把她的資料丟掉。
問題更早。恆春的民宿做直訂有意義——省掉 OTA 佣金,留住客人關係——但這個邏輯只有在 landing page 真的反映當週方案的時候才成立。連假前兩天開特惠、颱風過後用折扣撐過冷清週、旺季尾聲清庫存,促銷有明確的時間節奏,廣告在跑的當下,方案要同步。但每次改橫幅、每次更換促銷卡片,業主都要傳訊息給我。我不是她的員工,卻是她行銷節奏的必要條件。
純靜態擋住的不是功能,是認證路由
Google OAuth 需要一個 callback endpoint。後台表單要把更改寫回 repo,需要一個落地的地方。任何一個成立,靜態輸出就走不通了。
切到 Node adapter,output: 'server',後台路由有了活的 process 跑 SSR。公開的行銷頁各自帶 export const prerender = true,等於各頁面自己決定維持靜態輸出——行銷頁的渲染方式沒有任何改變,只是整個部署現在多了一個 server 要維持活著,代價是為了偶爾才有人登入的後台。
Railway proxy 改寫了 request 的 Host header。Astro 預設的 origin check 把改寫後的值跟應用程式的 expected host 比對,不一樣就回 403。每一筆後台表單提交全進不來。解法是在 astro.config.mjs 裡把 security.checkOrigin 關掉。這不是隨便關的:後台這一層已經有 Google OAuth state、每次 request 驗證的 signed JWT session、加上 email 白名單在保護;origin check 在這裡是多餘的,Railway proxy 讓它的判斷變成錯的。理由寫在 config 的 comment 裡,不留給下一個人重新推。
外部 CMS 訂閱服務是另一條路:另一套帳號、另一組憑證、另一套需要向只想改橫幅的人解釋的介面。一個登入,一個網站,Railway 月費買的就是這個。
兩條線落在同一個 repo,git 分不清楚哪條是資料
我改元件,業主改促銷設定,兩條線都在同一個 repo 裡。問題不是衝突報錯——衝突至少看得見。問題是不報錯。dev 的程式碼帶著那個 branch 上的 promoBanner.json 往 staging 推,把業主在 staging 設好的版本覆蓋。Build 成功,部署完成,沒有任何跡象,她昨晚的設定消失。
保護機制的前提是先分開。promoBanner.json、promoCards.json、adminWhitelist.json 必須先離開 .astro 元件的 markup,成為獨立的資料檔,腳本才有辦法把資料跟程式碼區分開來。分開之後,promote.sh 接手。執行 dev → staging 或 staging → main 的時候,腳本在 merge commit 落地前先把目標 branch 自己的資料檔 checkout 回來,來源 branch 的版本直接丟棄。
第一次 promote 是設計時看不見的 edge case:目標 branch 從來沒有過這個路徑,沒有副本可以還原。如果繼承來源的,dev 的測試促銷和 dev 的 admin 白名單就直接進了剛建的 main。解法是 seed 空白——促銷陣列給 [],白名單給 {"emails": []},圖片目錄給 .gitkeep 空目錄——每個環境從乾淨的狀態開始,不繼承上一層的測試資料。這個 guardrail 從第一次 promote 就成立,不是等到第二次才有效。
GitHub App 寫回、本機沙箱、圖片的原子 commit
後台用 GitHub App(@octokit/auth-app)寫回 repo,不是 PAT。每個部署環境有自己的 Installation ID,targetBranch() 在 runtime 讀 BASE_URL 來決定這個 admin 實例對哪個 branch 寫入。同一套 codebase,dev、staging、main 三個環境純靠環境變數區分,程式碼沒有差異。
本機跑的時候,storage 層完全繞過 GitHub,直接寫本地檔案。Vite HMR 接起來,admin 的改動立刻反映在畫面上。本機開發不需要 GitHub 憑證,也不會因為測試操作意外 commit 到正式 repo。
圖片上傳——新增促銷卡片要同時處理 JPEG、WebP、更新後的 JSON——走 Git Data API 的原子 commit:先上傳 blob,組新的 tree 接在 branch tip 上,建 commit,推進 ref。三個檔案在同一個 commit 落地。Dashboard 不存在「圖片到了但 JSON 還沒更新」或反過來的半套狀態。
白名單有 SEED_ADMIN_EMAIL 這個 env var 當永久後門:從 UI 把所有 email 都移除,seed admin 還是進得來,isAllowedEmail() 裡面的 fallback 是寫死的。新增或移除其他 admin 走同一套 GitHub App commit pipeline 寫回 adminWhitelist.json,不需要手動改任何設定檔。
這個規模的客戶,以前等不到這套後台
OAuth 認證、branch-aware GitHub App commit、原子圖片上傳、即時雙語預覽讓業主存檔前可以看到中英文兩個版本的效果、加上確保程式碼部署不蓋掉業主資料的 promotion pipeline——照傳統工程成本,這套後台不會為這個規模的客戶打造。工程時間向來比它要解決的問題貴。
AI 輔助開發讓這筆帳算過去了。邊界情況的分析——第一次 promote 怎麼辦、proxy 改寫 header 之後 auth 怎麼處理、whitelist 讀取失敗時不能把人鎖在外面——每一個都還在,沒有消失。本機沙箱讓開發完全不碰 GitHub 的設計是真正的設計工作。整個週期壓縮到幾天,而不是幾週,經濟帳跟著翻過來。
她晚上十一點設好的橫幅,隔天早上我的部署跑完還在那裡。