---
title: "出版管線 — 設計文件 v2.0（定稿）(FR-173)"
brand: "Guidant AI · **FR-173** 出版管線"
eyebrow: "FR-173 · 設計文件 v2.0 · 定稿 · 2026-11-04"
h1: "設計 v2.0（定稿）：正式發版、直接部署、測試版發布三條路"
subtitle: "正式發版選定受保護 tag 執行後全自動發布給客戶；直接部署把任一 branch 快速裝到內部三台；測試版發布（Beta）讓內部三台走更新中心測升級。三條路共用一支主設定檔分流，各自一組子設定與參數"
lede: "🔴 **這份是設計決策，不是現況。** 本版是完整的設計文件，不必對照 v1.0 閱讀；v1.0（design.md，2026-11-03 定稿）保留供稽核查閱。決策者 2026-11-04 定稿；內文的數字與現況停在當天。"
chips: [
  {text: "正式發版：選 tag 執行一次，自動到客戶", kind: ok},
  {text: "內部三台（190／STG／POC）皆可直接部署與測試升級", kind: accent},
  {text: "影響 BE、Agent、FE、update-center 四個 repo", kind: warn},
]
footer: "FR-173 設計文件 v2.0 · 與 v1.0 的異動見 §11 · 沿革見 handoff/FR-173-LOG.md 與 git log"
---

## 1. 概述 {#intro nav="概述"}

出版管線負責把 Guidant AI 的原始碼變成可以安裝的版本，並送到需要它的主機。本案把這件事從「188 手動出包、決策者 Mac 手動簽章與發布」改成 GitLab CI 自動執行；簽章私鑰存放在自架 Vault，任何人（含管理員）都無法取出。

出版管線有三條路。三條路都從同一支 `.gitlab-ci.yml` 觸發，由主設定檔依參數 `pipeline` 分流到各自的子設定檔（§4.9），每條路有自己的參數與流程圖，互不共用 job：

| 路 | 用途 | 入口 | 跑哪些檢查 | 終點 | 人工操作 |
|---|---|---|---|---|---|
| ① 正式發版 | 給客戶 | Run pipeline 選受保護 `v*` tag，`pipeline=release` | 全部：測試、弱點掃描；勾選時加跑升級演練 | **自動發布**，客戶的更新中心看得到 | 執行 pipeline |
| ② 直接部署 | 改完程式馬上在內部主機看結果 | Run pipeline 選任一 branch，`pipeline=deploy`，選一台目標主機 | 不跑 | 安裝到所選的那一台（190、STG 或 POC，一次一台） | 執行 pipeline；POC 要再按 ▶ 確認 |
| ③ 測試版發布（Beta） | 測試「透過更新中心升級」是否正常 | Run pipeline 選任一 branch，`pipeline=test-release` | 不跑 | 更新站上一筆只有內部三台看得到的版本 | 執行 pipeline；到目標主機更新中心按更新 |

**內部三台**：192.168.50.190（測試機，可隨意使用）、192.168.50.188（STG）、192.168.50.189（POC）。三台都是公司自己的主機。STG、POC 用於內部 Demo 與 UAT，要更新它們代表這個版本已經準備給內部展示；Demo 與 UAT 完成後才會走 ① 正式發版給客戶。

**設計目標**：

1. 正式發版的人工操作只有一個動作：在 GitLab Run pipeline 選定版本 tag 執行，之後自動發布給客戶。版本 tag 由進版流程（`version-bump`）建立。
2. 開發期間的部署與測試不跑弱點掃描、不跑完整測試，盡可能快。
3. 簽章私鑰不可匯出；CI 以短效身分向 Vault 請求簽署；只有決策者觸發的 pipeline 能取得簽章權限。
4. 測試金鑰簽的版本只有內部三台認得，客戶主機無法安裝，也看不到。
5. 客戶端驗章程式零改動。

**不在範圍內**：update-center 與 License Center 本身的部署；客戶主機執行期密碼（另案）；Nuitka 編譯加速。

## 2. 背景 {#background nav="背景"}

### 2.1 本案之前的出版方式

FR-167 建立了線上更新通道：188 的 CI 出包、上傳 R2、在更新站登記草稿；決策者在 Mac 上執行 `guidant-release publish`，以 Mac 上的私鑰簽章並發布。開發期間要讓 190 透過更新中心測升級，由決策者手動執行 `.fr167-dev/publish/publish2.sh`：從 188 取清單、Mac 以測試金鑰簽章、上傳 R2、直接寫入更新站資料庫。1.27.3～1.28.3、Agent 1.5.1～1.5.5 都是這樣測的。

```{.mermaid cap="圖 1　本案之前的出版流程。簽章與發布都在決策者 Mac 上完成。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','lineColor':'#4A5A5C','textColor':'#14201F','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#F6EBD5','labelBoxBorderColor':'#9C6B12','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF','fontSize':'20px'},'sequence':{'actorFontSize':20,'messageFontSize':19,'noteFontSize':18,'actorMargin':60,'messageMargin':42,'boxMargin':12,'useMaxWidth':false}}}%%
sequenceDiagram
  autonumber
  actor P as 決策者
  participant G as 188 CI
  participant U as 更新站
  participant M as 決策者 Mac
  P->>G: 手動執行 pipeline
  G->>G: 建置、打包、上傳 R2
  G->>U: 登記草稿（CI token，未簽章）
  P->>M: guidant-release publish <版號>
  M->>M: 讀取私鑰（passphrase）、簽署清單
  M->>U: 送回已簽章清單，草稿轉為公開
```

簽章私鑰只在決策者電腦，決策者不在就無法發版；每個版本都要人工簽章與發布，與「自動化」的目標相反。

### 2.2 v1.0 實作後暴露的問題

v1.0（2026-11-03 定稿）把簽章移進 CI、私鑰移進 Vault。實作完成後，決策者於 2026-11-04 審閱時指出三個問題。

**正式發版沒有自動化。** v1.0 的正式發版要經過：打 tag、到更新站後台貼版本說明、指定 190 為內部先裝、到 190 更新中心手動升級、回 GitLab 按發布，共五個人工步驟。原因有三：發布被設計成需要人工確認的手動 job；更新站與 CLI 實作時加了「發布前內部先裝清單不能為空、每台都要成功」的硬性檢查（FR-167 設計原本只寫「可指定」）；版本說明自動送出沒有完成。

**開發測試太慢。** v1.0 的測試包是正式發版 pipeline 的一個參數（`internal_test`），與正式發版共用 verify、build 等段落。改一個小地方要等完整流程。

**內部主機被限縮成只有 190。** v1.0 認為「測試金鑰任何 branch 都能簽，信任它的機器等於信任未經審查的程式碼」，因此測試金鑰公鑰只放 190，`deploy_e2e.sh` 也禁止測試包裝到 STG、POC。實際上 STG、POC 是公司內部的 Demo 與 UAT 環境，決策者要更新它們時，就代表該版本已準備給內部展示，不需要用金鑰擋。

此外，BE 的 `.gitlab-ci.yml` 以一個入口、六個參數組合出五種情境（正式、測試包、直接部署 STG／POC、只部署、弱點資料每日發布），每個 job 都要寫規則排除不該跑的情境，難以維護。

### 2.3 現有基礎設施

| 元件 | 現況（2026-11-04） |
|---|---|
| GitLab | gitlab.com 免費版。`secrets:`、`needs:project` 為付費功能，不可用 |
| 建置主機 192.168.50.188 | 24 核、31 GB；shell runner 兩個（auditmanager、commonservice3 群組），`concurrent=3`；五個 repo clone 在 `/opt/`；沒有 node |
| Vault 192.168.50.172 | HashiCorp Vault 2.2.1 Community，Transit 引擎；正式簽章金鑰 `guidant-release`（kid `05086455d42ca0d6`）、測試簽章金鑰 `guidant-test`（kid `128cfec6fc9877b8`），皆 `exportable=false`；JWT 登入已認 gitlab.com；角色 `release-ci`／`test-ci`／`fe-ci`／`classifier-ci` |
| 更新站 | Cloudflare Worker＋D1＋R2；版本狀態為草稿／已發布／已撤回；草稿只對「內部先裝清單」上的主機指紋可見；發布端點認 GitLab ID token；只接受印章表登記為 primary／backup 的 kid |
| 客戶端 | 以 `/etc/guidant/release-keys/*.pub` 驗章，支援多把 kid；正式金鑰公鑰自 1.28.0 起隨安裝包裝入 |
| 主系統建置時間（188 實測） | 完整約 51 分：Nuitka 約 14～19 分、映像弱點掃描與 SBOM 約 9 分、封包壓縮約 13 分 |

## 3. 需求 {#requirements nav="需求"}

### 3.1 功能需求

| 編號 | 需求 |
|---|---|
| R1 | 正式發版由 Run pipeline 選定受保護 `v*` tag 觸發，自動執行到客戶可見為止，不需其他人工操作 |
| R2 | 正式發版的版本說明（release note 檔尾三段）由 CI 隨版本送至更新站 |
| R3 | 正式發版可選擇是否執行升級演練（從指定舊版、帶真實資料升級到新版），預設不執行 |
| R4 | 正式簽章只在受保護 `v*` tag、且由決策者觸發的 pipeline 可取得 |
| R5 | 直接部署：任何 branch 可執行，不跑測試與弱點掃描，一次安裝到一台（190、STG 或 POC）；換下一台不必重新建置 |
| R6 | 測試版發布：任何 branch 可執行，不跑測試與弱點掃描，以測試金鑰簽章、登記到更新站，只有內部三台看得到，客戶看不到 |
| R7 | 內部三台皆安裝測試金鑰公鑰；客戶主機不安裝 |
| R8 | 三條路各自一個入口與一組參數，互不影響 |
| R9 | Vault 不可用時，決策者仍可在 Mac 以備用金鑰發布 |
| R10 | Agent 有同樣的三條路 |

### 3.2 限制條件

