[技術分享] 在 B 站擴充裡用一份原始碼發佈到 Chrome、Edge、Firefox、Safari 四個商店

4 個商店 × 5 種語言,一個月發 11 版。手動做一定會漏,所以版本號、打包、上傳草稿這些「不能漏」的交給腳本,連後台文案都讓瀏覽器自動化填進草稿;只有按發布這個「不能錯」的,還是我自己來。

我的 B 站擴充(嗶速 - 海外影片加速 for bilibili)目前上架 Chrome、Edge、Firefox。Safari 版的打包上傳已經接進同一條流程,但寫這篇時還沒在 App Store 上架(repo 的 README 還寫著 coming soon)。介面有繁中、簡中、英文、日文、韓文 5 種語言。

src/changelog.json 裡記到的版本,從 9/19 的 1.5.0 到 10/18 的 2.6.0,一個月 11 版。

開發、上架、回論壇留言都是我一個人。

每發一版,手動要做的事大概是這樣:

  • 改版本號:Chrome 的 manifest、Firefox 的 manifest、Xcode 工程的版本號,三個地方
  • 寫更新內容,5 種語言各一份
  • 打包 Chrome、Edge、Firefox 三個 zip
  • 三個後台各上傳一次,再貼商店介紹(3 個商店 × 5 種語言 = 15 份文案)
  • 商店截圖,每種語言一套
  • 開 GitHub Release,附上離線安裝檔
  • Safari 版還要 Archive、簽章、上傳 App Store Connect

一兩次還好,一個月 11 次一定會漏。版本號只改了兩個、更新內容忘了寫日文、還沒公告的更新說明跟著 zip 一起送出去、截圖還是上上版的 UI.... 這些我都不想靠記性。

下面照順序講:同一份程式碼怎麼變成四種產物、為什麼打包要「可重現」、更新內容怎麼管、Safari 怎麼塞進來,最後是一次 /release 怎麼把它們串起來。

同一份 src/,四種產物

先講結論:沒有四份程式碼,只有一個 src/ 資料夾。

這個擴充沒有編譯步驟,src/ 裡就是原生 JS、HTML 跟語系檔,Chrome 的「載入未封裝項目」直接指到 src/ 就能跑。所謂打包,就是把 src/ 壓成 zip。

差別只在 manifest:

  • Chrome、Edge 共用 src/manifest.json。Edge 是 Chromium 內核,這個擴充用到的 Manifest V3 API 在兩邊都一樣,不用另外一份。
  • Firefox 用 src/manifest.firefox.json。

兩份 manifest 實際的差異只有這些:

+    "browser_specific_settings": {
+        "gecko": {
+            "id": "bilibili-cdn-switcher@…",
+            "strict_min_version": "140.0",
+            "data_collection_permissions": { ... }
+        },
+        "gecko_android": { "strict_min_version": "142.0" }
+    },
     "background": {
-        "service_worker": "background.js"
+        "scripts": ["background.js"]
     }

Firefox 要一個固定的擴充 ID,背景腳本也用 scripts 而不是 service_worker。其他欄位一模一樣。

打包腳本 scripts/build.mjs 讀對應的 manifest,把它改名成 manifest.json 放進 zip 最上層,順便做兩件事:

  • 圖示換成正式版。src/icons/ 是開發版(多一個紅點,方便跟已安裝的正式版分辨),zip 裡要換成 assets/icons-prod/ 的版本。
  • changelog.json 裡還沒上架的 unreleased 清空。這個後面講。

Safari 走另一條路。Safari 擴充不能像 Chrome 那樣裝 zip,要包在一個 App 裡(macOS 是 .app,iOS 是 .ipa)。要上 App Store,就得走 Xcode 打包、簽章、送審這套。

safari/ 資料夾是 xcrun safari-web-extension-converter 產生的 Xcode 工程,裡面的擴充資源用相對路徑 ../../../src/ 直接引用 repo 的 src/。所以改 src/ 不用同步任何檔案,Xcode 打包時直接抓最新內容。

同一份 src/,怎麼變成四個商店的產物 · 在 Miro 開啟

