---
title: "出版管線定稿 — 設計文件 (FR-173)"
brand: "Guidant AI · **FR-173** 出版管線定稿"
eyebrow: "FR-173 · 設計文件 · 2026-11-03 修訂（出包時蓋章、測試包模式）"
h1: "設計 v1.0（定稿）：三條 CI、一個簽章金庫"
subtitle: "BE、Agent、FE 各一支活的 pipeline；發版起點是 GitLab 的 Run pipeline；出包時向自架 Vault 要簽名，試完在 GitLab 按「發布」；另有不掃描、不上站的測試包模式"
lede: "🔴 **這份是設計決策，不是現況。** 1.29 起走這條路出版，1.28.x 不出正式版。**推翻 FR-167 兩條裁示**：①「印章私鑰只在決策者 Mac」改為「Vault 簽、Mac 章備用」；②「先給內部機器裝草稿、再蓋章發布」改為「出包時就蓋章、發布只是公開」——客戶端一律拒收沒蓋章的清單，舊順序在內部機器那一步就走不通。update-center 與 License Center 不進管線。"
chips: [
  {text: "6 張卡 3 批、5 repo，預估 2 個工作天", kind: accent},
  {text: "決策者功課分三段，各在對應批次前做", kind: warn},
  {text: "Ed25519 不改、客戶端驗章零改動", kind: ok},
  {text: "私鑰不進 GitLab 變數", kind: crit},
]
footer: "FR-173 設計文件 · 拆卡表 handoff/cards.py · 沿革見 handoff/FR-173-LOG.md 與 git log"
---

> 📑 **本頁是 v1.0（定稿）。** 實作中有設計異動，見 **[設計 v2.0（定稿）](design-v2.0.md)**（正式發版、直接部署、測試版發布三條路；正式發版自動發布；內部三台皆可部署與測試升級）。本頁內容保留定稿原樣，不回頭改。

## 0. 分工概述（30 秒版） {#overview nav="概述"}

**整案一句話**：每個會出貨的 repo 一支自己的 CI 對自己負責；發版的人在 GitLab 按「Run pipeline」，CI 跑完測試、編譯、排練、打包，**出包時就向自架 Vault 要簽名**，把蓋好章的版本登記成只有 190 看得到的草稿；190 試過沒問題，回 GitLab 按「發布」，客戶才看得到。全程不用 Mac。

**兩種跑法**，同一顆按鈕，差在勾不勾 `internal_test`：

| | 正式包 | 測試包 |
|---|---|---|
| 給誰 | 客戶 | 只給 190 自己測 |
| 從哪跑 | 受保護的 tag（例 `v1.29.0`） | 任何 branch |
| 跑什麼 | 測試、編譯、190 升級排練、SBOM＋弱掃、推 Harbor、上 R2、登記更新站 | 測試、編譯、打包；**跳過**排練、SBOM＋弱掃、Harbor、R2、更新站 |
| 章 | Vault 正式章 `guidant-release` | Vault 測試章 `guidant-test`（客戶機不認得） |
| 怎麼到 190 | 190 更新中心「立即檢查」→ 更新 | CI 直接 scp 到 190 升級 |
| 主系統約多久（188 實測推估） | 約 50 分 | 約 30 分；配 `skip_be`（不重編）約 10 分 |

| 階段 | 做什麼 | 卡 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| 決策者功課 | 分三段，各在對應批次前做，見 §5 | — | 各段的檢查指令見 §5 |
| V 簽章 | 蓋章工具接 Vault；更新站收「蓋好章的草稿」、發布端點認 GitLab 身分 | V-1 | 172 簽一份清單，190 驗章過；Mac 私鑰仍能簽（備用） |
| C 三條 CI | FE 自己的 CI；Agent 新 CI；BE 改觸發 FE、出包時蓋章、手動「發布」、測試包模式；jedi 套件 CI | C-3 C-2 C-1 C-4 | 從 tag 跑一次：190 看得到新版、升級成功 → 按「發布」→ 更新站 `published`、kid 是 Vault 那把；勾 `internal_test` 跑一次：190 被直接升上去 |
| D 文件 | FINAL-SPEC、三 repo 分工表、仍要人按的步驟、鑰匙輪換 SOP | D-1 | — |

**依賴一句**：批 1（V-1、C-3）→ 批 2（C-2、C-1）→ 批 3（C-4、D-1），每批前決策者做完那一段功課。

---

