跳至主要内容
💬 回報這一頁

版本策略

三層各自獨立版號,因為它們的變動頻率與影響半徑完全不同。

套件版號意義
@dooping/tokens契約。變動影響所有宿主
@dooping/react參考實作。使用者複製走之後就與版號脫鉤
文件站跟著 repo,不獨立發版

三段式發布:dev → staging → main​

dev(工作中)──PR──▶ staging(候選版:套用驗收)──PR+核准──▶ main(核准版)──▶ 其他系統
│ │ │
▼ ▼ ▼
/preview/ /staging/(registry 可試裝) 正式站、/r/、npm、tag/Release
分支代表什麼部署到能不能參照能不能安裝誰讓它前進
dev最新功能開發/preview/不能不供安裝功能分支做完才併進來;外部 PR 開到這裡
staging候選版,正在做套用驗收/staging/不能可以在自己的分支試裝回報,不可正式導入只收 dev 的 PR
main最新核准版正式站根目錄其他系統只參照這裡/r/、npm只收 staging 的 PR,守門人核准後合併

進版=核准版前進一次。 一次 staging → main 的合併就是一次進版,對應 CHANGELOG 的一則。 寫不出「取用者要做什麼」的變更,就不是一次進版,繼續留在 dev。

三個詞各一個意思​

詞意思在哪一段
檢查CI 守衛全綠:測試、渲染、無障礙、視覺、內部試裝宿主每一段都跑
驗收候選版真的能被其他系統套用:自動的套用驗收,加上人工的核准清單staging
核准守門人 @kielchang 在 staging → main 的 PR 上勾完核准清單、必過檢查全綠後合併main

以前這三件事都叫「確認」——「CI 綠了」「手機看過預覽站」「合併了」分不清哪一個才是閘門。

流程​

① dev 上開發:功能分支做完才併進 dev;改到元件或 token 就依判準表 bump 版號(CI 擋漏 bump)
② 切候選版:守門人先分流 PMIS 裡新進的取用端回饋(見〈回饋與 RFC 流程〉);
在 dev 把 CHANGELOG「## 未發佈」改名——有 bump 寫「## vX.Y.Z · 日期」,
版號沒動寫「## 日期(說明)」——然後開 dev → staging 的 PR
└ pr-gate:只准 staging ← dev、版號遞增、tag 未被佔、CHANGELOG 標題對得上
③ 合併 → staging 建置、發版閘、套用驗收、部署 /staging/、部署後冒煙
④ 開 staging → main 的 PR,照 PR 模板的「核准清單」逐項驗收並打勾
└ pr-gate:只准 main ← staging、合併後內容=staging、核准清單勾完
⑤ 合併=核准 → 部署正式站、冒煙、自動蓋 tag vX.Y.Z、發 GitHub Release(版號沒動就不打、不發)
  • 套用驗收(npm run verify:consumer)在 repo 外建一個乾淨的 Vite+Tailwind v4 專案:token 用 npm pack 出來的套件、 元件用真的 shadcn CLI 從候選版的 registry 裝,型別檢查、建置,再到瀏覽器量顏色——證明別的系統照 AGENTS.md 做真的接得上。 還不涵蓋 Next.js App Router、Tailwind v3 宿主、Base UI 共存。
  • 驗收沒過:修在 dev、推上去,再開一次 dev → staging 的 PR,staging 會重跑全部驗收。不在 staging 上直接改; 版號不必再 bump,除非那個 tag 已經被佔。
  • dev 永遠是可發布的狀態——這是「修在 dev 再送一次」成立的前提。做到一半的東西留在功能分支。
  • 部署失敗的窗口:合併進 main 但部署紅了=main 已含內容、正式站還沒動。修好原因後重跑 deploy(手動觸發只能對 main),不是 revert。
  • 強制:main 與 staging 由 GitHub ruleset 保護(必須 PR、只允許 merge commit、必過檢查、禁止強推與刪除),來源分支由 pr-gate 擋。 設定正本在 .github/rulesets/,tests/workflow-contract.test.ts 核對它與 workflow 對得上。

兩條散佈通道的更新時機不同,這個區分很重要:

通道何時更新
文件站 / Storybook / registry每次進版都更新(它們反映 main 的當下狀態)
npm @dooping/tokens只在 token 內容真的變更時 bump

預覽站與候選版都不是進版​

dev 推上去後部署到 /preview/,給維護者在真實裝置上看 UI——尤其是響應式與觸控目標,那是開發機看不出來的。 staging 前進後部署到 /staging/,那是等待核准的候選版。

只參照正式站

/preview/ 隨時會被下一次 dev push 覆蓋;/staging/ 還沒核准,隨時會被下一個候選版取代。 其他系統一律以 main 的正式網址為準。 兩個站都掛著不可關的橫幅,也不給搜尋引擎收錄。