| 編號 | 限制 | 來源 |
|---|---|---|
| C1 | GitLab 免費版：無 `secrets:`、無 `needs:project`、受保護 tag 只能選角色不能指定人 | gitlab.com 方案 |
| C2 | 客戶端驗章程式不改；簽章演算法維持 Ed25519 | 已出貨客戶機 |
| C3 | 客戶端一律拒收未簽章的清單 | `package_verifier.py` |
| C4 | 188 沒有 node；BE 與 Agent 的 Nuitka 不能同時執行 | 188 實測 |
| C5 | 安裝包（主系統約 2.5 GB、Agent 約 1.3 GB）超過 gitlab.com artifacts 1 GB 上限，只能留在 188 | gitlab.com 限制 |
| C6 | 188 工作區由三條路共用（`GIT_STRATEGY: none`） | 建置時間與磁碟成本 |
| C7 | 更新站以（產品、版號）為鍵，同一版號只有一筆紀錄 | `product_releases` 表 |

## 4. 方案比較 {#options nav="方案比較"}

### 4.1 簽章金鑰存放與簽署方式

**問題**：私鑰要離開決策者 Mac 才能無人值守，但不能落到 CI 變數或任何人能讀到的地方。

| 方案 | 做法 | 缺點 |
|---|---|---|
| 自架 HashiCorp Vault Transit | 專用 VM，Transit 引擎存 Ed25519 金鑰，`exportable=false`；CI 以 OIDC 短效身分請求簽署 | 多一台要維運的主機；要自己做快照 |
| 雲端 KMS（Azure Key Vault／AWS KMS） | 託管金鑰服務 | 不支援 Ed25519，要換 P-256 並改驗章程式，違反 C2 |
| update-center Worker Secret | 私鑰放 Worker 環境變數 | 程式讀得到完整私鑰，不算不可匯出 |
| GitLab CI 變數 | 私鑰放 CI/CD 變數 | 任何 Maintainer 可讀 |
| GitLab Secrets Manager | GitLab 內建 | 需 Premium；不提供簽章 |

**決定**：自架 Vault Transit，Community 版，單節點 Raft，VM 192.168.50.172。

**理由**：唯一同時滿足 C2 與「不可匯出」的選項。多一台主機的維運以每日快照與 runbook 承擔。OpenBao（Vault 開源分支）功能相同、API 相容，日後 Vault 授權有變再換。

### 4.2 誰能取得簽章權限

**問題**：GitLab 免費版受保護 tag 只能選角色（C1），而五位成員都是 Maintainer 以上，用 GitLab 權限擋不住「別人打 tag 發版」。

**決定**：Vault 角色 `release-ci`（受保護 tag）與 `test-ci`（任何 branch）都以 `bound_claims` 綁決策者的 GitLab `user_id`。其他人觸發的 pipeline 在 Vault 登入時即被拒。加人只要改角色的 `user_id` 陣列，不改程式。

**理由**：免費版唯一可行的「指定人」做法，判定發生在簽章那一步，正好是最該守的地方。升級 GitLab Premium 可在 GitLab 端指定人，但仍需 Vault 做簽章，費用不划算。

### 4.3 簽章時機

**問題**：FR-167 的順序是「先登記未簽章草稿、試裝後再簽章發布」。但客戶端一律拒收未簽章的清單（C3），內部主機也是客戶端，未簽章的版本根本裝不上。

**決定**：打包後立即向 Vault 請求簽署，登記到更新站的就是已簽章的版本。正式發版以正式金鑰簽，測試版發布以測試金鑰簽。

**理由**：唯一讓「內部主機裝的」與「客戶拿到的」完全是同一份簽章的做法；簽章時機也與「發布」脫鉤，自動發布才可能。

### 4.4 正式發版是否需要人工確認發布

**問題**：v1.0 在正式發版 pipeline 尾端留一個手動 job「publish」，並要求發布前內部主機先透過更新中心裝過。這讓正式發版多出四個人工步驟，違背本案「自動化」的目的。

| 方案 | 做法 | 缺點 |
|---|---|---|
| 自動發布 | 登記版本後立即呼叫發布，客戶即可見 | 客戶看到之前沒有最後一次人工確認 |
| 保留手動發布 job | v1.0 | 執行之後還要回 GitLab 按一次；pipeline 長時間處於未完成 |
| 發布前強制內部試裝 | v1.0 實作 | 三個人工步驟 |

**決定**：自動發布。更新站「發布前內部先裝清單不能為空、每台都要成功」的檢查移除。

**理由**：選定版本 tag 執行正式發版，本身就是「這個版本要給客戶」的決定。在此之前，版本已經過開發期間的直接部署與測試版發布驗證，並在 STG、POC 完成內部 Demo 與 UAT；之後再要求人工確認，等於同一個決定做兩次。發布前的自動把關仍在：只有決策者觸發的受保護 tag 能取得正式簽章；測試與弱點掃描任一失敗就不會走到發布；勾選演練時演練失敗也不會發布。發布後發現問題，可在更新站後台撤回，尚未升級的客戶就不會再看到。

### 4.5 升級演練是否每次執行

**問題**：v1.0 每次正式發版都在 190 清機、安裝舊版、匯入 STG 資料、以新包升級、比對資料筆數，約 15～20 分鐘。決策者指出：開發期間已在內部主機測過升級，正式發版時強制再跑一次不合理。

演練模擬的是「客戶停在某個舊版、一次跳到新版、帶真實資料」這條路。開發期間的內部主機是一版接一版升上來，每次只跨一小步，兩者覆蓋的範圍不同；1.21.0 出包前就是靠演練抓到 migration 順序依賴（`fr093-4d`）。

**決定**：可選。正式發版參數 `rehearse`（預設關），勾選時以 `rehearse_base`（預設 1.27.0）為起點演練，成功才繼續簽章與發布。演練在打包之後執行，使用與出貨相同的安裝包。

**理由**：要不要花 15～20 分鐘驗跨版升級，是發版人對這一版風險的判斷。建議在本版新增了 migration、且有客戶停在較舊版本時勾選，起點設為客戶目前最舊的版本。

### 4.6 開發期間的部署與測試

**問題**：v1.0 的測試包掛在正式發版 pipeline 裡，與正式發版共用段落與參數；而且只能裝 190。決策者要的是兩件不同的事：改完程式馬上在內部主機看結果；以及測試「透過更新中心升級」這條客戶實際會走的路是否正常。

| 方案 | 做法 | 缺點 |
|---|---|---|
| 兩條獨立的路：直接部署、測試版發布 | 各自一個入口；都不跑測試與弱點掃描；三台皆可 | 多兩個入口 |
| 一條路，以參數切換「直接裝」或「上更新站」 | — | 又回到一個入口多種情境 |
| 維持 v1.0 | 測試包是正式發版的參數 | 見 §2.2 |

**決定**：兩條獨立的路。

- **直接部署**：建置、打包，以 `install.sh --upgrade` 直接安裝到所選主機。不簽章（`install.sh` 不驗發布清單簽章），不經更新站。最快。
- **測試版發布**：建置、打包，以測試金鑰簽章，上傳 R2，登記到更新站；內部清單自動帶入三台。到目標主機的更新中心按更新，走的是客戶實際會走的路：下載、驗章、八步升級、失敗退回。

**理由**：兩者驗的東西不同。直接部署驗的是「新功能對不對」，要快；測試版發布驗的是「更新中心升級這條路對不對」，要跟客戶走同一條路。合在一條會讓每次開發測試都多一段上傳與登記。

### 4.7 內部主機的範圍與測試金鑰

**問題**：v1.0 只把測試金鑰公鑰裝在 190，理由是「任何 branch 都能簽測試金鑰，信任它等於信任未經審查的程式碼」，連帶讓開發期間的部署與測試只能用 190。

**決定**：內部三台（190、STG、POC）都安裝測試金鑰公鑰，直接部署與測試版發布三台都可用。客戶主機不安裝（測試金鑰公鑰不進安裝包）。

**理由**：三台都是公司的機器。190 可隨意使用；STG、POC 用於內部 Demo 與 UAT，決策者要更新它們本身就是「這版要給內部看」的決定，誰能觸發仍由 Vault 角色綁 `user_id` 把關。客戶與測試金鑰的隔離不變：測試金鑰簽的版本在更新站只對內部三台可見，且更新站不允許測試金鑰的版本被發布（測試金鑰的 kid 不登記在印章表）。

### 4.8 測試版發布的版號

**問題**：更新站以（產品、版號）為鍵（C7）。測試版發布若和正式版用同一個版號，兩者會在更新站撞成同一筆紀錄。2026-11-04 實測：以 1.28.3 執行測試版發布，更新站回 `409 version_already_published`（1.28.3 已在前一天以 Mac 測試章發布過）。

| 方案 | 做法 | 缺點 |
|---|---|---|
| 測試版用預發版號 `X.Y.Z-beta.N` | 正式版 `X.Y.Z`、測試版 `X.Y.Z-beta.N`，兩者格式不同 | 開發期間每出一次測試版要遞增 N |
| 測試版用尚未正式發布的版號 | 測試版與正式版共用 `X.Y.Z`，正式發版覆蓋同版號測試草稿 | 版號一旦正式發布就不能再出測試版，要先進版；容易撞號 |

**決定**：測試版一律用預發版號 `X.Y.Z-beta.N`，正式版就是 `X.Y.Z`。

- **格式**：只收 semver 寫法，例如 `1.29.0-beta.1`。PEP 440 的 `1.29.0b1` 視為格式錯誤（更新站只認 semver）。
- **CI 擋法**：主系統與 Agent 的測試版發布在第一步檢查版號，不帶 `-beta.N` 就停止；正式發版的版號帶 `-beta` 也停止。
- **排序**：`1.29.0-beta.1 < 1.29.0-beta.2 < … < 1.29.0`；N 以數字比較（`beta.10 > beta.9`）。主系統升級（`upgrade_common.sh`）、Agent 升級（`upgrade_order.sh`）、主系統判斷 Agent 包新舊（`package_verifier.py`）、更新站（`versions.ts`）四處採同一套排序。內部主機從 `beta.N` 升到正式版 `X.Y.Z` 會被判定為升級。
- **更新站的保護仍在**：已正式發布的版號被測試金鑰登記時，回 `409 version_already_published`。