## 1. 決策定案表 {#decisions nav="決策"}

| # | 題目 | 定案 | 被排除的 | 理由 |
|---|---|---|---|---|
| D1 | CI 自動簽章 | 同意；私鑰進金庫、CI 用 OIDC 短效身分要簽名 | 維持 Mac 手簽 | 出差就出不了版；與無人值守方向相反 |
| D2 | 金庫 | **自架 HashiCorp Vault Transit**（Community 版），專用 VM 192.168.50.172、apt 裝成 systemd 服務、單節點 Raft；架法見 `vault-setup-runbook.md` | Azure Key Vault／AWS KMS（要換 P-256、改三處驗章）；update-center Worker Secret（鑰匙程式讀得到，不算不可匯出）；GitLab CI 變數（反模式）；Cloudflare 無 KMS；GitLab Secrets Manager（要 Premium、只存祕密不開放簽章）；OpenBao（功能同、文件少，日後授權有變再換，指令與 API 相同） | Ed25519 不改、鑰匙不可匯出、建置期祕密順便集中；業界共識是 KMS／Vault＋OIDC，私鑰不放 CI 變數 |
| D3 | 三 repo CI 分不分 | BE、Agent、FE 各一支；BE 發版 pipeline `trigger:` FE 下游（`strategy: mirror`，官方已不建議 `depend`）；Agent 是自己的按鈕，不被 BE 觸發 | 全塞 BE 一支 | 各 repo 對自己負責、FE push 就掃不用等出包 |
| D4 | runner | BE、Agent、FE、jedi 都用 188 shell runner（24 核、`concurrent=3`）；只有 Nuitka 那段用 188 主機鎖串行（`resource_group` 不跨專案）；188 沒裝 node，FE 的 npm 在 node 容器裡跑；jedi 套件另議 | FE 用 171 上 2024 年留下的 docker runner（#37407054） | 兩條 Nuitka 同跑 linker crash；171 只有 2 核且跑 Nexus、Harbor，FE build 會拖慢它們；188 上 BE CI 本來就在打包 FE，環境現成 |
| D5 | FE 三支舊 CI | 刪（ECR／ECS／ansible 是 SaaS 時代）；換 verify（lint、vitest、npm 弱掃）＋tag build 推 Harbor | 留著 | 沒人維護、推錯地方 |
| D6 | release note 與最低相容版 | 從 repo 檔讀：BE `docs/release_notes/v<版>.md` 檔尾、Agent `RELEASE_NOTES.md` 檔尾、`min_agent_version`／`min_host_version` 一行檔 | GitLab 表單欄位 | 進版控、有 review |
| D7 | update-center、LC | 不進管線，各自部署 | 納入 | 獨立系統，範圍太大 |
| D8 | 客戶主機預設密碼（DB、Redis、Bugsink 每台同組） | **另案**：Vault 解不了（客戶內網連不到）；正解是裝機時每台隨機、compose `secrets:` 掛檔、首次登入強制改 | 塞進本案 | 與 CI 無關 |
| D9 | 仍要人按的 | ①打受保護 tag 並在 GitLab 按 Run pipeline；②190 試過後按 pipeline 最後一個手動 job「發布」 | 全自動；推 tag 自動跑 | 「這個 commit 要出貨」是決策；BE CI 一直是人按才跑，維持 |
| D10 | 什麼時候蓋章 | **出包時就蓋**：CI 打包後向 Vault 要簽名，登記「蓋好章的草稿」，只有內部先裝機器看得到；發布只把狀態改成公開，不再蓋章 | 先給內部機器裝草稿、試完才蓋章 | 客戶端一律拒收沒蓋章的清單（`package_verifier.py`），舊順序內部機器裝不上，發布前的「內部先裝成功」檢查永遠過不了 |
| D11 | 誰能按發布 | 更新站多認一種身分：GitLab 每個 job 的 ID token，只收 BE、Agent 兩專案、受保護 ref；決策者本人的 Cloudflare 登入照舊可用（備用） | 發一把長效 CI 通行碼加「發布」權限 | 不留長效祕密；與 Vault 用同一張身分證、同一套信任條件 |
| D12 | 測試包 | 擴充 CI 既有 `internal_test`：跳排練、SBOM＋弱掃、Harbor、R2、更新站；用 Vault 第二把測試章 `guidant-test` 簽；scp 直升 190 | 另開第三種模式；測試包也上更新站 | 測試不需要的步驟約 20 分；測試章公鑰只裝在 190，包流出去別台（含 STG、POC、客戶機）都裝不了；要測「更新中心看到新版 → 按更新」那段就跑正式模式（草稿只有 190 看得到，本來就安全） |
| D13 | GitLab 免費版 | 拿 Vault 祕密用 `id_tokens`＋script 內登入（`secrets:` 是付費版） | 升 Premium | 官方教學的手動登入寫法免費版可用，效果相同 |
| D14 | Agent 主控台前端怎麼來 | 比照主系統前端：Agent CI 在 188 把 `/opt/guidant-ai-fe` 切到同名 branch，先跑 FE `sbom_fe.sh` 掃該 commit 的 lockfile，再用 FE `Dockerfile` 的 `console-dist` 目標 docker build 輸出 `dist-console/` | 從 FE pipeline 下載 artifact（免費版 `CI_JOB_TOKEN` 跨專案下載回 404、`needs:project` 付費）；FE 傳套件庫；長效唯讀通行碼；188 共用資料夾 | 跟主系統前端同一份 Dockerfile、同一個 node 版本、同一份 lockfile；掃描與打包同一個 commit；不多一把祕密 |