每一段的 registry 都用自己的網址建置,相依指回同一段——從 /staging/r/ 裝,拿到的全部是候選版,不會混到別段的版本。 想提前試下一版,就在自己的分支從 /staging/r/ 裝,把問題回饋上游(入口見回饋與 RFC 流程);正式導入一律用 /r/。

兩個對外訊號​

訊號給誰看什麼時候動
規範版號 vX.Y.Z(GitHub tag + registry 戳記)所有取用端元件、token 或模式有取用端可感知的變更
@dooping/tokens 的 npm 版號有安裝 token 套件的系統token 內容變更

規範版號回答的問題是:「這次進版,我需不需要跟進?」 看大中小就知道分量——大=會壞、中=有新東西、小=修正微調(判準見上方兩張 SemVer 表, 對象是規範整體)。純文件進版不 bump、也就沒有新 tag:取用端看不到新版號, 就代表不需要任何動作。

版號正本是根目錄 package.json 的 version;packages/react 與 version.ts 跟隨它、 registry 戳記由它產生(守衛測試強制四處一致),所以 tag、戳記與 /r/index.json 的 version 永遠是同一個號碼——「我抄的是哪一版」直接對得回 GitHub 的 tag。

三層版號的對應關係(配對模型)​

兩條版號線不同步前進——那怎麼知道「哪一版規範配哪一版 token」? 答案是配對模型:對應關係不靠同號,靠宣告。

定義:每個規範版恰好宣告一個 tokens 版。樞紐是 packages/react/package.json 的 "@dooping/tokens": "x.y.z" 那一行—— 它是配對的唯一正本,其他所有體現都從它產生:

體現處形式給誰用
/r/index.json 的 tokensVersion機器可讀配對(與 version 並列)取用端一個端點問到配對
registry item 的 dependencies@dooping/tokens@^x.y.znpx shadcn add 自動裝對
v* tag 的訊息「tokens 配對版:x.y.z」從 git 稽核,不翻 CHANGELOG
CHANGELOG 標題「規範 vA/tokens vB」要升級的人

多對一合法、一對多非法。token 沒變時,多個規範版指向同一 tokens 版 (v0.6.0 與 v0.6.1 都配 0.5.0);反過來,一個規範版宣告兩個 tokens 版不可能發生—— 守衛強制宣告是單一值。

規範 v0.1.0 v0.2.0 v0.2.1 v0.4.0 v0.6.0 v0.6.1
│ │ │ │ │ │
tokens 0.1.2 ──┴───────┘ 0.3.0 0.5.0 ──┘

^ 的語意就是配對的鬆緊:0.x 之下 ^0.6.0 = >=0.6.0 <0.7.0—— 同 minor 的 token 修補自動吃(不必重抄元件);宣告跨了 minor, 代表元件可能開始依賴新 token,該回 CHANGELOG 看要不要跟進。

取用端自查(一行對照就知道自己在不在配對上):

npm ls @dooping/tokens; curl -s https://kielchang.github.io/dooping-design-book/r/index.json | jq -r .tokensVersion

兩個數字的關係只有三種:相等=配對正確;本地落後=該升級 token; 線上 tokensVersion 比 npm 能裝到的還新=上游合併了但還沒發佈(等,或提醒維護者)。

配對由四道保證釘住,每一道擋一種真實出過的錯:

保證擋什麼
測試守衛(處內一致+index 配對)七處版號任何一處漂移、tokensVersion 與宣告不符
CI 兩硬閘(內容變了版號要跟)改了 token/元件卻忘記 bump——擋 PR
發佈硬閘(tag 名=package.json 版本、commit 在 main 上)推錯 tag 發出錯的版本;對未進版的 commit 發佈
兩處軟閘(PR 與進版當下的漂移提示)宣告了卻忘記發佈——不擋,但一直出聲到發為止

tag 與 Release:核准之後的自動蓋章​

tag 是自動蓋章,不是閘門——閘門是核准(上面流程的第 ⑤ 步合併)。日期不佔 tag 名稱, 在 tag 描述與 CHANGELOG 裡。

GitHub Release 是給取用端的推播訊號:Watch 這個 repo 的 Releases(或訂 releases.atom)就會在每次進版時被主動告知,notes 直接是 CHANGELOG 該則全文—— 通知本身回答三問。版號沒動的進版不發 Release,「沒有訊號=不需要動作」。 訂閱方式、收到訊號後的判斷與每層更新程序,見跟上新版。

一次進版只會蓋一個 tag

蓋 tag 讀的是合併進 main 當下根目錄 package.json 的 version。所以如果 dev 上累積了 好幾次 bump 才送出候選版,只有最後那一個版號會有 tag。