**理由**：正式版與測試版格式不同，永遠不會撞號，也不必為了測一次升級去動正式版號。版號本身就看得出這一版是不是測試版，客戶與內部人員都不會混淆。

### 4.9 三條路的入口分離

**問題**：v1.0 一支 `.gitlab-ci.yml` 以六個參數組合五種情境，每個 job 都要寫規則排除不該跑的情境。

**決定**：GitLab 一個專案只有一支 CI 入口 `.gitlab-ci.yml`，所以「三個入口」的做法是在主設定檔分流：主設定檔宣告一個下拉式參數 `spec.inputs.pipeline`（`release`／`deploy`／`test-release`，預設 `deploy`），Run pipeline 時在畫面上選；主設定檔的 `include:` 以 `rules:` 依這個值**一次只載入一支路的子設定檔**。子設定檔裡的 job 不寫「我屬於哪條路」的規則，因為沒被選到的那幾支根本不會載入。BE、Agent 各自如此。

```yaml
# .gitlab-ci.yml（示意）
spec:
  inputs:
    pipeline: { options: [release, deploy, test-release], default: deploy }
---
include:
  - local: .gitlab/ci/common.yml                         # 一律載入
  - local: .gitlab/ci/release.yml
    rules: [ { if: '"$[[ inputs.pipeline ]]" == "release" && $CI_PIPELINE_SOURCE != "schedule"' } ]
  - local: .gitlab/ci/deploy.yml
    rules: [ { if: '"$[[ inputs.pipeline ]]" == "deploy" && $CI_PIPELINE_SOURCE != "schedule"' } ]
  - local: .gitlab/ci/test-release.yml
    rules: [ { if: '"$[[ inputs.pipeline ]]" == "test-release" && $CI_PIPELINE_SOURCE != "schedule"' } ]
  - local: .gitlab/ci/vulndb.yml
    rules: [ { if: '$CI_PIPELINE_SOURCE == "schedule"' } ]
```

子設定檔放在 `.gitlab/ci/`，共五支：

| 檔案 | 內容 |
|---|---|
| `common.yml` | 三條路共用的片段（`.vault`、`.sync_repo`、`.in_repo`、`.collect`）：Vault 登入、同步 188 工作樹、進工作樹算版號、收集產物。無條件載入，本身不含 job |
| `release.yml` | 正式發版。「只准受保護 tag」的檢查在這支的 verify job 與 Vault `release-ci` 角色兩處，不在主設定檔 |
| `deploy.yml` | 直接部署 |
| `test-release.yml` | 測試版發布 |
| `vulndb.yml` | 弱點資料每日發布。只在排程觸發時載入；排程時三條路的子設定檔都不載入 |

觸發方式一律是 GitLab「Run pipeline」（網頁或 `glab ci run`），推 tag、push 都不會觸發 BE／Agent 的 pipeline（`workflow:rules` 只收 web／api）。正式發版不用「推 tag 觸發」，原因有二：推 tag 觸發的 pipeline 沒有參數畫面，`rehearse` 等參數只能用預設；FE 沒有同名 tag，`fe_ref` 預設的「同名」會找不到。

| 路 | 參數 |
|---|---|
| 正式發版 | `rehearse`、`rehearse_base`、`fe_ref` |
| 直接部署 | `target`（`e2e`／`stg`／`poc` 單選，預設 `e2e`）、`skip_be`、`fe_ref`、`reuse_bundle`（不建置，直接拿 188 上同版號的包；換下一台時用） |
| 測試版發布 | `skip_be`、`fe_ref`、`note`（選填，500 字內，一句話說明這版要測什麼） |

**直接部署一次一台**：`target` 是單選。一條 pipeline 同時升多台，失敗時看不出是哪一台、也不好追蹤；改成一次一台，要換下一台就再跑一次並勾 `reuse_bundle`，拿 188 上已打好的同一份包，約 2～5 分。`target=poc` 時部署 job 是手動 job，pipeline 跑到那一步會停下，按 ▶ 才升級 POC。

**版本說明的來源**：正式發版一律讀 release note 檔尾「更新中心顯示文字」三段，缺就停止。測試版發布的版本說明是選填：填了 `note` 就以它為唯一一條版本說明；沒填就讀 release note 檔尾；兩者都沒有就送空白並印警告，照常登記。測試版只給內部看，不必為了測一次升級去寫正式的版本說明。

**理由**：分流集中在主設定檔一處，同一支子設定檔只有一種情境，job 不必寫排除規則；修改一條路不會影響另一條。另一種做法是所有 job 都載入、每個 job 各自寫 `rules:` 判斷 `pipeline` 的值，那就回到 v1.0「每個 job 都要寫一排不跑條件」的問題，因此不採用。用下拉式參數而不是手打變數，是為了在 Run pipeline 畫面上直接選，不會打錯字。

### 4.10 同一台主機上建置產物的交接

**問題**：FE pipeline 在 188 建好 FE 映像、推上 Harbor 後隨即刪除本機那份；BE 再從 Harbor 拉回。推、刪、拉都在同一台主機，白走一趟 Harbor，Harbor 不可用時 BE 卡住。盤點 188 上所有跨 pipeline 交接，只有這一處如此。

**決定**：本機優先、registry 備查。FE 建完不刪本機映像；BE 先查本機有無 `guidant-ai-fe:<版號>-<FE commit>`，有就直接使用，沒有才拉 Harbor。推 Harbor 照舊。

**理由**：改動小，不動 pipeline 結構；本機沒有時仍能從 Harbor 取得，runner 日後分機也能運作。

### 4.11 四個 repo 的 CI 分工

**決定**：BE、Agent、FE、jedi 各一條 CI，全部跑在 188 shell runner。BE 以 `trigger:`（`strategy: mirror`）觸發 FE 建置映像；Agent 主控台前端在 188 對 `/opt/guidant-ai-fe` 同名 branch 以 FE Dockerfile `console-dist` 目標打包；jedi 套件 push 時測試、BE 觸發時建置分類映像。BE 與 Agent 的 Nuitka 段以 188 主機鎖 `/home/jedi/.guidant-build.lock` 串行（GitLab `resource_group` 只在同一專案內有效，C4）。

**理由**：各 repo 對自己負責；188 是唯一有 24 核的主機；171 只有 2 核且跑 Nexus、Harbor。

### 4.12 不納入本案的

| 項目 | 原因 |
|---|---|
| update-center、License Center 進管線 | 獨立系統，範圍過大 |
| 客戶主機執行期密碼 | Vault 解不了（客戶內網連不到），另案 |
| Nuitka 編譯加速 | 直接部署的主要耗時在此（約 14 分），另案評估 |
| 正式發版專用 runner | 隔離 188 工作區共用風險的根本解法，本案不做；見 §9 |
| Sigstore keyless | 客戶離線無法驗章，且簽章公開 |
| 客製化客戶專屬版本 | 同一套「草稿＋指紋清單」機制可讓特定客戶自動拿到專屬版本：專屬版本登記為草稿、清單填該客戶的機器指紋，該客戶的更新中心就會自動看到並升級，其他客戶看不到。差別在簽章要用正式金鑰（客戶機沒有測試公鑰），且更新站要允許正式金鑰的草稿長期只對指定指紋可見。待有客製化客戶時另案 |

## 5. 決策摘要 {#decisions nav="決策"}

本節只做索引，理由在 §4。

| 編號 | 題目 | 定案 | 需求 |
|---|---|---|---|
| D1 | 簽章金鑰 | 自架 Vault Transit，Ed25519，`exportable=false` | R4、C2 |
| D2 | 簽章權限 | Vault 角色綁決策者 `user_id`；`release-ci` 只認受保護 `v*` tag | R4 |
| D3 | 簽章時機 | 打包後立即簽章 | C3 |
| D4 | 正式發版的發布 | 自動發布；移除發布前內部先裝檢查 | R1 |
| D5 | 升級演練 | 正式發版可選，勾選 `rehearse` 才跑 | R3 |
| D6 | 開發期間 | 直接部署、測試版發布兩條獨立的路，皆不跑測試與弱點掃描；直接部署一次一台，POC 需按 ▶ 確認 | R5、R6 |
| D7 | 內部主機 | 190、STG、POC 皆可用；三台皆裝測試金鑰公鑰 | R5、R6、R7 |
| D8 | 測試版版號 | 測試版一律 `X.Y.Z-beta.N`，正式版 `X.Y.Z`，CI 互相擋 | C7 |
| D9 | 入口分離 | 主設定檔依 `pipeline` 參數分流到子設定檔；觸發一律 Run pipeline | R8 |
| D10 | 版本說明 | CI 隨版本登記一併送出 | R2 |
| D11 | 產物交接 | 同一主機本機優先，registry 備查 | — |
| D12 | 四 repo CI | 各一條；BE 觸發 FE；188 主機鎖串行 Nuitka | R10、C4 |
| D13 | 備用路徑 | Mac 備用金鑰 `guidant-release publish --signer file` | R9 |
| D14 | 建置期憑證 | Nexus、Harbor、R2、更新站 CI token、License Center token 全部進 Vault KV；188 磁碟不留明文 | — |

## 6. 架構設計 {#architecture nav="架構"}

### 6.1 系統架構