---

## 2. 目標管線 {#pipeline nav="管線"}

### 2.1 正式發版：從按鈕到客戶看得到

```{.mermaid cap="圖 1 — 正式發版時序。人按兩次：Run pipeline、發布；中間 190 自己測。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','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 GL as GitLab BE pipeline（188 runner）
  participant FE as FE pipeline（188 runner）
  participant V as Vault（172）
  participant H as Harbor／R2
  participant US as 更新站
  participant T as 190 測試機
  P->>GL: 打受保護 tag v1.29.0，Run pipeline
  GL->>GL: verify：守衛、pytest、弱掃 lockfile
  GL->>FE: trigger（mirror），等 FE 綠
  FE-->>GL: FE 映像 tag
  GL->>GL: build：Nuitka、BE／init 映像
  GL->>T: rehearse：190 清掉重灌舊版、灌 STG dump、用新包升級
  GL->>GL: package：SBOM＋弱掃放行門、打包
  GL->>V: 用 GitLab job 身分登入，讀 R2／Harbor／更新站祕密
  GL->>H: 推映像、上傳安裝包
  GL->>V: transit/sign guidant-release（canonical 清單）
  V-->>GL: 簽名（鑰匙不出 Vault）
  GL->>US: register-draft，附蓋好章的清單；草稿只給 190 看
  P->>T: 更新中心「立即檢查」→ 更新到 1.29.0
  T->>US: 回報升級成功
  P->>GL: 按手動 job「發布」
  GL->>US: 帶 GitLab job 身分呼叫 publish
  US->>US: 核：BE 專案、受保護 tag、190 已回報成功
  US-->>GL: published
  Note over US: 客戶的「立即檢查」從這一刻看得到 1.29.0
```

**為什麼章在第 10～11 步就蓋**：客戶端看到沒蓋章的清單一律拒收，190 要能在第 13 步裝上去，拿到的就必須是蓋好章的版本。發布（第 15～18 步）只是把草稿改成公開。

**從 branch 跑會怎樣**：Vault 不給正式章（角色只認受保護 ref），pipeline 在第 10 步紅；「發布」那顆 job 也不會出現。故意的：避免從開發分支誤發。

### 2.2 測試包：只給 190 測

```{.mermaid cap="圖 2 — 測試包時序。不碰 Harbor、R2、更新站。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','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 GL as GitLab BE pipeline（188 runner）
  participant V as Vault（172）
  participant T as 190 測試機
  P->>GL: 任一 branch，勾 internal_test（可加 skip_be），Run pipeline
  GL->>GL: verify：守衛、pytest
  GL->>GL: build（skip_be 時沿用上一顆 BE 映像）
  GL->>GL: 打包（不跑 SBOM／弱掃）
  GL->>V: transit/sign guidant-test
  V-->>GL: 測試章簽名
  GL->>T: scp 安裝包＋清單，install.sh --upgrade --force
  T-->>GL: 升級結果
  Note over T: 測試章只有 190 認得；包流出去別台都裝不了
```

### 2.3 三條 pipeline 誰觸發誰