這不是缺陷——核准版前進一次才算一次進版,中間那些版號從來沒有 任何取用端拿得到,它們是工作狀態。CHANGELOG 要照這個事實寫:一次進版一則, 底下用 ### 分工作項,不要把中間版號寫成好幾則發佈。

實際發生過:v0.3.0 沒有 tag,因為它是隨 v0.4.0 那次合併一起釋出的。

CHANGELOG 標題裡的 SHA 是選填的

標題格式是 ## vX.Y.Z · YYYY-MM-DD,後面可以再加 merge commit 的 short SHA。

但它不能是「改名」的前置條件——那個 SHA 是 staging → main 的 merge commit, 改名的時候(開 dev → staging 的 PR 之前)根本不存在。原本的規則把它寫成必填,結果連續三次進版都乾脆跳過改名, 於是 v0.2.1 與 v0.4.0 兩個已發佈版本一直躺在 CHANGELOG 的「未發佈」一節底下。

commit 本身記在 tag 描述裡,不會遺失。合併後想補 SHA 再補。

tokens-v* 則刻意維持人工,它是 npm 發佈的第二道閘門。兩條對等的路:

# 路一:本機打 tag(推上去即觸發 publish-tokens.yml)
git tag -a tokens-v0.1.3 -m "tokens 0.1.3" && git push origin tokens-v0.1.3

路二:GitHub 網頁 → Actions → Publish tokens → Run workflow → 取消勾選 dry_run。不需要開終端機,效果相同(0.1.2 就是這樣發的)。

自動蓋的 v* 不會誤觸發發佈:它不符合 tokens-v* 的比對模式, 而且 GITHUB_TOKEN 建立的 ref 依 GitHub 的防遞迴機制本來就不會觸發其他 workflow。

Token 的 SemVer 定義​

變更級別
移除或改名一個 tokenmajor
改變一個 token 的語意(--warning 從黃變紅)major
改變 --radius 之類會影響全站外觀的基準值major
新增 tokenminor
新增可 import 的入口檔(例:@dooping/tokens/tailwind.css)minor
微調色值但語意不變(對比修正)patch
修正 CSS 產物的 bugpatch

什麼不該發版​

上面那張表定義了「什麼變更算哪一級」,但漏了另一半——不是每次進版都該動 npm 版號:

變更npm 版號進 main
封裝設定(exports、files)的整理——不新增可 import 的入口不 bump✅
CI、建置腳本、測試不 bump✅
文件、註解、重構、改名不 bump✅

這些照樣進 main、文件站與 registry 照樣更新,但躺著等下次真的有 token 變更時順路帶出去。

✅ 這樣做

移除 token 前先標記棄用,保留至少一個 minor 版本, 並在 CHANGELOG 寫清楚替代方案。

🚫 不要這樣

不要「順手改名」。token 名稱是契約,改名對所有宿主都是編譯失敗 或更糟——樣式默默失效。

🚫 不要這樣

不要為了驗證發佈管線而發版。沒有內容變更的版本會稀釋 「token 幾乎不會被改,所以它才是契約」這個主張——取用者看到版號一直跳, 就不會相信它穩定。要驗證管線請用 --dry-run。

元件的 SemVer 定義​

變更級別
移除或改名 propmajor
改變預設行為(例如分頁預設值)major
新增 prop(有預設值)minor
新增元件minor
視覺微調、修 bugpatch

視覺變更也算變更。 「只是把 padding 從 8px 改成 12px」對已經對齊過版面的使用者 就是一次 breaking change。至少要 patch,而且要寫進 CHANGELOG。

元件走 registry 複製散佈,沒有 npm 版號可以鎖,所以版號改用戳記的形式交付: 每個 registry item 與 /r/index.json 都帶 version 欄位。取用端因此答得出兩個問題—— 「我抄的是哪一版」(看複製進來那份)與「現在最新是哪一版」(看線上 index)。

curl -s https://kielchang.github.io/dooping-design-book/r/index.json | jq -r .version

CHANGELOG 寫給誰看​

寫給要升級的人,不是寫給自己。每一則要回答:

  1. 改了什麼
  2. 我需要做什麼(不需要就明說「無須調整」)
  3. 為什麼改(有對應的 PMIS ADR 就附編號)

文件版本對映產品版號​

這一節與下一節是給取用端系統的做法(設定頁、列印頁尾)。本書自己不做文件快照: 下一版看 /staging/,舊版看 git tag。

上面講的是三層各自怎麼編號。但有兩個問題完全在讀者那一側,規則本身回答不了: 我現在讀的這頁文件對應哪一版產品?我手上這份產出物是哪一版產的?

文件站與產品同 repo 時,很容易讓文件永遠只有一個版本=主線最新狀態。 問題在於產品有版本,而使用者手上的不一定是最新版:

「文件說設定頁有這個開關,我這裡沒有。」 「喔那是下一版才有的。」