```{.mermaid cap="圖 2　元件與連線。GitLab 在雲端，不主動連入內網；所有連線由內網主機發起。紅底為持有機敏資訊的元件。"}
%%{init: {'theme':'base','themeVariables':{'darkMode':false,'background':'#FBFCFC','primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','secondaryColor':'#EEF2F3','tertiaryColor':'#FBFCFC','lineColor':'#4A5A5C','textColor':'#14201F','titleColor':'#14201F','edgeLabelBackground':'#FBFCFC','clusterBkg':'#F6F7F5','clusterBorder':'#C7D1D2','fontSize':'18px'},'flowchart':{'useMaxWidth':false}}}%%
flowchart LR
  P["決策者"]
  subgraph cloud["雲端"]
    GL["GitLab<br/>pipeline、ID token 簽發"]
    US["更新站<br/>Worker＋D1"]
    R2[("R2<br/>安裝包")]
  end
  subgraph lan["公司內網"]
    B["188 建置主機<br/>runner、五個 repo、編譯鎖"]
    V["172 Vault<br/>簽章金鑰、建置期憑證"]
    N["171 Nexus／Harbor"]
    L["189 License Center<br/>防竄改簽章私鑰"]
    I["內部三台<br/>190／188 STG／189 POC<br/>正式＋測試公鑰"]
  end
  C["客戶主機<br/>只有正式公鑰"]
  P -->|選 tag 或 branch 執行 pipeline| GL
  B -->|領取 job| GL
  B -->|ID token 登入、請求簽署、讀取憑證| V
  V -->|取 JWKS 驗 ID token| GL
  B -->|裝套件、推映像| N
  B -->|送防竄改清單簽署（防安裝後程式被改）| L
  B -->|上傳安裝包| R2
  B -->|登記版本、發布（Cloudflare Access 服務憑證）| US
  B -->|直接部署：install.sh| I
  I -->|檢查更新：已發布＋內部草稿| US
  C -->|檢查更新：只有已發布| US
  I -.->|下載| R2
  C -.->|下載| R2
  style V fill:#FDECEC,stroke:#B42318,color:#7A1A12
  style L fill:#FDECEC,stroke:#B42318,color:#7A1A12
```

**兩種簽章**：管線裡有兩種不同用途的簽章，私鑰放在不同地方。

| | 防竄改簽章（FR-064） | 出版簽章（本案） |
|---|---|---|
| 簽什麼 | 映像內每個程式檔的 SHA-256 清單（`integrity-manifest.json`、Agent 為 `manifest.json`） | 安裝包的雜湊、大小、版本資訊（發布清單） |
| 誰簽 | License Center（189）的私鑰；建置時送簽 | Vault（172）的正式或測試金鑰 |
| 誰驗、何時驗 | 主系統：每次啟動時驗，執行期間每 4 小時抽查一次。Agent：每次啟動時驗；升級時由舊版先驗過新版才執行 | 主系統或 Agent 安裝前（更新中心下載後、離線上傳時） |
| 防什麼 | 安裝後程式檔被修改 | 安裝包被調包或來源不明 |
| 哪幾條路需要 | 三條路只要重新編譯就需要 | 正式發版（正式金鑰）、測試版發布（測試金鑰）；直接部署不需要 |

防竄改清單沒有簽章時，`build_image.sh` 拒絕打成映像，因此三條路只要重新編譯 BE 或 Agent，都要以 Vault 取 License Center 憑證送簽。

**為什麼防竄改清單用 License Center 簽、不用 Vault**：FR-064 決策 D2 規定授權檔、防竄改清單、解鎖碼三種憑證共用 License Center 同一把私鑰，產品端用同一把公鑰、同一支驗章程式（`verify_payload`）驗。要繞過防竄改就得改驗章程式，而改壞它授權也一起失效，系統直接鎖住（「拆一個等於拆兩個」）。若防竄改改由 Vault 簽，就變成兩把鑰匙、兩條驗章路徑，可以只拆防竄改而授權照常運作；已出貨客戶機也要先升一版才認得新鑰。因此維持 License Center 簽。提高這把鑰匙保護的方向見 §6.7。

**CI 呼叫更新站後台端點**：更新站的 `/admin/*`（登記版本、發布）前面有 Cloudflare Access。CI 呼叫時除了原本的身分（登記用 CI token、發布用 GitLab ID token）外，還要帶 Cloudflare Access 服務憑證（Service Token，請求標頭 `CF-Access-Client-Id`、`CF-Access-Client-Secret`），才通得過 Access。服務憑證存在 Vault `kv/ci/update-site`，Access 應用程式加一條「服務驗證」原則，指定 token `guidant-ci`。更新站程式收到 `Authorization` 時只認 CI token 或 GitLab ID token，不看 Access 傳來的身分，所以程式不必改。不用 Access 的 Bypass（整條路徑放行）規則，是因為 Bypass 會讓任何人都打得到後台端點，只剩更新站程式那一層把關。

更新站對兩種主機回不同的版本：客戶主機只看得到已發布的版本；內部三台的主機指紋在內部清單上，額外看得到測試版發布登記的草稿。內部三台同時信任正式與測試兩把公鑰，客戶主機只信任正式公鑰。

### 6.2 ① 正式發版

```{.mermaid cap="圖 3　正式發版。發版人只做第 1 步，之後全自動到客戶可見；任一步失敗即停止，不會發布。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','lineColor':'#4A5A5C','textColor':'#14201F','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#F6EBD5','labelBoxBorderColor':'#9C6B12','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF','fontSize':'20px'},'sequence':{'actorFontSize':20,'messageFontSize':19,'noteFontSize':18,'actorMargin':60,'messageMargin':42,'boxMargin':12,'useMaxWidth':false}}}%%
sequenceDiagram
  autonumber
  actor P as 發版人
  participant G as GitLab 正式發版 pipeline（188 runner）
  participant F as FE pipeline（188 runner）
  participant V as Vault（192.168.50.172）
  participant L as License Center（189）
  participant H as Harbor／R2
  participant U as 更新站
  participant T as 190 測試機
  participant C as 客戶主機
  P->>G: Run pipeline，選受保護 tag v1.29.0（pipeline=release，可填 rehearse／rehearse_base／fe_ref）
  G->>G: verify：確認受保護 tag、同步 188 工作樹、守衛測試、pytest、原始碼與相依套件弱點掃描
  G->>F: 觸發 FE pipeline（strategy: mirror）
  F-->>G: FE 映像建於 188 本機（tag 帶 FE commit），並推 Harbor
  G->>V: 以 ID token 登入（role=release-ci），讀取 License Center／Harbor／R2／更新站憑證
  G->>G: Nuitka 編譯（188 編譯鎖）
  G->>L: 送防竄改清單簽署（FR-064）
  L-->>G: 清單簽章
  Note over G,L: 防竄改：程式啟動時逐檔比對雜湊，被改過就拒絕啟動
  G->>G: BE／init／classifier 映像、取本機 FE 映像
  G->>G: 檢查版本說明：release note 檔尾要有「更新中心顯示文字」三段，缺就停
  G->>G: 映像弱點掃描（Trivy、Grype）：有白名單外的 Critical 漏洞就停
  G->>G: 打包：映像＋安裝腳本＋設定 → 客戶安裝用的 guidant-ai-<版號>.tar.gz
  opt 發版人勾選 rehearse
    G->>T: 演練：清機、安裝 rehearse_base 版、匯入 STG 資料、以新包升級、比對筆數
    T-->>G: 升級成功
  end
  G->>H: 推映像（tag 帶 commit）、上傳安裝包
  G->>V: transit/sign guidant-release（canonical 清單）
  V-->>G: 簽名（私鑰不離開 Vault）
  G->>U: 登記版本：已簽章清單＋版本說明三段
  G->>U: 發布（GitLab ID token）
  U->>U: 核：BE 專案、受保護 tag、tag＝v＋版號、kid 為印章表 primary／backup
  U-->>G: published
  C->>U: 檢查更新
  U-->>C: 1.29.0（正式金鑰簽章）
```

正式發版只有第 1 步是人工。演練與出貨用的是同一份安裝包。任一段失敗（測試、弱點放行門、演練、簽章、上傳），pipeline 即停止，不會走到發布。發布後若發現問題，到更新站後台撤回該版，尚未升級的客戶就不會再看到。

**權限把關**：從 branch 觸發時，verify 在第 2 步失敗（「正式發版只能從受保護的 v* tag 執行」）；即使繞過，Vault 的 `release-ci` 角色只認受保護 tag，登入被拒。非決策者觸發時同樣在 Vault 登入被拒，訊息為「這個 pipeline 不是由允許發版的人觸發」。

**188 工作區共用的防護**（C6）：三條路共用 `/opt/guidant-ai-be`、`.venv`、`.build/image-cache/`。開發期間任一 branch 都可能留下被改過的快取，正式發版不信任它們：不允許 `skip_be`；`.venv` 先 `poetry sync` 回 lock 檔；`image-cache` 只讀不寫，載入後比對 image ID，不符即丟棄重匯。

### 6.3 ② 直接部署

```{.mermaid cap="圖 4　直接部署。不跑測試與弱點掃描、不簽章、不經更新站，一次裝一台內部主機；POC 要按 ▶ 確認。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','lineColor':'#4A5A5C','textColor':'#14201F','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#F6EBD5','labelBoxBorderColor':'#9C6B12','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF','fontSize':'20px'},'sequence':{'actorFontSize':20,'messageFontSize':19,'noteFontSize':18,'actorMargin':60,'messageMargin':42,'boxMargin':12,'useMaxWidth':false}}}%%
sequenceDiagram
  autonumber
  actor D as 開發者
  participant G as GitLab 直接部署 pipeline（188 runner）
  participant F as FE pipeline（188 runner）
  participant V as Vault（192.168.50.172）
  participant L as License Center（189）
  participant I as 目標主機（190、188 STG 或 189 POC 其中一台）
  D->>G: 任一 branch，pipeline=deploy，target 選一台（e2e／stg／poc）；可選 skip_be、reuse_bundle
  alt reuse_bundle
    G->>G: 取 188 上同版號既有安裝包
  else skip_be
    G->>G: 同步 188 工作樹；沿用 188 上同版號 BE 映像
    G->>F: 觸發 FE pipeline
    F-->>G: FE 映像建於 188 本機
    G->>G: 打包（不掃描、不檢查正式公鑰）
  else 一般
    G->>G: 同步 188 工作樹
    G->>F: 觸發 FE pipeline
    F-->>G: FE 映像建於 188 本機
    alt 188 已有本次程式內容的 BE 映像（標記 src-<commit>）
      G->>G: 沿用，不重新編譯
    else 程式有改
      G->>V: 以 ID token 登入（role=test-ci），讀取 License Center 憑證
      G->>G: Nuitka 編譯（188 編譯鎖）
      G->>L: 送防竄改清單簽署（FR-064）
      L-->>G: 清單簽章
      Note over G,L: 防竄改：程式啟動時逐檔比對雜湊，被改過就拒絕啟動
    end
    G->>G: BE／init／classifier 映像、打包（不掃描、不檢查正式公鑰）
  end
  opt target＝poc
    G-->>D: 停在部署 job，等待按 ▶
    D->>G: 按 ▶ 確認
  end
  opt 目標為 STG 或 POC
    G->>I: 停 api／socketio／worker；確認現行授權 kid 在新版公鑰表內
  end
  G->>I: scp 安裝包、install.sh --upgrade --force
  I-->>G: 升級結果
```