打包腳本原本是 PowerShell 寫的。Mac 上沒有 pwsh 就跑不了,後來改成純 Node(jszip 壓縮、sharp 產圖示),Windows、Mac、Linux 都能跑。

可重現建置:內容沒變,zip 就該一模一樣

dist/ 裡打包好的 zip 是有 commit 進 repo 的(現在裡面放著 2.5.0 跟 2.6.0 兩版)。

問題來了:JSZip 預設會把「打包當下的時間」寫進 zip 裡每個檔案的時間戳。src/ 一個字都沒改,重新打包一次,時間戳不同,zip 的 bytes 就不同。

發版的時候版本號一定會變,zip 本來就該變,這沒差。麻煩的是發版之間:改了打包腳本、重產了圖示、或只是隨手 npm run build 一次,src/ 沒動,git 卻說 zip 全改了。這種 diff 沒有任何資訊,我(或 agent)還得自己判斷「這次到底要不要 commit、要不要重傳」。

反過來,如果內容一樣 bytes 就一樣,zip 有 diff 就代表內容真的變了。之後接 CI 也能在 CI 上重包一次比 hash,對不上就是有人忘了重新打包。

所以 build.mjs 把時間戳固定死:

// zip 內每個 entry 的時間戳都固定成這個,內容沒變的話每次包出來的 zip bytes 才會完全一樣
// (reproducible build),不然每個 entry 會帶「打包當下」的時間,光時間不同就會讓 bytes 每次都不同。
const FIXED_DATE = new Date(Date.UTC(1980, 0, 1));

只固定檔案的時間戳還不夠:JSZip 會自動幫 _locales/en/messages.json 這種路徑補上 _locales/、_locales/en/ 的資料夾 entry,而且資料夾 entry 的時間戳是現在時間。要加 createFolders: false 關掉。

另外 build.mjs 還寫死了 platform: "UNIX",註解說「不指定的話屬性欄位會依執行平台不同」。寫這篇時我順手驗了一下,其實不會:JSZip 不看你在哪個作業系統跑,沒指定就一律寫 DOS。拿掉 platform 之後,把 process.platform 假裝成 win32、darwin、linux 各包一次,三個 hash 一模一樣(只是跟寫 UNIX 的版本不同)。所以這行是保險,不是必要;真正會讓不同電腦包出不同 zip 的是下面會講的換行。

zip.file(relName, fs.readFileSync(entries.get(relName)), { date: FIXED_DATE, createFolders: false });
// ...
const buf = await zip.generateAsync({
  type: "nodebuffer",
  platform: "UNIX",
  compression: "DEFLATE",
  compressionOptions: { level: 9 }
});
內容沒變,zip 的 sha256 為什麼每次不一樣 · 在 Miro 開啟

實際跑一次。把 repo 複製到另一個資料夾,連續打包兩次(中間隔 5 秒),比 sha256:

$ node scripts/build.mjs --out=../run1 > /dev/null
$ node scripts/build.mjs --out=../run2 > /dev/null
$ sha256sum run1/*.zip run2/*.zip
e08eac5b337306c7ccd77b1a0b70fa3bd5b19e823e8c7cebdd15630355c2e580  run1/bilibili-cdn-switcher-chrome-2.6.0.zip
e08eac5b337306c7ccd77b1a0b70fa3bd5b19e823e8c7cebdd15630355c2e580  run1/bilibili-cdn-switcher-edge-2.6.0.zip
fc98f77b5a3f1044cf3ea8c0a02fa1c5c20c8691d7788426640153d4ea492c6d  run1/bilibili-cdn-switcher-firefox-2.6.0.zip
e08eac5b337306c7ccd77b1a0b70fa3bd5b19e823e8c7cebdd15630355c2e580  run2/bilibili-cdn-switcher-chrome-2.6.0.zip
e08eac5b337306c7ccd77b1a0b70fa3bd5b19e823e8c7cebdd15630355c2e580  run2/bilibili-cdn-switcher-edge-2.6.0.zip
fc98f77b5a3f1044cf3ea8c0a02fa1c5c20c8691d7788426640153d4ea492c6d  run2/bilibili-cdn-switcher-firefox-2.6.0.zip

兩次完全一樣。順便看得到 Chrome 跟 Edge 的 zip 根本是同一個檔案(同一份 manifest、同一份 src)。

對照組:只把 date: FIXED_DATE 拿掉(createFolders: false 留著),同樣打包兩次(隔 3 秒):

$ sha256sum dateonly-*/*.zip
fc08a000ae3a6fd9c730f2d2808c000fac2c881a997bc37982cf319c174a5b12  dateonly-1/bilibili-cdn-switcher-chrome-2.6.0.zip
fc08a000ae3a6fd9c730f2d2808c000fac2c881a997bc37982cf319c174a5b12  dateonly-1/bilibili-cdn-switcher-edge-2.6.0.zip
a87a54d9e17a28d9b4d70ba6f559468dcca630f5268929deeac63304b05fb0b4  dateonly-1/bilibili-cdn-switcher-firefox-2.6.0.zip
122d95d4ccc2391606a01f94ae169fcf971b62622ce61eb3e6780ea0c276bcc9  dateonly-2/bilibili-cdn-switcher-chrome-2.6.0.zip
47a95e054b1d31febe14934da97c16aa72d6c63756e1efd7b608bc1d3142c6bf  dateonly-2/bilibili-cdn-switcher-edge-2.6.0.zip
4ebd292f0a8e213555f055d80748cbae08ec885a2d182863497c483bc7c8c58c  dateonly-2/bilibili-cdn-switcher-firefox-2.6.0.zip

內容一樣,hash 全部不一樣。更好笑的是第二次的 Chrome 跟 Edge 也對不上:同一次打包裡,兩個 zip 剛好跨過了一個時間戳的刻度(zip 的時間只精確到 2 秒)。

等等,換一台電腦還是不一樣?

寫這篇的時候我拿 repo 裡 commit 的 dist/bilibili-cdn-switcher-firefox-2.6.0.zip(我自己電腦包的),跟上面 Linux 容器裡包出來的比。

c7f738edc1b5cbb1886d27db64336eb123e8f3bada7546b8a3ee868f457fe9b9  dist/bilibili-cdn-switcher-firefox-2.6.0.zip     ← repo 裡的
fc98f77b5a3f1044cf3ea8c0a02fa1c5c20c8691d7788426640153d4ea492c6d  run1/bilibili-cdn-switcher-firefox-2.6.0.zip     ← 容器裡包的

不一樣。說好的跨平台一致呢?

拆開兩個 zip 一個檔案一個檔案比,時間戳都是 1980-01-01、屬性都是 UNIX,差的是內容本身:repo 裡那份的 theme.js 有 32 個 \r\n,容器這份一個都沒有。

原因是換行。我本機是 Windows,工作目錄裡這些檔案大多是 CRLF(git 在 Windows 上的預設行為),容器 checkout 出來是 LF。輸入的 bytes 本來就不一樣,輸出當然不一樣。

驗證方法很簡單:把容器裡 src/ 的換行改成跟我本機一樣(大部分 CRLF,但 popup.js 跟 en、ja 兩個語系檔是 LF。我猜是 checkout 之後被編輯器或 agent 用 LF 重寫過,git 不會回頭再轉一次),再包一次:

c7f738edc1b5cbb1886d27db64336eb123e8f3bada7546b8a3ee868f457fe9b9  ../out-crlf/bilibili-cdn-switcher-firefox-2.6.0.zip
c7f738edc1b5cbb1886d27db64336eb123e8f3bada7546b8a3ee868f457fe9b9  ../bcs/dist/bilibili-cdn-switcher-firefox-2.6.0.zip

一個 byte 都不差。Windows 上包的 zip,在 Linux 上重現出來了。

所以「可重現」的意思是:輸入的 bytes 一樣,輸出的 bytes 就一樣。時間戳、資料夾 entry 這些打包工具自己加的東西,build.mjs 都處理掉了。但換行屬於輸入,它管不到。

NOTE