```{.mermaid cap="圖 3 — 三條 pipeline。Agent 那條是自己的按鈕。"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
  subgraph trig["人按"]
    T1["BE：Run pipeline（tag 或 branch）"]
    T2["Agent：Run pipeline（tag 或 branch）"]
    T3["FE／jedi：任意 push"]
  end
  subgraph be["BE pipeline（188 shell runner）"]
    B1["verify"]
    B2["trigger FE（mirror）"]
    B4["build：Nuitka、BE／init 映像"]
    B5["rehearse：190 舊庫升級排練（測試包跳過）"]
    B6["package：SBOM 放行門 → 打包 → Harbor → R2（測試包只打包）"]
    B6s["sign：Vault 正式章或測試章"]
    B6d["正式：register-draft 帶章／測試：scp 升 190"]
    B7["publish（手動、只在受保護 tag）"]
    B1 --> B2 --> B4 --> B5 --> B6 --> B6s --> B6d --> B7
  end
  subgraph fe["FE pipeline（188 shell runner）"]
    F1["verify：lint、vitest、npm 弱掃"]
    F2["build：FE 映像 → Harbor；dist-console artifact"]
  end
  subgraph ag["Agent pipeline（188 shell runner，與 BE 串行）"]
    G1["verify"] --> G2["build：Nuitka、拉 FE dist-console、Agent 映像"] --> G3["rehearse"] --> G4["package＋sign＋register-draft product=agent"] --> G5["publish（手動）"]
  end
  T1 --> B1
  T2 --> G1
  T3 --> F1
  B2 -.-> F1
```

| 項 | 主系統 CI | Agent CI | FE CI | jedi CI |
|---|---|---|---|---|
| runner | 188 shell（id 56998011）；Nuitka 段用 188 上的 `flock /home/jedi/.guidant-build.lock` 排隊 | 188 shell，同一把鎖 | 188 shell（同一台；不用 Nuitka，不進 resource_group，188 一次可同跑 3 個 job） | 待決策者開給套件群組 |
| 觸發 | 人按 Run pipeline | 人按 Run pipeline | push（verify）、BE 觸發（build） | push（test）、BE 觸發（分類器映像） |
| 弱掃 | pip-audit、syft＋grype lockfile、trivy＋grype 映像、`vuln_gate.py`；測試包跳映像掃描 | 同左 | syft＋grype `package-lock` | pip-audit |
| 祕密 | `id_tokens` → Vault JWT 登入 → KV | 同左 | 同左（Nexus npm 認證） | 同左 |
| 簽章 | 正式：`guidant-release`；測試：`guidant-test` | 同左 `--product agent` | 無 | 無 |
| 人按的 | Run pipeline、發布 | 同左 | 無 | 無 |

### 2.4 Agent 怎麼發

Agent 是另一個專案（`auditmanager/evidence-agent`）、另一顆按鈕，流程與圖 1 相同，蓋同一把正式章（客戶機只認一批公鑰；190 的 Agent 已認得）。和主系統不同的三處：

| | 主系統 | Agent |
|---|---|---|
| 190 怎麼試 | 更新中心升主系統 | 更新中心 Agent 區「推送 Agent」 |
| 離線檔（不出網的客戶從 Agent 主控台上傳） | 無 | 蓋章時一起產 `guidant-agent-<版>.offline.tar.gz`（包＋簽好的清單＋`.sha256`），留在 188 `.build/compose/`，與主系統安裝包同樣不上 artifacts；不出網的客戶從 188 拿 |
| 打包時另一個簽章 | 無 | Agent 映像內檔案清單送 License Center 簽（`sign_agent_manifest.sh`，與出版章無關），要 `LICENSE_CENTER_API_TOKEN`；改從 Vault `kv/ci/license-center` 拿，188 `/opt/guidant-ai-agent/.env` 不再放 |

兩條同時按時，後到的在 188 主機鎖（`/home/jedi/.guidant-build.lock`）排隊，不會兩個 Nuitka 同跑；GitLab 的 `resource_group` 只在同一專案內有效，擋不了跨專案。主系統這一版要求新 Agent 時，先發 Agent、再發主系統（主系統清單的 `min_agent_version` 會指到它）。

---

## 3. 簽章金庫 {#vault nav="金庫"}