直接部署走 `install.sh --upgrade`，不驗發布清單簽章，所以不需要出版簽章。重新編譯 BE 時仍要送 License Center 簽防竄改清單（沒有這份簽章的映像無法啟動），因此要以 `test-ci` 登入 Vault 取 License Center 憑證；`skip_be` 與 `reuse_bundle` 不需要。STG、POC 升級前的兩道檢查沿用 `deploy_e2e.sh` 既有邏輯：先停 api 等服務（避免 migration 與舊版 api 搶鎖死鎖）、確認現行授權的 kid 在新版公鑰表內（避免升級後整站鎖住）。

**同一份程式不重編**：直接部署與測試版發布建置 BE 映像時，會多標一個 `src-<最後一次改到程式的 commit>`（不計 docs、`.gitlab/`、`.gitlab-ci.yml`、test 的改動）。188 本機已有同標記的映像，就跳過 Nuitka 編譯與防竄改送簽，直接沿用。只改文件或 CI 設定時不必重編。`skip_be` 優先於這個判斷。正式發版不吃這個判斷，永遠重新編譯。

**時間估計（主系統）**：程式有改約 30 分（2026-11-04 實跑 pipeline 3025779481，190 升到 1.28.3；Nuitka 約 14～17 分、封包約 13 分）；程式沒改（`src-` 命中）或 `skip_be` 約 10～13 分；`reuse_bundle`（包已存在，只換目標主機）約 2～5 分。

**STG、POC 的 Agent 不另外停服務**：Agent 直接部署時，升級腳本遇到執行中的工作會自動取消，所以不比照主系統先停 api。

### 6.4 ③ 測試版發布

```{.mermaid cap="圖 5　測試版發布。以測試金鑰簽章，登記為只有內部三台看得到的版本；到目標主機更新中心按更新，走客戶實際的升級路徑。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','lineColor':'#4A5A5C','textColor':'#14201F','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#F6EBD5','labelBoxBorderColor':'#9C6B12','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF','fontSize':'20px'},'sequence':{'actorFontSize':20,'messageFontSize':19,'noteFontSize':18,'actorMargin':60,'messageMargin':42,'boxMargin':12,'useMaxWidth':false}}}%%
sequenceDiagram
  autonumber
  actor D as 開發者
  participant G as GitLab 測試版發布 pipeline（188 runner）
  participant V as Vault（192.168.50.172）
  participant L as License Center（189）
  participant H as R2
  participant U as 更新站
  participant I as 內部主機（190／188 STG／189 POC）
  D->>G: 任一 branch，pipeline=test-release，版號須為 X.Y.Z-beta.N（可選 skip_be、fe_ref、note）
  G->>V: 以 ID token 登入（role=test-ci），讀取 License Center／R2／更新站憑證
  G->>G: 同步 188 工作樹、觸發 FE；BE 映像同直接部署（src- 標記命中或 skip_be 時不重編）
  opt 重新編譯 BE
    G->>L: 送防竄改清單簽署（FR-064）
    L-->>G: 清單簽章
  Note over G,L: 防竄改：程式啟動時逐檔比對雜湊，被改過就拒絕啟動
  end
  G->>G: 映像、打包（不掃描、不檢查正式公鑰）
  G->>H: 上傳安裝包
  G->>V: transit/sign guidant-test
  V-->>G: 測試金鑰簽名
  G->>U: 登記版本：測試金鑰簽章清單＋版本說明（note，或 release note 檔尾，或空白）；內部清單自動帶入
  Note over U: 草稿，只有內部三台看得到；測試金鑰的版本不可發布
  D->>I: 到要測的那台更新中心，按「更新到 X」
  I->>U: 檢查更新、取得下載網址
  I->>H: 下載安裝包
  I->>I: 以本機測試公鑰驗章、八步升級（失敗自動退回）
  I->>U: 回報升級結果
```

**命名**：對外（PM、業務）稱「Beta 版」。它不是給客戶試用的公開測試版，目前只開放給公司內部三台。

**誰拿得到**：主機向更新站檢查更新時，帶上更新站身分票（`update_ticket`，License Center 在授權開通時另簽的簽章信封，只含授權編號、機器指紋、主版號、kid，不含客戶名稱；FR-167 決策 D9）與機器指紋；還沒有身分票的舊主機改送授權檔原文。更新站先驗身分票簽章、確認身分票記載的指紋與送來的一致（防冒用別台指紋）、確認授權編號不在拒絕名單，再用指紋比對每個草稿的「內部主機清單」：指紋在清單上，回傳的版本包含 Beta 版；不在清單上，只回傳正式版（`update-center/src/routes/client.ts` `authenticate`、`src/releases.ts` `visibleReleases`）。身分票只用來證明「你是這台機器」，不需要另發 Beta 專用憑證。清單存在更新站資料表 `internal_hosts`，在後台「內部主機」分頁維護；只有測試金鑰簽的草稿會自動套用清單，正式金鑰的版本不套。

**現況限制**：190、188、189 三台的 `/etc/machine-id` 相同，算出的主機指紋都是同一個，所以清單目前只要登記一筆三台就都看得到；但三台的升級結果回報會互相覆蓋，後台無法分辨是哪一台。正式出 1.29.0 前要在三台重新產生 `machine-id`（見 §7.3），之後清單改為三筆。

測試版發布測的是客戶實際的升級路徑：下載、驗章、停接新工作、複製資料庫、在複本上 migration、健康檢查、換接、清理，以及失敗時的自動退回。直接部署與正式發版的演練都走 `install.sh`，測不到這段。

**客戶看不到、裝不了、也發布不出去**：版本狀態是草稿，更新站只回給內部清單上的主機指紋；即使草稿外流，客戶主機沒有測試金鑰公鑰，驗章失敗拒絕安裝；更新站也拒絕發布測試金鑰簽章的版本（`kid_not_registered`）。

**版號**：一律 `X.Y.Z-beta.N`（§4.8），每出一次測試版遞增 N，內部主機更新中心就會把新的 `beta.N` 判定為升級。同一個 `beta.N` 重跑時覆蓋同一筆草稿；內部主機要重裝同一版，用「同版重套」開關（`PRODUCT_UPDATE_ALLOW_SAME_VERSION=1`）。

### 6.5 Agent

Agent（`auditmanager/evidence-agent`）有自己的三條路，入口與參數比照主系統，各自獨立觸發。與主系統最大的差別在升級方式：**Agent 不直接連更新站**。客戶與內部主機的 Agent 一律由所屬的主系統向更新站下載 Agent 安裝包、驗章，再推送給 Agent；Agent 收到後寫升級單，由 Agent 主機的 systemd 執行升級。因此 Agent 的「透過更新中心升級」是在**主系統的更新中心 Agent 區**操作。

| 項目 | 主系統 | Agent |
|---|---|---|
| 本機資料庫與 migration | PostgreSQL；升級時先複製資料庫，在複本上跑 migration，失敗丟棄複本 | SQLite 單檔 `agent.db`；migration 由各模組 `migrations.py` 申報、以 `schema_version` 記版本，新容器啟動時直接在原檔上執行，**沒有複本** |
| 升級演練 | 正式發版可選 | 正式發版可選（同主系統，勾選 `rehearse`） |
| 主控台前端 | FE pipeline 建置映像 | 188 對 `/opt/guidant-ai-fe` 同名 branch（或 `fe_ref`）以 FE Dockerfile `console-dist` 目標打包 |
| 離線檔 | 無 | 正式發版簽章後產 `guidant-agent-<版號>.offline.tar.gz`，與安裝包一起上傳 R2 `agent/v1/<版號>/`。內容與 R2 上的安裝包、清單、雜湊三個檔相同，只是包成一個檔：Agent 與主系統斷線時，現場人員在 Agent 主控台「離線上傳更新包」只能上傳一個檔。需要時由更新站後台取得 15 分鐘有效的預簽下載網址給客戶（沿用客戶主機下載安裝包用的同一套 `presignDownload`，不新增金鑰或機制） |
| 直接部署的安裝方式 | `install.sh --upgrade`（`deploy_e2e.sh`） | `guidant-agent-compose upgrade <包>`（`deploy_agent_e2e.sh`，目標由寫死 190 改為 `target` 單選三台之一） |
| 更新站的版本線 | `product=host` | `product=agent`；R2 路徑 `agent/v1/<版號>/` |
| 測試版發布的版本說明 | 選填，`note` 或 release note 檔尾 | 同主系統：選填，`note` 或 `RELEASE_NOTES.md` 本版段 |
| 發布順序 | — | 主系統某版要求新 Agent 時，先發 Agent、再發主系統（主系統清單的 `min_agent_version` 指向它） |

#### ① Agent 正式發版