目前 .gitattributes 只把 .githooks/* 固定成 LF(hook 用 sh 執行,CRLF 會整個跑不起來)。要真的做到「任何電腦 clone 下來包出來都一樣」,src/ 也要加一條 eol=lf。這個我還沒改。

更新內容:5 種語言、兩個清單

src/changelog.json 同時存兩份清單:

{
  "unreleased": [ /* 還沒上架的新功能/修正 */ ],
  "releases": [
    {
      "version": "2.6.0",
      "date": "2026-10-18",
      "entries": [
        {
          "type": "feature",
          "text": {
            "zh_TW": "抽獎公告顯示每種禮物卡的面額,並可附上活動備註",
            "zh_CN": "抽奖公告显示每种礼品卡的面额,并可附上活动备注",
            "en": "Prize draw announcements now show each gift card's value and can include extra notes",
            "ja": "抽選のお知らせにギフトカードごとの金額と備考を表示できるようになりました",
            "ko": "추첨 공지에 기프트 카드별 금액과 참고 사항을 표시할 수 있게 되었어요"
          }
        }
      ]
    }
  ]
}

平常改完功能就在 unreleased 加一條,5 種語言一次寫完。發版時整批搬進新版本。

為什麼要分兩個清單? 因為這份檔案同時是擴充的一部分。使用者更新擴充後第一次打開設定選單,會跳一次「更新內容」,資料只從 releases 讀:它要靠版本號判斷「你從哪一版升上來、該列哪幾條」,沒有版本號的 unreleased 它用不到。

擴充更新後的「已更新至 v2.6.0」提示,紅框標出從 changelog.json 讀出來的 7 條更新內容(新增/修正)

(這張是用 headless Chromium 載入 src/、把已安裝版本設成 2.4.0 拍的,所以列出 2.5.0 跟 2.6.0 兩版累積的 7 條。)

正常發版時,發版腳本(release.mjs prepare,後面講)會先把 unreleased 搬走再打包,打包當下它本來就是空的。但 zip 不只在發版時打:dist/ 有進 git,發版之間我也會隨手 npm run build。這時候 unreleased 裡是還沒公告的更新說明,popup 不讀它,留在 zip 裡沒有用,只會讓解開 zip 的人先看到下一版要講什麼。(老實說,這時候功能的程式碼本身已經在 src/ 裡、也會進 zip,這一步擋不了;它只保證 zip 裡的更新說明,跟商店上已經公告的一致。)所以 build.mjs 不管什麼時候打包,都把它清空,只留 releases:

// changelog.json 的 unreleased 是尚未上架的功能,不該隨 zip 外流,只留已上架的 releases
const changelog = readJson(path.join(SRC, "changelog.json"));
const changelogBuf = Buffer.from(JSON.stringify({ unreleased: [], releases: changelog.releases }, null, 2) + "\n");

這個提示什麼時候該跳、什麼時候不該跳(新使用者不跳、同版本不跳、關掉之後才寫入版本號...),有一支 scripts/test-whats-new.mjs 用 puppeteer 開真的 Chrome 載入 src/ 逐項測。

忘了寫怎麼辦? pre-push 擋下來

寫程式的時候最容易忘的就是寫 changelog。改完、commit、push,三天後發版才發現這次改了什麼完全沒記。

所以 repo 有一個 .githooks/pre-push(npm install 時會自動設定 core.hooksPath)。push 的時候它會比對「這次 push 的 commit」跟「分支從主線分出來的那個 commit」,只要 src/changelog.json 沒動,就擋下來問你:

Ignore and push: No/Yes [y/N]

預設是 No。真的只是改腳本或文件、使用者看不到的,按 y 放行,或用 CHANGELOG_CHECK_IGNORE=1 git push 略過。

它比的是整條分支,所以一條分支只要有一個 commit 動過 changelog,之後再 push 就不會擋了。它管的是「整條分支都忘了寫」這種最常見的情況,不是每個 commit。

沒有終端機可以互動的時候(AI agent、CI)一律擋。這是故意的:我大部分程式碼是 Claude Code 寫的,它 push 前一定要自己判斷這次改動要不要記,拿不準就要問我,不能自己硬略過。這條規則寫在 repo 的 agent.md 裡。環境變數誰都能設,hook 擋得住手滑,擋不住故意,後者靠的是這條規則。