| 項 | 定案 |
|---|---|
| 鑰匙 | 正式章 `guidant-release`（已建，kid `05086455d42ca0d6`）；測試章 `guidant-test`（待建）。都是 Ed25519、`exportable=false`、`deletion_allowed=false` |
| 身分 | GitLab `id_tokens`（aud＝Vault）→ Vault JWT auth 角色。正式角色 `release-ci`：綁 BE、Agent 兩專案、`ref_type=tag`、`ref_protected=true`、`user_id`＝決策者，policy 給正式章簽章＋`kv/ci/*` 讀；測試角色 `test-ci`：綁兩專案、`ref_type=branch`、`user_id`＝決策者，policy 給測試章簽章＋Nexus、Harbor、License Center 三筆讀；FE 角色 `fe-ci` 只綁 FE 專案、只讀 Nexus。**只有決策者能發版、能跑測試包**靠 `user_id`（按 Run pipeline 的人）擋：GitLab 免費版受保護 tag 只能選角色、不能指定人，五位成員都是 Maintainer 以上。要加人就把他的 `user_id` 加進兩個角色，重寫角色即可，不改程式、不出版 |
| 稽核 | Vault audit log 每次簽章記 job id、project、ref；D-1 寫進 SOP 怎麼查 |
| 公鑰 | 正式章公鑰在 BE `scripts/installer/release-keys/` 與 `PROD_KIDS`，隨 1.28.0 裝上客戶機；客戶端本來就支援多把 kid，**零改動**。客戶機要先升到 1.28.x 才認得這把（190 已有；189 POC 還在 1.27.x，要先升）。測試章公鑰只放 190（188 STG、189 POC 不放：任何 branch 都能簽測試章，信任它的機器就等於信任未經審查的程式碼），**不進安裝包**，只手動複製到 190 的 `/etc/guidant/release-keys/` |
| 備用 | Mac 根印章保留第二把 kid；Vault 出事時 `publish --signer file` 照舊 |
| 輪換 | 加新鑰 → 新 kid 進 `PROD_KIDS` 出一版 → 舊 kid 標 retiring → 下一版拔；SOP 在 D-1 |
| 建置期祕密 | Nexus npm 認證、R2 金鑰、更新站 CI token、Harbor 帳密、License Center API token（Agent 打包用）進 Vault KV；188 上 `~/.npmrc`、`.env` 內對應鍵刪掉；job 執行時 `secrets:` 拿、結束即失效 |

**188 工作區共用的風險與擋法**：BE、Agent CI 都直接在 188 的 `/opt/guidant-ai-*` 跑（`GIT_STRATEGY: none`），測試包（任何 branch）與正式包共用同一個工作區、`.venv`、映像快取。任何 branch 的 pipeline 都可能留下被改過的快取，下一次正式包把它包進去再蓋正式章。擋法（C-1、C-2 的必做項）：正式模式不准 `skip_be`／`--reuse-images`；`.build/image-cache/` 正式模式只讀不寫，讀入後 `docker load` 比對 image ID，不符就丟掉重匯；`.venv` 正式模式先 `poetry install --sync` 再跑。殘餘風險：能推 branch 的人就能讓 188 以 `jedi`（在 docker 群組，等同 root）跑任意指令——BE、Agent 是人按 Run pipeline 時、FE 是每次 push 時；讀得到 188 上的明文 `.env`、Harbor 登入，也能動共用快取。目前 BE、Agent、FE 三個專案成員是同一批五位 Maintainer，FE 改成 push 就跑並沒有多開放給新的人。Vault 裡的章與 License Center token 有 `user_id` 擋住，188 上的明文副本在第二批 CI 驗完後清掉（STATE §3）。要徹底隔離得另開一台只收受保護 ref 的 runner，本案不做，記在 §8；**FE 專案日後若加入非 Maintainer 成員，要先把 FE 的 verify 移出 188 或改成人按才跑。**

**不解的**：客戶主機執行期密碼（D8，另案）。

---

## 4. 拆卡表 {#cards nav="拆卡表"}

由 `handoff/cards.py` 產。每批開工前決策者做完 §5 對應那段功課。