```{.mermaid cap="圖 6　Agent 正式發版。發版人只做第 1 步；演練勾選才跑；客戶端由主系統取得新版 Agent 並推送。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','lineColor':'#4A5A5C','textColor':'#14201F','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#F6EBD5','labelBoxBorderColor':'#9C6B12','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF','fontSize':'20px'},'sequence':{'actorFontSize':20,'messageFontSize':19,'noteFontSize':18,'actorMargin':60,'messageMargin':42,'boxMargin':12,'useMaxWidth':false}}}%%
sequenceDiagram
  autonumber
  actor P as 發版人
  participant G as GitLab Agent 正式發版 pipeline（188 runner）
  participant V as Vault（192.168.50.172）
  participant L as License Center（189）
  participant H as Harbor／R2
  participant U as 更新站
  participant T as 190 測試機
  participant M as 客戶主系統
  participant A as 客戶 Agent
  P->>G: Run pipeline，選受保護 tag v1.6.0（pipeline=release，可填 rehearse／rehearse_base／fe_ref）
  G->>G: verify：同步 188 工作樹、守衛測試、pytest、相依套件弱點掃描
  G->>G: build：主控台前端（FE console-dist）、Nuitka 編譯（188 編譯鎖）
  G->>V: 以 ID token 登入（role=release-ci），讀取 License Center／Harbor／R2／更新站憑證
  G->>L: 送防竄改清單簽署（FR-064）
  L-->>G: 清單簽章
  Note over G,L: 防竄改：程式啟動時逐檔比對雜湊，被改過就拒絕啟動
  G->>G: 建 Agent 映像
  G->>G: 映像弱點掃描（Trivy、Grype）：有白名單外的 Critical 漏洞就停
  G->>G: 打包：映像＋compose 設定＋安裝腳本 → 客戶安裝用的 guidant-agent-compose-<版號>.tar.gz
  G->>G: 檢查版本說明：RELEASE_NOTES.md 要有這一版的條目，缺就停
  opt 發版人勾選 rehearse
    G->>T: 演練：清除 Agent、安裝 rehearse_base 版、放入帶資料的 agent.db、以新包升級
    T-->>G: 新容器 healthy、schema_version 到最新、資料筆數不變
  end
  G->>V: transit/sign guidant-release
  V-->>G: 簽名
  G->>G: 產離線檔 guidant-agent-1.6.0.offline.tar.gz（安裝包＋簽好的清單＋雜湊，包成一個檔）
  G->>H: 推映像；上傳 agent/v1/1.6.0/（安裝包、清單、雜湊、離線檔）
  G->>U: 登記版本（product=agent）＋版本說明
  G->>U: 發布（GitLab ID token）
  U-->>G: published
  M->>U: 查 Agent 線，看到 1.6.0
  Note over M,A: 管理員在主系統更新中心 Agent 區按更新，或 Agent 主控台按「更新到 1.6.0」
  M->>H: 下載 Agent 安裝包、驗章、核雜湊
  M->>A: 推送 POST /update
  A->>A: 下載包、寫升級單；Agent 主機升級、健康檢查，回報結果
```

**Agent 演練樣本**：主系統演練用 STG 的唯讀資料匯出，Agent 沒有對應來源，因此在 188 固定放 `.build/rehearse/agent-<舊版號>.db`。第一份由決策者從 190 現行版 Agent 匯出，之後每次 Agent 進版順手留一份。

**Agent 演練**：勾選 `rehearse` 時，在 190 清除 Agent（保留 Agent 憑證與弱點資料庫兩個 volume，免得重新註冊）、安裝 `rehearse_base` 指定的舊版 Agent、放入 188 上的樣本資料庫 `.build/rehearse/agent-<舊版號>.db`，以新包執行 `guidant-agent-compose upgrade`，確認整個 Agent compose 專案（含 seaweedfs）healthy、`schema_version` 等於新版 migration 的最後一版、`upload_files` 與 `local_runs` 筆數不變，才繼續簽章與發布。演練只准在 190 執行：腳本寫死主機，指到其他主機一律拒絕，避免清掉 STG、POC 的 Agent。Agent 的 migration 在原檔上執行、退回時不還原 `agent.db`（見 §9），跨版 migration 出錯的代價比主系統更高，本版有動到 Agent migration 時建議勾選。

離線檔與 R2 上的安裝包是同一份內容，只多包一層；選擇另存一份而不是需要時由更新站即時打包，是因為 R2 儲存成本低（每版約 1.3 GB），而在 Worker 上即時打包 1.3 GB 的檔有執行時間與記憶體上限的風險。客戶端的升級在發布之後由客戶自行觸發（主系統更新中心或 Agent 主控台），與主系統相同。

#### ② Agent 直接部署

```{.mermaid cap="圖 7　Agent 直接部署。不跑測試與弱點掃描、不簽出版章、不經更新站，一次升級一台主機上的 Agent；POC 要按 ▶ 確認。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','lineColor':'#4A5A5C','textColor':'#14201F','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#F6EBD5','labelBoxBorderColor':'#9C6B12','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF','fontSize':'20px'},'sequence':{'actorFontSize':20,'messageFontSize':19,'noteFontSize':18,'actorMargin':60,'messageMargin':42,'boxMargin':12,'useMaxWidth':false}}}%%
sequenceDiagram
  autonumber
  actor D as 開發者
  participant G as GitLab Agent 直接部署 pipeline（188 runner）
  participant V as Vault（192.168.50.172）
  participant L as License Center（189）
  participant I as 目標 Agent 主機（190、188 STG 或 189 POC 其中一台）
  D->>G: 任一 branch，pipeline=deploy，target 選一台（e2e／stg／poc）；可選 reuse_bundle
  alt reuse_bundle
    G->>G: 取 188 上同版號既有 compose 安裝包
  else 一般
    G->>G: 同步 188 工作樹、主控台前端、Nuitka 編譯
    G->>V: 以 ID token 登入（role=test-ci），讀取 License Center 憑證
    G->>L: 送防竄改清單簽署（FR-064）
    L-->>G: 清單簽章
  Note over G,L: 防竄改：程式啟動時逐檔比對雜湊，被改過就拒絕啟動
    G->>G: Agent 映像、compose 安裝包（不掃描）
  end
  opt target＝poc
    G-->>D: 停在部署 job，等待按 ▶
    D->>G: 按 ▶ 確認
  end
  G->>I: scp 安裝包、核 sha256
  G->>I: guidant-agent-compose upgrade <包>
  I-->>G: 整個 Agent compose 專案轉為 healthy
```

防竄改清單簽章是映像建置的必要步驟（與主系統相同，見 §6.1），所以直接部署仍要登入 Vault 取 License Center 憑證；出版簽章則不需要。

#### ③ Agent 測試版發布

```{.mermaid cap="圖 8　Agent 測試版發布。以測試金鑰簽章、登記為只有內部三台看得到的版本；在內部主系統的更新中心 Agent 區推送，走客戶實際的升級路徑。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','lineColor':'#4A5A5C','textColor':'#14201F','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#F6EBD5','labelBoxBorderColor':'#9C6B12','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF','fontSize':'20px'},'sequence':{'actorFontSize':20,'messageFontSize':19,'noteFontSize':18,'actorMargin':60,'messageMargin':42,'boxMargin':12,'useMaxWidth':false}}}%%
sequenceDiagram
  autonumber
  actor D as 開發者
  participant G as GitLab Agent 測試版發布 pipeline（188 runner）
  participant V as Vault（192.168.50.172）
  participant H as R2
  participant U as 更新站
  participant M as 內部主系統（190／STG／POC）
  participant A as 該主機的 Agent
  D->>G: 任一 branch，pipeline=test-release，版號須為 X.Y.Z-beta.N（可選 fe_ref、note）
  G->>G: 同步 188 工作樹、主控台前端、Nuitka、防竄改清單簽署（License Center）、Agent 映像、compose 安裝包（不掃描）
  G->>V: 以 ID token 登入（role=test-ci），讀取 R2／更新站憑證
  G->>H: 上傳 agent/v1/<版號>/
  G->>V: transit/sign guidant-test
  V-->>G: 測試金鑰簽名
  G->>U: 登記版本（product=agent）；內部清單自動帶入三台
  Note over U: 草稿，只有內部三台的主系統看得到；測試金鑰的版本不可發布
  D->>M: 到該主系統更新中心 Agent 區，選 Agent，按更新
  M->>U: 查 Agent 線、取得下載網址
  M->>H: 下載 Agent 安裝包
  M->>M: 以本機測試公鑰驗章、核雜湊
  M->>A: 推送 POST /update
  A->>A: 下載包、寫升級單；Agent 主機升級、健康檢查
  A-->>M: 回報每步進度與結果
```

Agent 測試版發布驗的是「主系統下載 Agent 包、驗章、推送、Agent 主機升級、失敗自動還原」這整段，直接部署測不到。內部清單填的是**主系統的主機指紋**：Agent 包由主系統向更新站取得，該主機底下任一台 Agent 升級成功即算。Agent 主控台「更新到 X」也可觸發，效果相同（請主系統替這一台做第 9 步起的動作）。驗章用的是主系統主機上的測試公鑰（`/etc/guidant/release-keys/`），Agent 本身不需要另裝；Agent 主控台的「離線上傳」只信 Agent 安裝包內附的正式公鑰，所以測試版不能走離線上傳。

### 6.6 CI 如何取得簽章權限

本節回答兩個問題：CI 本身不保存任何密碼，怎麼取得簽章權限與建置期憑證；以及為什麼只有決策者觸發的 pipeline 能發版。做法是每個 job 開始時由 GitLab 簽發一張短效的身分憑證（ID token），Vault 核對憑證上的專案、ref 類型、是否受保護、觸發者帳號，符合角色條件才發給 30 分鐘的通行 token；GitLab 免費版無法限定「只有某人能打 tag」，因此「只有決策者能發版」由 Vault 核對觸發者帳號來把關。

```{.mermaid cap="圖 9　CI 取得憑證與簽章。Vault 依 ID token 的聲明決定角色；私鑰不離開 Vault。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','lineColor':'#4A5A5C','textColor':'#14201F','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#F6EBD5','labelBoxBorderColor':'#9C6B12','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF','fontSize':'20px'},'sequence':{'actorFontSize':20,'messageFontSize':19,'noteFontSize':18,'actorMargin':60,'messageMargin':42,'boxMargin':12,'useMaxWidth':false}}}%%
sequenceDiagram
  autonumber
  participant G as GitLab
  participant J as 188 上的 CI job
  participant V as Vault（192.168.50.172）
  G->>J: 啟動 job，附 ID token（aud＝Vault 網址，含 project_path、ref_type、ref_protected、user_id）
  J->>V: POST auth/jwt/login（ID token、role）
  V->>G: 取 JWKS 驗 ID token 簽章
  V->>V: 核 bound_claims：專案、ref 類型、受保護、user_id
  V-->>J: 短效 token（30 分鐘）
  J->>V: GET kv/data/ci/*（依角色 policy）
  J->>V: GET transit/keys/<鑰名>（比對 CLI 釘住的公鑰與 kid）
  J->>V: POST transit/sign/<鑰名>
  V-->>J: 簽名
  J->>V: POST auth/token/revoke-self
```