下面是在 repo 的複本裡改一行 popup.js、不碰 changelog 直接 push 的實際輸出(在雲端容器跑的,沒有終端機可以問,直接擋;基準 commit 的 sha 我換成了 <base>):

$ git push origin fix/popup-typo
相較於 <base>(分支出來的基準),這次 push 改了 1 個檔案,但 src/changelog.json 沒有任何變動。

No new features/bugs found. Please check if any changes need to record.
請把使用者看得到的新功能/修正寫進 src/changelog.json 的 unreleased。
error: failed to push some refs to '../origin.git'

有終端機的時候就會問。一樣是 repo 的複本,push 到本機的假 remote,問的時候直接按 Enter(等於選 No):

Git Bash 裡 git push 被 pre-push 攔下,紅框框住 Ignore and push: No/Yes [y/N],按 Enter 之後顯示已取消 push

有改 changelog 的話,hook 也會順便驗格式:每條都要有 zh_TW/zh_CN/en/ja/ko 五語、版本由新到舊、日期是 YYYY-MM-DD。少一種語言一樣推不上去。

商店截圖:讓程式去拍

商店截圖是最容易過期的東西。UI 改一個按鈕,5 種語言、每個商店的截圖全部要重拍。手動拍? 拍完一輪我大概也不想再改 UI 了。

所以截圖也是腳本拍的:用 puppeteer 載入最新的 src/,開真實的 B 站影片頁,依序切換語言,拍下商店要用的畫面。拍的是當下原始碼的真實 UI,不是 mockup,所以只要重跑一次,截圖就跟程式碼同步。

這支腳本跟商店文案一起放在私人 repo(.claude/skills/store-screenshots/capture.mjs),公開 repo 只看得到它需要的設定:.env.local 的 BILI_COOKIE(帶登入狀態才拍得到高畫質,沒填就是 480P)跟選填的 LIVE_ROOM(拍直播頁用的房號)。

一輪分四步,每步可以單獨重拍:

  • ui:popup 主畫面(明暗兩種主題)、國家與節點、進階設定。5 種語言各一套,Chrome 版跟 Safari 版也各一套(Safari 版換成 App 名稱、沒有評分按鈕)。
  • speedtest:測速頁。真的只測一次,再切換 popup 的介面語言、把同一份結果放回去各拍一張,所以 5 種語言的節點、數字、金皇冠完全一樣。
  • video:同一支 4K 影片,最慢的節點跟最快的節點各播一段,拍除錯疊層上的真實讀數,做成開啟前後的對比。
  • mobile:iPhone 截圖當背景用的手機版 B 站網頁。

拍出來的是原始素材,另一支 compose.mjs 再把它們合成商店要的版面:Chrome/Edge/Firefox 共用的 1280x800、Mac App Store 的 2560x1600、iPhone 的 1290x2796、iPad 的 2732x2048,每組 5 種語言、每種語言 5 張,放在 store/screenshots/<組>-<寬>x<高>/。整輪跑完大概 15~20 分鐘。

下面是 1280x800 的第 3 張(測速頁)5 種語言並排,紅框是 popup 裡跟著語言換掉的地方。往下看節點列表,數字一個都沒變:

同一張商店截圖的繁中、簡中、英文、日文、韓文版本並排,紅框框出 popup 上方隨語言改變的標題與說明,下方的節點測速數字五張都一樣

不過截圖不常換,/release 不會自動重拍。UI 有大改才手動跑一次,拍完先輸出到暫存資料夾、出一張總覽圖給我看,確認了才搬進 store/。

Safari:塞進同一條流水線

之前論壇有人問有沒有 Safari 版,我回「沒有 Mac 開發不了......QQ」。後來還是做了:Xcode 工程產生一次、commit 進 repo,之後的打包、簽章、上傳都丟給 GitHub Actions 的 macOS runner,不用每次發版都坐到 Mac 前面。

Xcode 工程是在一台借來的 Mac 上用 xcrun safari-web-extension-converter 產生的,只跑那一次。它用相對路徑直接引用 repo 的 src/,不複製檔案,所以改 src/ 不用同步任何東西。