| 批 | 卡 | 階段 | repo | 做什麼 | reuse／extend／new | 改既有檔 | 新開檔 | 依賴 | 驗收（小弟本機） | model／effort |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | **V-1** | 簽章金庫 | update-center | 出包時就蓋章、GitLab 身分發布：蓋章 CLI 接 Vault Transit（正式章 guidant-release、測試章 guidant-test），新增 sign-draft 子命令讓 CI 登記草稿時帶上簽好的清單（Agent 版同時產離線檔）；更新站 Worker 收簽好的草稿、發布端點多認 GitLab 的 job 身分（只限 BE／Agent 專案受保護 tag），發布不再帶章；Mac 私鑰留備用 | extend：`cli/keystore.ts` 加 `VaultSigner`（與既有檔案私鑰 signer 同介面）；`cli/envelope.ts` 簽章函式吃 signer；`cli/commands.ts` 加 `sign-draft`（`--product agent` 時 reuse `cli/offline.ts` 產離線檔）、`publish --signer vault|file`；Worker `src/access.ts` 照 `verifyAccessJwt` 的寫法加 GitLab ID token 驗證、`register-draft` 收 `signed_manifest`＋`kid`、`publish` 對 GitLab 身分只改狀態不收章 | `cli/keystore.ts`<br>`cli/envelope.ts`<br>`cli/commands.ts`<br>`cli/README.md`<br>`src/access.ts`<br>`src/routes/admin.ts`<br>`src/releases.ts`<br>`test/` | `cli/vault_signer.ts`<br>`test/cli/vault_signer.spec.ts`<br>`test/gitlab_identity.spec.ts` | — | vitest：`VaultSigner` 對 mock 的 Vault 組出合法簽名、`verifyRelease` 驗過；file signer 行為不變；缺 Vault 身分明確錯誤不退回 file；register-draft 帶簽好清單後內部先裝機器 check 拿得到 `signed_manifest`；GitLab ID token 不是 BE／Agent 專案、不是受保護 ref、過期、aud 不對都 403；Cloudflare 本人登入的 publish 路徑照舊可用。用開發帳號對 172 以測試章實簽一份清單並驗過；正式章只讀公鑰、核 kid＝`05086455d42ca0d6`（開發帳號不能簽正式章，正式章實簽在 C-1 由 CI 驗） | opus／high |
| 1 | **C-3** | 三條 CI | FE | FE 自己的 CI：刪三支舊檔（ECR／ECS／ansible），換一支 verify＋build：push 跑 lint、vitest、npm 弱掃（syft＋grype 對 package-lock）；BE 觸發或 tag 時 build FE image 推 Harbor；主控台前端同一支 pipeline build 成 artifact 給 Agent CI 拿 | new（照 BE CI 分段，跑 188 的 `guidantai-build` shell runner；188 沒裝 node，npm 一律在 node 容器裡跑）：弱掃 reuse BE `scripts/build/sbom.sh` 的 lockfile 掃法搬成 FE repo 自己的 `scripts/ci/sbom_fe.sh`；Nexus 認證從 Vault KV 拿（job 內用 `id_tokens` 登入 Vault 再讀 KV；GitLab 免費版不能用 `secrets:` 關鍵字），不再 GitLab Variable | `.gitlab-ci.yml（重寫）` | `scripts/ci/sbom_fe.sh`<br>`scripts/ci/vuln-allowlist.txt` | — | 刪 `.gitlab-ci-on-premises.yml`、`.gitlab-ci-terraform.yml`；push feature branch 跑 verify 綠（有白名單外 Critical 會紅）；tag 跑 build 推 Harbor `guidant-ai-fe:<版>`；artifacts 有 `dist-console/` | opus／medium |
| 2 | **C-3b** | 三條 CI | FE | FE Dockerfile 多一個主控台打包目標 console-dist：docker build 就能產出 Agent 主控台 dist-console/，不必在 Mac 打包（從 C-2 拆出，CM-3480） | extend：既有 Dockerfile 拆出共用 deps stage，主系統與主控台各一個 build stage；不帶 --target 時產物不變 | `Dockerfile` | — | — | `docker build --target console-dist --output type=local` 產出 index.html＋assets/（≤2 MB）；主系統映像照舊通過 build_image.sh --no-push 檢查 | opus／medium |
| 2 | **C-2** | 三條 CI | evidence-agent | Agent 自己的 CI（新）：verify → build（主控台前端從 FE pipeline 拿）→ rehearse → package（SBOM 放行門、build_compose_bundle、Harbor、sign-draft 蓋 Vault 章並產離線檔、登記草稿 product=agent、R2 agent/v1/）→ 手動 publish；License Center 送簽 token 改從 Vault 拿；測試包模式同 C-1；188 shell runner、resource_group 與 BE 串行 | new：照 BE `.gitlab-ci.yml` 五段與 `.sync_repo`／`.in_repo`／`.collect` 片段；`publish_draft.sh` 從 BE 複製成 Agent 版加 `--product agent`（先複製，不跨 repo 共用）；`vault_login.sh` 照 C-1 那支複製；測試包模式沿用既有 `--internal-test` | `scripts/build/build_all.sh（CI 模式與既有 --internal-test 併用）`<br>`scripts/build/README.md` | `.gitlab-ci.yml`<br>`scripts/build/publish_draft.sh`<br>`scripts/build/vault_login.sh` | C-3, C-3b | 正式模式：五段綠、更新站草稿 `product=agent` 帶 Vault 章、R2 `agent/v1/<版>/` 三檔、188 `.build/compose/` 有離線檔、按 publish 後 published；188 `/opt/guidant-ai-agent/.env` 不再需要 `LICENSE_CENTER_API_TOKEN`；測試包模式：測試章簽、不碰 Harbor／R2／更新站；與 BE pipeline 同時觸發時第二條在 `resource_group` 排隊不併跑 | opus／high |
| 2 | **C-1** | 三條 CI | BE | BE 發版 pipeline 改版：trigger FE 下游（strategy mirror）、不再在 188 build FE、祕密從 Vault 拿、package 段用 V-1 的 sign-draft 向 Vault 蓋章後登記草稿、最後一個手動 job「發布」用 GitLab 身分叫更新站公開；internal_test 擴成「測試包」模式（跳排練、SBOM／弱掃、Harbor、R2、更新站，測試章簽、scp 直升 190）；release note 檔尾自動送更新站；runbook 改寫 | extend：`.gitlab-ci.yml` 的 `build-fe` 改 `trigger:`；`package` 段加 sign-draft、新 job `publish`（manual、只在受保護 tag）；`internal_test` input 改成測試包模式（既有開關擴充，不另開）；`publish_draft.sh` 帶簽好清單與 release note 摘要；runbook 步驟 2.2／2.3／4 重寫 | `.gitlab-ci.yml`<br>`scripts/build/publish_draft.sh（帶簽好清單＋release note 摘要）`<br>`docs/features/FR-167-2610-online-update-channel/release-runbook.md` | `scripts/build/release_notes_tail.sh`<br>`scripts/build/vault_login.sh` | V-1, C-3 | 正式模式（受保護 tag）：FE 下游綠才進 build-be；草稿登記時已帶 Vault 章、只有 190 check 看得到；190 升上去後按 `publish` job，更新站轉 published、kid 是 Vault 正式章；從 branch 跑的 pipeline 要不到正式章、`publish` job 不出現。測試包模式：不跑排練／SBOM／Harbor／R2／更新站，包用測試章簽、scp 升 190 成功，總時間比正式模式少 20 分以上。188 上 `grep -r R2_SECRET ~/.npmrc /opt/*/.env` 為 0；runbook 無「Mac 蓋章」步驟（備用章段除外） | opus／high |
| 3 | **C-4** | 三條 CI | jedi 套件 | jedi 套件 CI：push 跑各套件 pytest 與 pip-audit；分類器 image 由 BE 下游 trigger build 推 Harbor（runner 待決策者開給套件群組） | extend：既有 `.gitlab-ci.yml`（若有）加 job；分類器 image build reuse BE `build_classifier_image.sh` 搬到套件 repo | `.gitlab-ci.yml` | `jedi-evidence-classification/docker/ci.sh` | C-1 | push 跑測試綠；BE tag 觸發時分類器 image 在 Harbor 有新 tag | sonnet／medium |
| 3 | **D-1** | 文件 | BE | 文件：補齊獨立文件 docs/system-design/release-pipeline/出版管線總覽.md 八段（全景、機器、服務、祕密、連線、流程、從零重建、出事怎麼辦），含 Vault 鑰匙輪換與備用章啟用 SOP；FR-173 的 FINAL-SPEC 只放指向它的摘要 | new 文件 | `docs/system-design/release-pipeline/出版管線總覽.md`<br>`docs/system-design/README.md` | `docs/features/FR-173-2611-release-pipeline/FINAL-SPEC.md` | C-1, C-2 | 總覽八段沒有「待填」；每台機器、每個服務都實查過（ssh／API 讀回），不照抄 design；FINAL-SPEC 五段齊；`grep -n "原本\|改判\|第 [0-9] 棒"` 為 0 | sonnet／low |