這句對話會反覆發生,而每一次都在消耗使用者對文件的信任。 發生幾次之後,他不會再查文件,他會直接問人——那就是文件站失效的那一刻。

做法:文件也做版本快照,版號直接沿用產品版號。

版本內容標示
current(掛在 /next)開發中、尚未發佈未發佈橫幅,每頁常駐
最新快照=正式環境現在跑的版本預設顯示
舊快照還有人在用的舊版版本下拉可切

三個實作細節,每一個都對應一種會出錯的方式:

  1. 預設顯示「最新快照」,不是 current。 預設值決定 99% 的讀者看到什麼; current 是還沒發佈的內容,把它當預設等於讓多數讀者讀到一份描述未來的文件。
  2. 未發佈的內容要有橫幅,而且要在每一頁。 讀者多半從搜尋引擎或別人貼的連結 直接落到某一頁,他不會經過首頁,也不會去看下拉選單。
  3. 設定要動態讀取已有的快照清單。 手寫版本清單會撞上先有雞還先有蛋: 設定裡宣告了 v1.5.0 但快照指令還沒跑,文件站當場建置失敗—— 而這件事發生的時間點,永遠是你正在發佈的時候。

發佈 SOP 要把快照綁進去:bump 版號 → 寫 CHANGELOG → 快照文件 → 合併發佈。 快照要跟在 bump 後面,成為同一個步驟。 拆成兩件事的話,被跳過的永遠是文件那一件—— 而跳過一次之後,那一版就永遠沒有對應的文件了。

✅ 這樣做

文件版號直接用產品版號,不要另起一套。 讀者要對照的是「我用的系統是哪一版」,不是「文件寫到第幾版」。

🚫 不要這樣

不要每次改錯字都快照一次。快照的意義是對映一次發佈; 快照數量應該等於發佈次數,多於它就只是在製造要維護的舊分支。

產出物的身分(provenance)​

版號不只是給開發者看的。每一個離開系統的產出物,都要帶著自己的身分。

單一來源、建置時注入、三個地方顯示:

建置時注入:
__APP_VERSION__ = package.json 的 version
__GIT_SHA__ = CI 環境變數的 commit(取前 7 碼),本地建置= "dev"
__BUILD_TIME__ = 建置當下時間

單一來源:
version.ts 讀這三個常數,對外只匯出組好的字串與連結
顯示位置形式為什麼是這裡
側邊欄最底v1.4.2 · a3f9c21(可點開對應 commit)常駐但不佔位。要回報問題時看一眼就有
設定 →「關於」版號、commit 連結、建置時間、環境需要完整資訊時的去處
列印頁尾v1.4.2 · a3f9c21 2026-07-29 14:20 產生最重要的一個,理由見下

為什麼列印頁尾最重要​

螢幕上的東西可以隨時再看一次;印出來的那張紙不會更新。

使用者拿著一份三個月前印出來的文件說「這個數字不對」時, 你要回答的第一個問題是「這是哪一版算出來的」。 如果那張紙上沒有版號,這個問題無解——沒有人記得三個月前的那一天系統是哪一版, 而在那之後可能已經修過三次計算邏輯。

有了頁尾那一行,同一個回報就從「無法重現」變成「用 a3f9c21 重跑一次」。 做法見列印與匯出第 6 條。

本地建置要顯示 dev​

v1.4.2 · dev ← 有人在自己機器上建的
v1.4.2 · a3f9c21 ← CI 建的,可追溯

這一個字省下的是另一種難查的問題:有人把本機建置的版本部署上去, 之後所有回報都對不到任何一個 commit。顯示 dev 讓這件事在第一眼就被看見。

✅ 這樣做

版號字串只有一個來源,側邊欄、關於頁、列印頁尾共用同一個匯出。

🚫 不要這樣

不要在列印頁尾另外寫一次版號。兩處手寫的版本字串一定會分歧, 而分歧的那一份通常就是印在紙上的那一份——最不容易被發現、也最需要正確的那一份。

✅ 這樣做

注入機制要防呆:常數沒被注入時(例如某個測試環境直接跑原始碼) 也不能讓畫面崩掉,退成 dev 即可。

🚫 不要這樣

不要讓「顯示版號」這件小事有能力讓整個應用程式白屏。 它的重要性在於出事時能追溯,不在於它自己出事。

為什麼元件版號與 token 分開​

元件是要被複製走的。使用者複製之後,它就是使用者的程式碼—— 再幫他管版號沒有意義。元件版號存在的目的只有一個:讓人知道自己抄的是哪一版, 方便日後對照上游的修正。

也因為如此,版號檔放在元件庫裡面(packages/react/src/version.ts), 不只是 package.json。元件是要被複製走的——複製走之後 package.json 留在原地, 而使用者最需要的資訊正是「我手上這份是哪一版」。