版本策略
三層各自獨立版號,因為它們的變動頻率與影響半徑完全不同。
| 套件 | 版號意義 |
|---|---|
@dooping/tokens | 契約。變動影響所有宿主 |
@dooping/react | 參考實作。使用者複製走之後就與版號脫鉤 |
| 文件站 | 跟著 repo,不獨立發版 |
進版是什麼事件
dev ──開發、CI 確認──▶ main ──▶ 文件站/Storybook/registry 自動更新
│
└─ token 內容有變才 bump npm 版號
dev 併進 main 就是一次進版。 其他系統一律以 main 為參照,不看 dev。
一次進版對應
CHANGELOG 的一則。
寫不出「取用者要做什麼」的變更,就不是一次進版,繼續留在 dev。
兩條散佈通道的更新時機不同,這個區分很重要:
| 通道 | 何時更新 |
|---|---|
| 文件站 / Storybook / registry | 每次進版都更新(它們反映 main 的當下狀態) |
npm @dooping/tokens | 只在 token 內容真的變更時 bump |
預覽站不是進版
dev 推上去後會部署到 /preview/,給維護者在真實裝置上確認 UI——
尤其是響應式與觸控目標,那是開發機看不出來的。
/preview/ 的內容隨時會被下一次 dev push 覆蓋,而且沒有經過進版。
其他系統一律以 main 的正式網址為準,不得參照任何 /preview/ 底下的東西。
預覽站的 registry 檔案雖然存在,內容裡的相依網址仍指向正式站—— 就算誤複製了安裝指令,裝到的也是正式版本。
兩個對外訊號
| 訊號 | 給誰看 | 什麼時候動 |
|---|---|---|
規範版號 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.z | npx 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 與進版當下的漂移提示) | 宣告了卻忘記發佈——不擋,但一直出聲到發為止 |
進版流程:工程師提議,維護者在 GitHub 上確認
① dev 上:依判準表 bump 規範版號 + 寫 CHANGELOG(這是「提議」)
② 開 dev → main 的 Pull Request
└ CI 在 PR 上把關:守衛全綠?內容變了版號有沒有跟上?
③ 在 GitHub 看 PR(版號、CHANGELOG、檢查結果)→ 合併 = 確認
④ 部署成功後自動蓋上 tag vX.Y.Z(版號沒動就不打,重跑不重複打)
⑤ 自動發 GitHub Release:notes=CHANGELOG 該則,標題帶 tokens 配對版
tag 是自動蓋章,不是閘門——閘門是第 ③ 步的合併。日期不佔 tag 名稱, 在 tag 描述與 CHANGELOG 裡。
第 ⑤ 步是給取用端的推播訊號:Watch 這個 repo 的 Releases(或訂
releases.atom)就會在每次進版時被主動告知,notes 直接是 CHANGELOG 該則全文——
通知本身回答三問。純文件進版不發 Release,「沒有訊號=不需要動作」。
訂閱方式、收到訊號後的判斷與每層更新程序,見跟上新版。
第 ④ 步讀的是合併當下根目錄 package.json 的 version。所以如果 dev 上累積了
好幾次 bump 才併,只有最後那一個版號會有 tag。
這不是缺陷——依上面第一條,dev 併進 main 才算一次進版,中間那些版號從來沒有
任何取用端拿得到,它們是工作狀態。CHANGELOG 要照這個事實寫:一次合併一則,
底下用 ### 分工作項,不要把中間版號寫成好幾則發佈。
實際發生過:v0.3.0 沒有 tag,因為它是隨 v0.4.0 那次合併一起釋出的。
標題格式是 ## vX.Y.Z · YYYY-MM-DD,後面可以再加 merge commit 的 short SHA。
但它不能是「合併前改名」的前置條件——那個 SHA 是 dev → main 的 merge commit,
合併前根本不存在。原本的規則把它寫成必填,結果連續三次進版都乾脆跳過改名,
於是 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 定義
| 變更 | 級別 |
|---|---|
| 移除或改名一個 token | major |
改變一個 token 的語意(--warning 從黃變紅) | major |
改變 --radius 之類會影響全站外觀的基準值 | major |
| 新增 token | minor |
| 微調色值但語意不變(對比修正) | patch |
| 修正 CSS 產物的 bug | patch |
什麼不該發版
上面那張表定義了「什麼變更算哪一級」,但漏了另一半——不是每次進版都該動 npm 版號:
| 變更 | npm 版號 | 進 main |
|---|---|---|
封裝設定(exports、files) | 不 bump | ✅ |
| CI、建置腳本、測試 | 不 bump | ✅ |
| 文件、註解、重構、改名 | 不 bump | ✅ |
這些照樣進 main、文件站與 registry 照樣更新,但躺著等下次真的有 token 變更時順路帶出去。
移除 token 前先標記棄用,保留至少一個 minor 版本, 並在 CHANGELOG 寫清楚替代方案。
不要「順手改名」。token 名稱是契約,改名對所有宿主都是編譯失敗 或更糟——樣式默默失效。
不要為了驗證發佈管線而發版。沒有內容變更的版本會稀釋
「token 幾乎不會被改,所以它才是契約」這個主張——取用者看到版號一直跳,
就不會相信它穩定。要驗證管線請用 --dry-run。
元件的 SemVer 定義
| 變更 | 級別 |
|---|---|
| 移除或改名 prop | major |
| 改變預設行為(例如分頁預設值) | major |
| 新增 prop(有預設值) | minor |
| 新增元件 | minor |
| 視覺微調、修 bug | patch |
視覺變更也算變更。 「只是把 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 寫給誰看
寫給要升級的人,不是寫給自己。每一則要回答:
- 改了什麼
- 我需要做什麼(不需要就明說「無須調整」)
- 為什麼改(連到 ADR)
文件版本對映產品版號
上面講的是三層各自怎麼編號。但有兩個問題完全在讀者那一側,規則本身回答不了: 我現在讀的這頁文件對應哪一版產品?我手上這份產出物是哪一版產的?
文件站與產品同 repo 時,很容易讓文件永遠只有一個版本=主線最新狀態。 問題在於產品有版本,而使用者手上的不一定是最新版:
「文件說設定頁有這個開關,我這裡沒有。」 「喔那是下一版才有的。」
這句對話會反覆發生,而每一次都在消耗使用者對文件的信任。 發生幾次之後,他不會再查文件,他會直接問人——那就是文件站失效的那一刻。
做法:文件也做版本快照,版號直接沿用產品版號。
| 版本 | 內容 | 標示 |
|---|---|---|
current(掛在 /next) | 開發中、尚未發佈 | 未發佈橫幅,每頁常駐 |
| 最新快照 | =正式環境現在跑的版本 | 預設顯示 |
| 舊快照 | 還有人在用的舊版 | 版本下拉可切 |
三個實作細節,每一個都對應一種會出錯的方式:
- 預設顯示「最新快照」,不是
current。 預設值決定 99% 的讀者看到什麼;current是還沒發佈的內容,把它當預設等於讓多數讀者讀到一份描述未來的文件。 - 未發佈的內容要有橫幅,而且要在每一頁。 讀者多半從搜尋引擎或別人貼的連結 直接落到某一頁,他不會經過首頁,也不會去看下拉選單。
- 設定要動態讀取已有的快照清單。 手寫版本清單會撞上先有雞還先有蛋:
設定裡宣告了
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 留在原地,
而使用者最需要的資訊正是「我手上這份是哪一版」。