共 7 張、3 批；update-center 1、FE 2、evidence-agent 1、BE 2、jedi 套件 1。

---

## 5. 決策者功課 {#homework nav="功課"}

| 什麼時候前 | 做什麼 | 狀態 |
|---|---|---|
| 派批 1 前 | Vault 測試章 `guidant-test`、開發帳號 `release-dev`（只能簽測試章）、Mac CA 憑證、update-center `.env` 四個 `VAULT_` 鍵 | ✅ 2026-11-03，首腦實測登入、簽章、越權 403 |
| 派批 1（C-3）前 | Vault JWT 登入（認 gitlab.com）、`kv/ci/` 五筆祕密、`ci-fe`＋`fe-ci`；FE 專案 CI 設定檔路徑清空（原指已刪的舊檔）；188 信任 Vault CA | ✅ 2026-11-03，FE pipeline 3022544900 實跑通過 |
| 派批 2 前 | `ci-release`／`ci-test` 政策、`release-ci`／`test-ci` 角色（綁 `user_id`）；測試章公鑰只放 190；BE、Agent `v*` 受保護 tag；FE job token 允許清單加 Agent | ✅ 2026-11-03，首腦讀回確認 |
| 第二批 CI 驗完後 | 刪 188 上的明文帳密（`/opt/guidant-ai-*/.env` 的 License Center token、`~/.fr167-r2.env`、`~/.npmrc`）、BE 專案 GitLab 變數的 R2 與 Bugsink；License Center 換新 token 放進 Vault | ⬜ |
| 派批 3 前 | jedi 套件群組（`commonservice3`）開一個 runner | ⬜ |
| 正式出 1.29 前 | Vault 每日快照＋異地備份、防火牆（只放 188、決策者 Mac）；189 POC 先升到 1.28.x；部署更新站 Worker 後在後台登記 `05086455d42ca0d6` 為 primary、Cloudflare Access 放行 GitLab 打發布端點 | ⬜ |