之後 src/ 多了新檔案,Xcode 工程也要加引用,這件事我沒再碰過 Mac:直接叫 Claude Code 在 Windows 上改 project.pbxproj(例如 10/7 補上 background.js、i18n.js、theme.js 那次)。手改 pbxproj 聽起來很恐怖,所以下面有一關 CI 會檢查改完是不是真的打得出來。

Safari 版跟其他三個共用 src/,但有幾個只有它才有的麻煩。

名字不能有 bilibili

App 名稱不能用 bilibili 這個別人家的品牌,而且 App Store 上的名字要跟安裝後顯示的一致(改名腳本的註解記的是審核指南 4.1(c) 跟 2.3.8)。但 Chrome、Edge、Firefox 版的名字要保留「for bilibili」。

不能改 src/_locales,因為其他三家也在用。做法是在 Xcode 的擴充 target 加一個 build phase,打包時跑 scripts/safari-ext-name.py,只改 .appex 裡複製出來的那份語系檔:

EN = "BiBoost - Video Speedup"
NAMES = {
    "zh_TW": "嗶速 - 海外影片加速",
    "zh_CN": "哔速 - 海外视频加速",
    "en": EN, "ja": EN, "ko": EN,
}
# extName、popupHeaderTitle、extActionTitle 三個 key 都換成 App 名稱

另一個 build phase 把正式版圖示複製進 .appex(跟 zip 一樣,不用開發版那個紅點)。

每個 PR 都檢查一次能不能打包

.github/workflows/safari-build.yml:PR 改到 src/、safari/、正式版圖示或改名腳本時,在 macOS runner 上用 xcodebuild 打包 macOS 跟 iOS 兩個版本(不簽章、不上傳),然後檢查:

  • App 顯示名稱是不是 BiBoost - Video Speedup
  • src/ 底下每個檔案、資料夾,.appex 裡都有(漏了就是 Xcode 工程沒引用到新檔案)
  • 5 種語言的 extName 都不含 bilibili
  • git diff --exit-code -- src/:打包過程不准改到 src/

第二條最實用。在 src/ 加了一個新的 JS 檔,Chrome 版直接就有了,Xcode 工程卻要手動加引用。忘了加,Safari 版就少一個檔案,而且不會有任何錯誤訊息,直到使用者回報壞掉。

上傳 App Store:CI 簽章的兩個坑

.github/workflows/safari-appstore.yml 是手動觸發的(Actions › Safari App Store › Run workflow),在 macOS runner 上 Archive、簽章、上傳到 App Store Connect。App Store Connect 的 API 金鑰放在 repo secrets,執行時寫成暫存的 .p8 檔,跑完就刪掉。

坑一(macOS):自動簽章的 Archive 會用「開發憑證」簽。CI 每次都是新機器、鑰匙圈裡沒有私鑰,Xcode 就向 Apple 再申請一張,累積到帳號上限後 Archive 直接失敗。改成 Archive 時用 ad-hoc 簽章(不需要任何憑證),App Sandbox 這些 entitlements 一樣會寫進簽章,下一步匯出時再用雲端管理的發布憑證重簽。workflow 裡還多一步檢查 .app 跟 .appex 都帶有 App Sandbox,沒有的話 App Store 會拒收,在這裡就先擋下。

坑二(iOS):自動簽章 Archive 會先要 iOS 開發用的描述檔,那需要已註冊的實體裝置,CI 沒有。所以 iOS 先不簽章 Archive,匯出時再用發布憑證簽(App Store 版只看匯出時的簽章)。

另外每次上傳的 build number 都要比上一次大,CI 把工程裡寫的 build number 跟 GitHub 的 run number 相加。上傳之後 build 還要在 Apple 那邊處理一陣子,另一個 asc-status.yml 會用 App Store Connect API 查處理狀態跟 TestFlight 設定(只讀不寫)。

跑完長這樣。iOS、macOS 兩個 job 都過,每個 job 的 Summary 會寫上傳了哪個版本、哪個 build:

GitHub Actions 的 Safari App Store run 頁面,紅框框住 Matrix: archive 底下 ios、macos 兩個綠勾 job,以及兩段 Summary「已上傳 2.1.0 (18),App Store Connect 處理完後即可選來送審」

一次 /release,從 src/ 到四個商店

前面這些零件,最後用一個 Claude Code 的 /release skill 串起來(skill 本身放在私人 repo)。我只要說要發 minor 還是 patch,它就照順序跑,任何一步失敗就停下來回報,不會硬往下走:

  1. 前置檢查:要在 master、工作目錄乾淨、changelog 格式正確,unreleased 不能是空的(沒東西可以發)。
  2. 升版:列出 unreleased 的條目,問我要升 major、minor 還是 patch(有新功能建議 minor,只有修正建議 patch)。開 release/x.y.z 分支,node scripts/release.mjs prepare x.y.z:unreleased 整批搬進新版本(日期=今天),manifest.json、manifest.firefox.json、Xcode 工程的版本號一起改。
  3. 改商店介紹:照這一版的內容改 store/description-*.md(4 個平台 × 5 種語言 = 20 份)裡的功能清單。只寫使用者會想用的新功能,bug 修正跟效能微調不寫;這版只有修正的話介紹就不動。
  4. 打包、測試、commit:npm run package(scripts/package.mjs)呼叫兩次前面講的 build.mjs,一次產生 dist/ 的三個商店 zip,一次產生 release/ 的離線安裝檔(檔名不帶版本號),再加上 Windows 測速工具。接著跑更新提示的測試,全過才 commit release: vx.y.z。
  5. 上傳草稿:npm run publish-stores,用各家 API 把 zip 傳上去。
  6. 更新商店後台:文案或截圖這版有變的話,用 Playwright 開三個後台改(見下一節)。
  7. GitHub Release:push 分支、打 tag,release.mjs notes 從 changelog 產生中英文的更新說明。建立 Release 是公開動作,所以先把檔案清單跟說明給我看,我說好才上傳。
  8. 合併回 master(git log 裡看得到 Merge release/2.6.0)。

Safari 不在這條流程裡。版本號第 2 步就同步好了,要上 App Store 時,是我自己去 Actions 按 Run workflow。

一次發版,哪幾步機器做、哪幾步一定我按 · 在 Miro 開啟

發布按鈕一律我自己按

agent.md 裡有一條寫死的規則:商店的「發布」按鈕一律由我人工按,agent 不得代按。

為什麼? 送出去就收不回來。發錯版本,使用者會自動更新到壞掉的版本,商店審核還要再排一次隊。我寧願多按一次按鈕。

發布鍵是我的選擇;但有些事是 API 本來就做不到(2026 年 10 月,依 publish-stores.mjs 註解的整理):Chrome、Edge 的 API 改不了商店文案跟圖片,只能回後台改。所以 /release 這一步改用 Playwright 開瀏覽器去後台填:一種語言一種語言貼文案、換截圖,只按「儲存草稿」,存完重新載入頁面、讀回來跟檔案逐字比對。登入跟 2FA 是我自己在 Playwright 視窗裡打。Chrome、Edge 的 API 其實也有發布的端點,是我故意不接。

商店腳本做的我做的
Chrome Web Store上傳 zip 到草稿;文案、截圖有變就用 Playwright 改好存草稿檢查草稿、按「提交審查」
Edge Add-ons同上同上;API key 大約兩個月到期,要回後台重產
Firefox(AMO)上傳並驗證;我同意後加 --submit-firefox 建立版本,順便寫入各語言的更新說明跟簡介AMO 沒有草稿,建立版本就等於送審,所以要不要送是我決定
App StoreCI 上傳 build在 App Store Connect 選 build、提交審核

所以 publish-stores.mjs 預設絕不會按發布:Chrome、Edge 只傳到草稿,Firefox 只驗證不送審。跑完會列出「還需要你手動做的事」。這是 dry-run 的實際輸出(ID 已遮掉):

$ node scripts/publish-stores.mjs --dry-run
版本 v2.6.0(dry-run)