| 角色 | 給誰 | bound_claims | policy |
|---|---|---|---|
| `release-ci` | BE、Agent 正式發版 | 兩專案、受保護 tag、決策者 `user_id` | 簽 `guidant-release`、讀 `kv/ci/*` 全部（含 `kv/ci/update-site` 的更新站 CI token 與 Cloudflare Access 服務憑證） |
| `test-ci` | BE、Agent 直接部署與測試版發布 | 兩專案、任何 branch、決策者 `user_id` | 簽 `guidant-test`；讀 Nexus、Harbor、License Center、**R2、更新站**（後兩項本版新增，測試版發布要上傳與登記；更新站一項含 Cloudflare Access 服務憑證） |
| `fe-ci` | FE | FE 專案任何 job | 讀 Nexus |
| `classifier-ci` | jedi 套件 | jedi 專案 | 讀 Harbor |

簽章 CLI 在簽署前把 Vault 回的公鑰與程式內釘住的 kid 比對，不同即拒簽；簽完以釘住的公鑰自驗。

**金鑰輪換**：Vault `transit/keys/guidant-release/rotate` 產新版本 → 新公鑰存為 `scripts/installer/release-keys/<新 kid>.pub`、加入 `PROD_KIDS`、更新 CLI 釘住的表 → 隨一版主系統裝上客戶機 → 下一版起以新版本簽 → 舊版本設 `min_decryption_version` 停用。

### 6.7 已知限制

- **188 工作區共用**：能觸發 pipeline 的人可讓 188 以 `jedi`（docker 群組，等同 root）執行任意指令。BE、Agent 的 pipeline 只有決策者能取得 Vault 權限；FE 每次 push 都會在 188 執行。根本解法是另開一台只收受保護 ref 的 runner，本案不做；FE 專案日後若加入非 Maintainer 成員，要先把 FE 的 verify 移出 188。
- **Harbor 帳密共用一組**：任何能取得 Harbor 帳密的 pipeline 都能推 `guidant-ai` 專案下任何映像。緩解：CI 一律推帶 commit 的 tag；日後可開 Harbor 的 tag 不可覆寫。
- **Vault 尚無快照與防火牆**：172 損毀即失去正式金鑰（Mac 備用金鑰仍可發布，但要出一版換 kid）。正式出 1.29.0 前補。
- **188 上的 FE 映像不會自動清**：FE CI 推 Harbor 後保留本機映像供 BE 取用（D11），舊映像會累積，定期清理另案。
- **Cloudflare Access 服務憑證有到期日**：到期後 CI 登記與發布全部被 Access 擋下，要在到期前換新並更新 Vault `kv/ci/update-site`。
- **License Center 走 http**：token 在內網明文傳輸，另案評估。
- **License Center 私鑰是 189 主機上的加密檔**：不像 Vault Transit 拿不出來。日後可改為 License Center 照常負責簽發、簽章時請 Vault Transit 代簽；授權檔、防竄改清單、解鎖碼仍是同一把鑰匙，客戶端零改動。屬 License Center 改造，另案。
- **Agent 防竄改是簡化版**：Agent 只在啟動時與升級前驗，沒有執行期抽查、沒有鎖定畫面（FR-066 D6，當時的前提是 Agent 沒有畫面）。Agent 主控台（FR-166）已上線，是否補執行期抽查與主控台提示，另案評估。

## 7. 實作規劃 {#plan nav="實作"}

### 7.1 已完成（v1.0）

V-1（簽章 CLI 接 Vault、更新站收已簽章草稿、GitLab 身分發布）、C-3／C-3b（FE CI、Dockerfile `console-dist`）、C-1（BE CI）、C-2（Agent CI）、C-4（jedi CI）已驗收合回 `feature/FR-172`。第二輪批 4（CM-3481 BE 觸發 FE、CM-3482 Agent CI 兩處、CM-3483 seaweedfs `init: true`）與本版不衝突，可照常進行；批 5 CM-3484（拿掉 `deploy-stg`／`deploy-poc`）由本版的 CI 改寫取代。

### 7.2 本版異動

| repo | 檔案 | 異動 | 依據 | 卡 |
|---|---|---|---|---|
| update-center | `src/releases.ts` | 發布移除「內部先裝清單不能為空、每台須成功」檢查；測試金鑰（未登記 kid）的版本仍不可發布；已發布版號拒絕測試版登記（409 `version_already_published`）；`versions.ts` 依 semver 排序 `-beta.N` | D4、D8 | CM-3485 U-1 |
| update-center | `src/routes/admin.ts` register-draft | 接受版本說明三段；測試金鑰簽章的版本自動套用內部主機清單 | D7、D10 | CM-3485 U-1 |
| update-center | 內部主機清單 | 新增設定（後台維護三台主機指紋），測試版發布自動套用：資料表 `internal_hosts`、後台「內部主機」分頁 | D7 | CM-3485 U-1 |
| update-center | `cli/commands.ts` `publish` | 移除「內部先裝清單不能為空」，與 Worker 一致（Mac 備用路徑） | D4、D13 | CM-3485 U-1 |
| BE | `.gitlab-ci.yml` | 改為分流主檔（參數 `pipeline`）；子設定檔 `.gitlab/ci/common.yml`／`release.yml`／`deploy.yml`／`test-release.yml`／`vulndb.yml` | D9 | CM-3490 B-1 |
| BE | `release.yml` | 正式發版：verify、fe、build、package、（勾選）rehearse、sign、upload、register、publish 一路自動；刪 `deploy-stg`／`deploy-poc`／`environment`／`internal_test`；演練移到打包之後、用同一份包 | D4、D5 | CM-3490 B-1 |
| BE | `deploy.yml` | 直接部署：`target` 單選一台（POC 為手動 job）、`skip_be`、`reuse_bundle`、`src-` 標記命中不重編；不簽章 | D6、D7 | CM-3490 B-1 |
| BE | `test-release.yml` | 測試版發布：build、package、測試金鑰簽章、上傳 R2、登記草稿 | D6、D8 | CM-3490 B-1 |
| BE | `scripts/build/deploy_e2e.sh` | 移除「內部測試包只准 e2e」檢查 | D7 | CM-3486 B-2 |
| BE | 測試版發布版號檢查 | 第一步擋不帶 `-beta.N` 的版號；正式發版擋帶 `-beta` 的版號；`upgrade_common.sh` 排序支援 `-beta.N` | D8 | CM-3494 |
| BE | `scripts/build/publish_draft.sh` | 登記時帶版本說明；正式發版登記後呼叫發布；發布邏輯只留這一份（`--publish`、`--publish-only`），`publish_release.sh` 縮成轉呼叫，CI 改寫後可刪 | D4、D10 | CM-3486 B-2 |
| BE | `scripts/build/pull_fe_image.sh` | 本機優先，沒有才拉 Harbor（2026-11-04 直接部署實跑確認取本機） | D11 | CM-3486 B-2 |
| FE | `scripts/ci/build_image.sh` | 推 Harbor 後不刪本機映像 | D11 | CM-3489 F-1 |
| Agent | `.gitlab-ci.yml` 與三支子設定檔 | 同 BE；`rehearse-upgrade` 改為勾選 `rehearse` 才跑，移到打包之後 | D5、D9、§6.5 | CM-3491 A-1 |
| Agent | `scripts/build/deploy_agent_e2e.sh` 演練模式 | 新增 `--base-version`：清除 190 的 Agent、安裝舊版、放入帶資料的 `agent.db`、以新包升級、核 `schema_version` 與資料筆數（比照 BE `deploy_rehearse.sh`） | D5 | CM-3487 A-2 |
| Agent | `scripts/build/deploy_agent_e2e.sh` | 目標由寫死 190 改為 `target` 單選三台之一 | D7 | CM-3487 A-2 |
| Agent | 測試版發布版號檢查、版本說明選填 | 同 BE；`upgrade_order.sh` 排序支援 `-beta.N`；測試版發布加 `note`，版本說明改選填 | D8、D10 | CM-3495 |
| Agent | `scripts/build/publish_draft.sh` | 同 BE；正式發版時離線檔一併上傳 R2 `agent/v1/<版號>/`；拔掉獨立的 `publish` 子命令，只留 `register --publish` | D4、D10 | CM-3488 A-3 |
| update-center | 後台版本詳情 | Agent 版本多回 `offline_download_url`：沿用 `presignDownload` 對離線檔產 15 分鐘預簽網址；後台加「取得離線檔連結」按鈕；`register-draft` 回應補 `upload.offline`（原本沒回，Agent 有離線檔也傳不上去） | §6.5 | CM-3492 U-2 |

卡片批次與驗收條件見 `handoff/cards_r3.py`。依賴：update-center 的改動要先部署，BE、Agent 的正式發版與測試版發布才能驗；BE、Agent、FE 之間互不依賴。

### 7.3 前置作業（決策者）