指令逐行寫在 `vault-setup-runbook.md` 第 14 段。

---

## 6. 端到端驗收 {#e2e nav="驗收"}

1. 從 branch 勾 `internal_test` 跑 BE：190 被直接升上去；Harbor、R2、更新站都沒有這次的東西。
2. 從受保護 tag 跑 BE：FE 下游紅 → BE 停在 trigger 不往下；FE 綠 → 一路到 register-draft。
3. 190「立即檢查」看得到草稿、升級成功；別台看不到。
4. 按「發布」：更新站 `published`、`kid` 是 `05086455d42ca0d6`；Vault 稽核 log 有一筆 sign。
5. 從 branch 跑正式模式：在 sign 那步紅，沒有「發布」job。
6. Agent 與 BE 同時按：第二條排隊不併跑。
7. 188：`grep -r "R2_SECRET\|_auth=\|LICENSE_CENTER_API_TOKEN" ~/.npmrc /opt/guidant-ai-*/.env` 為 0。
9. 從 Agent 受保護 tag 跑：190 推送 Agent 成功；188 `.build/compose/` 有 `guidant-agent-<版>.offline.tar.gz`，拿一台不出網的 Agent 從主控台上傳驗過。
10. 拔掉 Vault：決策者 Mac `publish --signer file` 照樣能發（備用路徑）。

---

## 7. 時程 {#timeline nav="時程"}

以 FR-171、172 實際節奏推估（工作坊一批 2～3 張平行，一張卡 2～4 小時）：

| 段 | 內容 | 預估 |
|---|---|---|
| 批 1 | V-1（蓋章工具＋更新站三處，本案最大一張）、C-3 | 半天 |
| 批 2 | C-1、C-2；要真跑 pipeline 才驗得到，一輪 30～50 分，算兩三輪修 | 半天到一天 |
| 批 3 | C-4、D-1 | 2～3 小時 |
| 總驗 | §6 八項，從 tag 跑一次正式、一次測試包 | 半天 |
| **合計** | | **約 2 個工作天** |

**會拉長的地方**：決策者功課沒在批次前做完，小弟會停下等；GitLab 免費版的 artifact 下載方式（C-2）若不通要換做法。

---

## 8. 不做什麼 {#out nav="不做"}

update-center 與 LC 進管線（update-center 本身的部署照舊手動）、BE／Agent 的 Nuitka 塞進 Dockerfile 變純容器 build、Sigstore keyless（客戶離線驗不了、簽章公開）、雲端 KMS（要換演算法）、客戶主機預設密碼（另案）、推 tag 自動跑（維持人按）、正式包專用 runner（只收受保護 ref 的第二台 runner，見 §3 工作區共用的殘餘風險）。