[chrome]
  已打包 dist/bilibili-cdn-switcher-chrome-2.6.0.zip
  上傳 bilibili-cdn-switcher-chrome-2.6.0.zip → https://chromewebstore.googleapis.com/upload/v2/publishers/<CWS_PUBLISHER_ID>/items/dfaddcffoondcendifiljhdbdagebgch:upload(只到草稿,不發布)

[edge]
  已打包 dist/bilibili-cdn-switcher-edge-2.6.0.zip
  上傳 bilibili-cdn-switcher-edge-2.6.0.zip → https://api.addons.microsoftedge.microsoft.com/v1/products/<EDGE_PRODUCT_ID>/submissions/draft/package(只到草稿,不發布)

[firefox]
  已打包 dist/bilibili-cdn-switcher-firefox-2.6.0.zip
  上傳並驗證 bilibili-cdn-switcher-firefox-2.6.0.zip(channel=listed)
  (未加 --submit-firefox:只驗證,不建立 version,不會送審)

—— 還需要你手動做的事 ——
• chrome:到 https://chrome.google.com/webstore/devconsole 確認草稿 → 貼上 store/description-chrome-{zhtw,zhcn,en,ja,ko}.md 的各語言簡介(API 不能改文案/圖片,圖片在 store/*.png)→ 自己按「發布」
• edge:到 https://partner.microsoft.com/dashboard/microsoftedge/overview 確認草稿 → 貼上 store/description-edge-{zhtw,zhcn,en,ja,ko}.md 的各語言簡介(API 不能改文案/圖片,圖片在 store/*.png)→ 自己按「發布」
• firefox:套件已驗證;確認後執行 `npm run publish-stores -- --browser=firefox --submit-firefox` 送審(AMO 沒有草稿,這步等同發布按鈕)

Edge 的 API key 會過期這件事特別煩。過期了上傳就是一個 401,很容易以為是別的問題。所以 .env.local 多記一個到期日,剩 14 天內 publish-stores 會提醒,過期了直接擋下並告訴你去哪裡重產。

下面四張是各家後台「最後一步交給我」的地方(2026 年 10 月 19 日拍的,2.6.0 已經發布,所以 Chrome、Edge 看到的是草稿跟線上同版的狀態)。

Chrome:左邊 ① 是草稿,publish-stores 上傳的 zip 只會改到這一欄;右邊 ② 是線上版,要等我送審、審過才會變:

Chrome Web Store 開發人員資訊主頁的套件頁,紅框 1 是草稿的版本 2.6.0,紅框 2 是已發布的版本 2.6.0

Edge:API 上傳完是 ①「套件已經過驗證」,② 是草稿裡的套件版本,接著按右上角 ③「繼續」一路到發佈,這段我自己來:

Microsoft 合作夥伴中心的 Edge 擴充功能套件頁,紅框 1 是「您目前的套件已經過驗證」,紅框 2 是版本 2.6.0 的套件,紅框 3 是右上角的「繼續」

Firefox:沒有後台可以拍,看的是終端機。這是 --dry-run 的輸出(不呼叫 API),上面紅框是「只驗證,不送審」,下面紅框是要送審得自己再下的那行指令:

終端機執行 npm run publish-stores -- --browser=firefox --dry-run,紅框框出「未加 --submit-firefox:只驗證,不建立 version,不會送審」,以及「確認後執行 npm run publish-stores -- --browser=firefox --submit-firefox 送審」

App Store:CI 上傳的 build 處理完之後,是我在 App Store Connect 選 build、按送審。現在兩個平台的 2.1.0 都在等審查:

App Store Connect 的 App 列表,BiBoost - Video Speedup 底下紅框框出 iOS 2.1.0 正在等待審查、macOS 2.1.0 正在等待審查

還能怎麼改

  • .gitattributes 補一條讓 src/ 一律 LF,可重現建置才真的不挑電腦。
  • 把前面說的「CI 重包一次比 hash」真的接起來。現在 dist/ 的 zip 還是打包完手動 commit。
  • build.mjs 裡 platform 那行的註解改成實際原因(保險,不是必要)。

一個人顧四個商店,靠的不是記性,是讓每一步漏掉的時候會有東西叫出來。叫不出來的那一步(按發布),就乾脆留給自己。