| 時機 | 項目 |
|---|---|
| CI 改寫前 | Vault `ci-test` policy 加讀 `kv/ci/r2`、`kv/ci/update-site`；測試金鑰公鑰 `128cfec6fc9877b8.pub` 放到 188 STG、189 POC 的 `/etc/guidant/release-keys/`；STG、POC 的 `guidant.env` 開啟 `PRODUCT_UPDATE_ALLOW_SAME_VERSION=1`（Agent 為 `AGENT_UPDATE_ALLOW_SAME_VERSION=1`）；在更新站後台登記三台主機指紋為內部主機清單；Cloudflare Access 建立服務憑證 `guidant-ci`，Access 應用程式加「服務驗證」原則，憑證存 Vault `kv/ci/update-site`（`cf_access_client_id`、`cf_access_client_secret`），**有到期日，到期前要換** |
| CI 改動驗完後 | 刪 188 上明文副本（`/opt/guidant-ai-*/.env` 的 License Center token、`~/.fr167-r2.env`、`~/.npmrc`）、BE 專案 GitLab 變數的 R2 與 Bugsink；License Center 換新 token 放進 Vault |
| 正式出 1.29.0 前 | Vault 每日快照＋異地備份、防火牆；189 POC 升到 1.28.x 以上；部署 update-center Worker（含本版改動）、後台登記 `05086455d42ca0d6` 為 primary、Cloudflare Access 放行發布端點；內部三台重新產生 `/etc/machine-id`（目前三台相同，升級回報互蓋），後台內部主機清單改為三筆；**印章表目前是空的**，沒登記正式章前正式發版的自動發布會被 `kid_not_registered` 擋下 |

## 8. 驗收標準 {#acceptance nav="驗收"}

| # | 做什麼 | 預期結果 | 需求 |
|---|---|---|---|
| 1 | 從受保護 tag 執行正式發版，不勾 `rehearse` | 不跑演練；一路到發布；更新站 `published`、kid `05086455d42ca0d6`、帶版本說明三段；客戶主機檢查更新看得到 | R1、R2 |
| 2 | 同上，勾 `rehearse`、`rehearse_base` 填 1.27.0 | 190 清機裝 1.27.0、匯入 STG 資料、升級成功後才簽章發布 | R3 |
| 3 | 同上，演練失敗 | pipeline 停止，更新站沒有這一版 | R3 |
| 4 | 從 branch 執行正式發版 | verify 失敗；不簽章、不登記 | R4 |
| 5 | 非決策者從受保護 tag 執行 | Vault 登入被拒，印「不是由允許發版的人觸發」 | R4 |
| 6 | 直接部署，`target` 依序選 e2e、stg、poc（後兩次勾 `reuse_bundle`） | 每次只升級所選那一台；`poc` 停在部署 job 等按 ▶；STG、POC 升級前 api 已停、授權 kid 檢查通過；pipeline 無測試與弱點掃描 job | R5、R7 |
| 7 | 直接部署，勾 `skip_be` | 不跑 Nuitka；約 10 分完成 | R5 |
| 8 | 直接部署，勾 `reuse_bundle` | 不建置，直接以 188 上既有包升級 | R5 |
| 9 | 測試版發布 | 更新站有一筆測試金鑰簽章的草稿，內部清單為三台；R2 有安裝包 | R6 |
| 10 | 測試版發布後，190、STG、POC 各自從更新中心升級 | 三台都看得到、驗章通過、升級成功並回報 | R6、R7 |
| 11 | 測試版發布後，非內部主機檢查更新 | 看不到該版 | R6 |
| 12 | 對測試金鑰的草稿呼叫發布 | 更新站回 `kid_not_registered`，維持草稿 | R6 |
| 13 | 以不帶 `-beta.N` 的版號執行測試版發布；以帶 `-beta` 的版號執行正式發版 | 兩者都在第一步停止 | D8 |
| 14 | 內部主機先裝 `1.29.0-beta.2`，再正式發版 `1.29.0` | 內部主機更新中心判定 `1.29.0` 較新，可升級 | D8 |
| 15 | BE 與 Agent 同時執行 | 第二條在 Nuitka 段排隊，不並行 | C4 |
| 16 | Agent 正式發版，不勾 `rehearse` | 不跑演練；發布後客戶主系統 Agent 區看得到新版；R2 `agent/v1/<版號>/` 有離線檔，更新站後台取得的下載連結可下載；在一台 Agent 主控台以「離線上傳更新包」上傳該檔，升級成功 | R10 |
| 16a | Agent 正式發版，勾 `rehearse` | 190 裝回舊版 Agent、放入帶資料的 `agent.db`、升級後 `schema_version` 到最新、資料筆數不變，才簽章發布 | R3、R10 |
| 16b | Agent 直接部署，`target` 依序選三台 | 每次只升級所選那一台的 Agent、整個 Agent compose 專案 healthy；`poc` 要按 ▶ | R5、R10 |
| 16c | Agent 測試版發布後，在三台主系統的更新中心 Agent 區推送 | 主系統下載、驗章（測試公鑰）通過，Agent 升級成功並回報 | R6、R10 |
| 17 | 關閉 Vault，Mac 執行 `publish --signer file` | 發布成功 | R9 |
| 18 | 正式發版 build 段 log | FE 映像取本機，無 `docker pull` | D11 |
| 19 | 188 檢查 | `grep -r "R2_SECRET\|_auth=\|LICENSE_CENTER_API_TOKEN" ~/.npmrc /opt/guidant-ai-*/.env` 為 0 | D14 |

## 9. 風險與未決事項 {#risks nav="風險"}

| 項目 | 影響 | 緩解 | 處理時機 |
|---|---|---|---|
| 正式發版自動發布，客戶看到前沒有人工確認 | 有問題的版本會直接到客戶 | 測試、弱點放行門、（勾選時）演練任一失敗不發布；正式發版前已經過直接部署、測試版發布與 STG／POC 的 Demo、UAT；發布後可撤回 | 若發生問題版本外流，評估加回「發布前等待 N 小時」的緩衝 |
| 演練改為可選，未勾選時跨版 migration 問題沒有提前驗證 | 問題出現在客戶端；線上升級失敗會自動退回，客戶看到「升級失敗」 | 本版新增 migration 時建議勾選 | 若出現跨版升級事故，改回有 migration 自動執行 |
| 測試金鑰任何 branch 都能簽，內部三台都信任它 | 決策者在別人推的 branch 上觸發，未經審查的程式碼可裝到 STG／POC | `test-ci` 綁決策者 `user_id`；觸發前確認 commit；STG／POC 是內部環境 | — |
| 測試版與正式版排序在四處不一致 | 內部主機從 beta 升正式版被誤判為降版或同版 | 四處共用同一套 semver 排序規則（§4.8） | — |
| 188 工作區共用 | 見 §6.7 | 正式發版不信任共用快取 | 正式發版專用 runner（本案不做） |
| Vault 無快照 | 172 損毀即失去正式金鑰 | Mac 備用金鑰 | 1.29.0 前 |
| Agent 升級失敗退回時不還原 `agent.db`（`upgrade_runner.sh` 的 rollback 只還原 `.env` 與 compose 檔） | 新版 migration 已改過 `agent.db` 後失敗退回，舊版 Agent 可能讀不懂新欄位或新表 | Agent migration 只補欄位、不刪不改（`add_missing_columns`）；跨版前勾選演練 | 另案（FR-172 升級機制）：升級前備份 `agent.db`、退回時還原 |

## 10. 參考文件 {#refs nav="參考"}

- v1.0 定稿：`design.md`（2026-11-03）
- FR-167 線上更新通道設計：`docs/features/FR-167-2610-online-update-channel/design.md`
- Vault 架設與角色設定：`vault-setup-runbook.md`；現況：`vault-status.md`
- 簽章 CLI 與 Vault signer：`update-center/cli/README.md`、`cli/vault_signer.ts`
- 更新站版本與發布邏輯：`update-center/src/releases.ts`
- 同版重套：`app/product_update/service/same_version.py`
- 系統說明：`docs/system-design/release-pipeline/`（一章一頁，從 `01-概述.md` 讀起）

## 11. 與 v1.0 的異動 {#diff nav="與 v1.0 的異動"}

| v1.0 | 本版 | 為什麼改 |
|---|---|---|
| D9：正式發版尾端手動按發布，且發布前須 190 透過更新中心試裝成功 | D4：自動發布；移除內部先裝檢查 | 本案目的是自動化；選 tag 執行正式發版即代表要給客戶，再要求人工確認是同一個決定做兩次 |
| D12：測試包是正式發版 pipeline 的 `internal_test` 參數，只裝 190 | D6、D9：直接部署、測試版發布兩條獨立的路，各自入口 | 開發測試被正式發版的段落拖慢；一支設定檔五種情境難以維護 |
| 測試金鑰公鑰只放 190；`deploy_e2e.sh`、`deploy_agent_e2e.sh` 禁止裝 STG／POC | D7：內部三台都可用、都裝測試公鑰；直接部署一次一台 | 三台都是公司機器，STG／POC 用於內部 Demo 與 UAT；把關由 Vault `user_id` 承擔 |
| 演練每次正式發版必跑，且在打包前另打一次包 | D5：可選；在打包之後用同一份包 | 開發期間已測過；演練與出貨同一份包也省一次封包 |
| 正式發版可選 `environment=stg／poc`，CI 直接部署 | 刪除；直接部署改走 ② | 正式發版只到更新站與客戶 |
| 測試版發布靠決策者 Mac 手動 `publish2.sh` | ③ 測試版發布由 CI 執行 | 自動化 |
| 版本說明由人到後台貼（v1.0 C-1 未完成項） | D10：CI 隨版本送出；測試版發布改選填，可用 `note` 一句話 | 補完 v1.0 已定但未做的事；測試版只給內部看，不必寫正式說明 |
| 測試版與正式版共用版號 | D8：測試版一律 `X.Y.Z-beta.N` | 實測撞號（409）；格式不同就永遠不撞 |
| FE 映像經 Harbor 交接給 BE | D11：本機優先，Harbor 備查 | 同一台主機白走一趟 Harbor |
| Agent 每次正式發版演練（只以新包升級 190 現行版，未從舊版跨版） | 可選；勾選時從 `rehearse_base` 舊版帶 `agent.db` 資料跨版升級 | 與主系統一致；Agent 有 SQLite 與 migration，跨版升級要驗，但由發版人判斷是否需要 |
| Agent 離線檔只留在 188 | 一併上傳 R2，後台可取下載連結 | 客戶斷線需要離線檔時，不必再有人登 188 取檔 |
