---
title: "發版操作手冊 (FR-167)"
brand: "Guidant AI · **FR-167** 線上更新"
eyebrow: "FR-167 · 操作手冊 · 2026-11-01"
h1: "發版操作手冊"
subtitle: "從定版號到三台主機升完：188 出包、開包驗、蓋章、上傳 R2 與 D1、驗更新站、依序升級、失敗處理與 build 機清理"
lede: "給原廠內部發版的人照著做。每一步寫明在哪台機器、跑什麼指令、成功長什麼樣、失敗看哪裡。指令都對過 repo 內的腳本與旗標；188 上的暫存腳本與 Mac 上的測試期發布腳本不在 repo，本頁寫出它們的內容與位置。**本頁不含任何密碼、金鑰或帳號編號**，需要時只寫存放位置。"
---

## 角色與前提 {#roles nav="角色"}

| 角色 | 做什麼 | 在哪台 |
|---|---|---|
| 決策者 | 定版號、蓋正式章（`guidant-release publish`）、放行 STG／POC 升級、母卡 Done | 自己的 Mac（發布印章私鑰只在這台） |
| 發版執行者（首腦或被指派的人） | 版號與 release note、188 出包、開包驗、測試期上傳 R2／D1、升 190 測試機 | Mac＋188 |
| 客戶主機管理員 | 在更新中心按「更新到 X」 | 客戶主機 |

**機器座標**

| 機器 | 角色 | 存取 |
|---|---|---|
| 192.168.50.188 | build 機（`/opt/guidant-ai-be`、`/opt/guidant-ai-fe`、`/opt/guidant-ai-agent`、`/opt/jedi-python-package`）＋STG stack（`/srv/guidant-ai`） | `ssh jedi@192.168.50.188`，docker 免 sudo；**沒有 node** |
| 192.168.50.190 | 測試機，升級先在這台跑 | `ssh jedi@192.168.50.190` |
| 192.168.50.189 | POC stack（等同正式環境） | `ssh jedi@192.168.50.189`；升級要決策者當次放行 |
| Mac | FE 主控台前端 build、測試章簽章、wrangler 寫 D1 | 本機 |

**要準備的環境變數與憑證（只列名稱與位置）**

| 名稱 | 誰用 | 放在哪 |
|---|---|---|
| `GUIDANT_BUGSINK_ADMIN_PASSWORD` | `build_all.sh` 前提檢查、`build_bundle.sh` 烙進安裝包 | 正式：GitLab CI 遮罩變數；188 手跑：188 `/home/jedi/fr167-test-bugsink-admin.txt`（≥12 字元、不可含 `$`） |
| `LICENSE_CENTER_API_TOKEN`、`LICENSE_ACTIVATION_SERVER_URL` | Agent build 第 3 步送簽 | 188 `/opt/guidant-ai-agent/.env` |
| `FE_CONSOLE_DIST` | Agent build 收主控台前端 | 指向 188 `/tmp/fr167-fe-console/dist-console` |
| R2 讀寫金鑰、R2 endpoint | 上傳 R2 | 測試期：188 `/home/jedi/.fr167-r2.env`（600）；CI：`R2_ACCESS_KEY_ID`／`R2_SECRET_ACCESS_KEY`／`R2_ENDPOINT` 遮罩變數；本機：update-center `.env` |
| `UPDATE_SITE_URL`、`UPDATE_SITE_CI_TOKEN` | CI 的 `publish_draft.sh` 登記草稿 | GitLab CI 遮罩變數 |
| Cloudflare API 權杖與帳號 | wrangler 寫 D1、部署 Worker | update-center `.env`；Mac 上 wrangler 已登入 |
| 發布印章（主力＋備用） | `guidant-release publish` | 決策者 Mac `~/.guidant-release`（passphrase 只從終端機讀） |
| 測試章 | 測試期簽章 | Mac `~/Projects/Billows/Audit-Manager/.fr167-dev/publish/sign/`（不入版控） |
| `RELEASE_HAS_SECURITY_FIX` | `release_json.sh` 標「含安全修正」 | 出包時 export `true`；不設＝`false` |

**本機工具**：Mac 要有 node 22、`npx wrangler`、`ssh` 免密到三台；188 要有 `rclone`、BE `.venv`（含 `pip-audit`）。

## 流程總圖 {#flow nav="總圖"}

```{.mermaid cap="圖 1 — 一次發版從版號到三台升完"}
%%{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 TD
  S1["步驟 1 版號與 release note<br/>BE／FE／Agent 各自 bump、commit、push"] --> S2a
  subgraph S2["步驟 2　188 出包"]
    S2a["pull 三個 clone 到目標 commit<br/>pgrep 查有沒有別的 build"] --> S2b["Mac：npm run build:CONSOLE<br/>scp 到 188"]
    S2b --> S2c["Agent：build_all.sh --with-compose"]
    S2c --> S2d["主系統：build_all.sh --all<br/>→ build_bundle.sh"]
  end
  S2d --> S3["步驟 3 開包驗<br/>grep 本版新字串、storage-seed、ops 小檔、Agent 包"]
  S3 --> S4{"步驟 4 蓋哪一種章？"}
  S4 -- 測試期 --> S4t["Mac 測試章簽<br/>（含 ops 兩欄）"]
  S4 -- 給客戶 --> S4p["決策者 guidant-release publish<br/>（正式章）"]
  S4t --> S5["步驟 5 上傳 R2 五檔＋D1 寫一列<br/>（含三段說明）"]
  S4p --> S5
  S5 --> S6["步驟 6 驗更新站<br/>D1 列、R2 物件、planPath、190 立即檢查"]
  S6 --> S7a["步驟 7 190 升級 → 驗"]
  S7a --> S7b["188 STG 升級 → 驗<br/>（決策者放行）"]
  S7b --> S7c["189 POC 升級 → 驗<br/>（決策者放行）"]
  S7c --> S7d["各台 Agent 區推送 Agent"]
  S7a -. 失敗 .-> S8["步驟 8 失敗處理<br/>進度檔、journal、diag、recover"]
  S7b -. 失敗 .-> S8
  S7c -. 失敗 .-> S8
```

正式出貨走 GitLab CI（`.gitlab-ci.yml`：verify → build → rehearse → package → deploy），出包、排練升級、推 Harbor、登記草稿上 R2 一條龍；決策者再用 `guidant-release publish` 蓋章。本頁步驟 2～5 寫的是 **188 手跑**這條路（CI 不可用、或要出內部測試包時），並標出哪些事只在 CI 做。

## 步驟 1　版號與 release note {#version nav="1 版號"}

**主系統（BE）**：照 `version-bump` skill（`.claude/skills/version-bump/SKILL.md`）做兩件必做的事：

1. 寫 `docs/release_notes/v<X.Y.Z>.md`，檔尾固定一段「更新中心顯示文字」（3～5 條白話＋影響範圍＋建議動作；停機不寫分鐘數，統一寫「升級期間將看到維護頁」）。這段是更新站後台三欄的來源，見步驟 5。
2. `pyproject.toml` 的 `version` 改成新版號。`build_bundle.sh`、`release_json.sh`、`gen_stamp_sql.sh` 都從這裡讀版號。

**前端（FE）**：`package.json` 的 `version` 對齊同一個 `X.Y.Z`。

**Agent**：程式有改就**一定要升版號**（同號重出，更新中心會判「已是最新」而推不出去）。

- `evidence-agent/pyproject.toml` 的 `version`。
- `evidence-agent/RELEASE_NOTES.md` 加一段 `## <版號>`，條列白話（build 時取最多 5 條寫進 Agent 的 `notes.json`，更新中心 Agent 區顯示它）。

**跟版號一起確認的檔**

| 檔 | 什麼時候要動 |
|---|---|
| `scripts/build/min_agent_version` | 這一版主系統不再接受某版以下的 Agent 時，寫最低版號（一行）。檔不存在＝等於本版 |
| `scripts/sql/manifest.tsv` 第五欄 `min_base` | 這一版有 migration 要求客戶「必須先經過某版」時標記；`release_json.sh` 取小於本版的最大標記當 `min_base_version` |
| `scripts/init/99-stamp.sql`、`02-schema.sql` | 這一版有主線 `phase=active`／`envs=*` 的新 migration 時要重產基線，否則 `build_init_image.sh` 的 gate (a) 會擋（見步驟 8）。重產步驟見 `sql-migration` skill「出貨基線同步」段 |

三個 repo 各自 commit、push 到當下的 feature branch。188 要 pull 得到才算數。

## 步驟 2　188 出包 {#build nav="2 出包"}

### 2.1 開跑前三件事

```bash
# ① 三個 clone 拉到目標 commit，親眼看 HEAD 與版號
ssh jedi@192.168.50.188 'for r in /opt/guidant-ai-be /opt/guidant-ai-fe /opt/guidant-ai-agent; do git -C $r pull -q --ff-only && echo "$r $(git -C $r log -1 --format="%h %s" | cut -c1-70)"; done; grep -m1 ^version /opt/guidant-ai-be/pyproject.toml /opt/guidant-ai-agent/pyproject.toml'

# ② 查有沒有別的 build 在跑（兩個 Nuitka 同時跑會 linker crash）。[b] 寫法避免抓到自己這行
ssh jedi@192.168.50.188 'pgrep -af "[n]uitka|[b]uild_all|[b]uild_bundle" || echo 沒有在跑的 build'

# ③ 磁碟：一包主系統 5.3G＋解開的目錄＋image 層，至少留 40G
ssh jedi@192.168.50.188 'df -h / | tail -1'
```

BE 有合回的工作坊分支時，**先合回 feature branch 並 push 再出包**；188 拉的是 feature branch，沒合回就出包，包裡不會有修正。

### 2.2 主控台前端（Mac build、scp 到 188）

Agent 映像要收 Agent 主控台的前端產物，188 沒有 node，所以在 Mac build：

```bash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
git log -1 --format='%h %s'          # 確認是要出的那個 commit
npm run build:CONSOLE                # 產出 dist-console/
ssh jedi@192.168.50.188 'rm -rf /tmp/fr167-fe-console/dist-console && mkdir -p /tmp/fr167-fe-console'
scp -rq dist-console jedi@192.168.50.188:/tmp/fr167-fe-console/
```

**每次出 Agent 都要重 build 一次**。沿用 188 上舊的 `dist-console` 會讓新版 Agent 帶舊主控台，而且沒有任何錯誤訊息。

### 2.3 先出 Agent

`build_bundle.sh` 會到 `/opt/guidant-ai-agent/.build/compose/` 取**最新一支** `guidant-agent-compose-*.tar.gz` 收進主系統包，所以 Agent 一定先出。

```bash
# 188 上
cd /opt/guidant-ai-agent
export FE_CONSOLE_DIST=/tmp/fr167-fe-console/dist-console
scripts/build/build_all.sh --check-only      # 秒級：LC token、簽發站、工具鏈、磁碟、主控台產物
scripts/build/build_all.sh --with-compose    # fetch 工具 → Nuitka（約 11 分）→ 簽章打包 → smoke → compose 包
ls -la .build/compose/ | grep <Agent 版號>   # 要有 .tar.gz、.tar.gz.sha256、.notes.json
```

要指定別支 Agent 包（例如沿用上一版 Agent），出主系統前 `export AGENT_COMPOSE_BUNDLE=<檔>`。

### 2.4 再出主系統

`scripts/build/build_all.sh` 開跑前自己擋三道前提（以前只寫在 `.gitlab-ci.yml`，手跑永遠過不了）：

| 前提 | 腳本怎麼處理 | 手跑要做什麼 |
|---|---|---|
| ① `pip-audit` 找得到 | 自己 `export PATH=<repo>/.venv/bin:$PATH`；還找不到就紅 | 188 `.venv` 已裝；不用做事 |
| ② DB 前置斷言回 3（STG 落後本版 migration） | ⓪ 放行；② smoke 走 `smoke_auto.sh` → `smoke_tempdb.sh`：STG 唯讀 `pg_dump` → 188:25432 臨時庫 → 套本版 migration → smoke → 刪庫 | 不用做事。**絕不為了出包去升 STG** |
| ③ `GUIDANT_BUGSINK_ADMIN_PASSWORD` ≥ 12 字元 | 開跑第一步 `gen_bugsink_admin_bake.sh --check` | **要自己 export**（值從檔案讀，不上命令列） |

188 上用一支暫存腳本串起來（放 `/home/jedi/fr167-build-<版>.sh`，不入版控），內容照這個寫：

```bash
#!/usr/bin/env bash
set -o pipefail
cd /opt/guidant-ai-be || exit 1
export GUIDANT_BUGSINK_ADMIN_PASSWORD="$(tr -d '[:space:]' < /home/jedi/fr167-test-bugsink-admin.txt)"
echo "[$(date +%T)] HEAD $(git rev-parse --short HEAD) $(grep -m1 ^version pyproject.toml)"
scripts/build/build_all.sh --all || { echo "[$(date +%T)] build_all FAILED rc=$?"; exit 2; }
scripts/build/build_bundle.sh --skip-prod-key-check || { echo "[$(date +%T)] build_bundle FAILED rc=$?"; exit 3; }
echo "[$(date +%T)] ALL DONE"
```

- `--skip-prod-key-check`：**只准內部測試包用**。它略過「BE image 要含 PROD 授權公鑰」與「要有 Agent 包」兩道守門，包尾會印「僅供內部測試，不可交付客戶」。給客戶的包不加這個旗標，缺 PROD 鑰就該紅。
- Agent 與主系統要一起出時，把 2.3 的指令放在同一支腳本前段（先 Agent、`ls` 確認 compose 包、再主系統），一個背景行程跑完。
- 背景跑、log 落檔：

```bash
ssh jedi@192.168.50.188 'nohup setsid bash /home/jedi/fr167-build-<版>.sh > /home/jedi/fr167-build-<版>.log 2>&1 < /dev/null &'
```

等它結束時，**判斷條件要錨定腳本自己印的那幾行**。smoke 輸出含 `FAIL=0` 字樣，只 grep `FAIL` 會提前誤判：

```bash
ssh jedi@192.168.50.188 'grep -E "^\[[0-9:]+\] (ALL DONE|.*FAILED)" /home/jedi/fr167-build-<版>.log; pgrep -af "[f]r167-build" || echo 已結束'
```

### 2.5 只換版號的快速出包

程式碼與上一版完全相同、只要換版號重發（例如只為了驗證升級流程本身）時：

```bash
scripts/build/build_all.sh --reuse-images <舊版> --version <新版> --skip-prod-key-check
scripts/build/build_all.sh --reuse-images <舊版> --version <新版> --dry-run    # 只印步驟
```

be 疊一層只換 `/app/VERSION`、fe／init 重打 tag，跳過編譯、smoke、image build 與 OS 層 SBOM，直接 `build_bundle.sh`。舊 be image 沒有 `/app/VERSION` 會直接擋。成功後自動 `docker builder prune -f --keep-storage 20G`；連出兩包時第一包加 `--no-prune`。有程式碼變動就要完整跑 2.4。

### 2.6 只在 CI 做的事

| 事 | CI 哪一段 | 188 手跑時 |
|---|---|---|
| 排練升級：190 先裝舊版＋灌 STG 唯讀 dump，再用新包升級 | `rehearse-upgrade`（`deploy_e2e.sh`） | 不做；改由步驟 7 在 190 線上升級驗 |
| `internal_test` input：排練段用 `--skip-prod-key-check` | `rehearse-upgrade` | 等同 2.4 手跑加旗標 |
| 正式包（不准 `--skip-prod-key-check`）、推 Harbor | `package`（`push_harbor.sh`） | 不做 |
| 登記更新站草稿＋傳 R2 三檔 | `package`（`publish_draft.sh`） | 改用步驟 5 的測試期腳本 |
| 部署 STG／POC | `deploy-stg`、`deploy-poc`（POC 為 manual） | 不做；改走步驟 7 |

### 2.7 時間估算（188 實測）

| 段落 | 時間 |
|---|---|
| Agent `build_all.sh --with-compose` | 約 20 分（Nuitka 約 11 分） |
| 主系統 `build_all.sh --all`＋`build_bundle.sh` | 約 33 分（`build_release` 12～20 分、smoke 臨時庫數分、FE image 約 20 秒、封包數分） |
| Agent＋主系統同一支腳本 | 約 55 分 |
| `--reuse-images` 快速出包 | 約 11 分 |

### 2.8 產物

`/opt/guidant-ai-be/.build/bundle/` 下：

| 檔 | 用途 |
|---|---|
| `guidant-ai-<版>.tar.gz`＋`.sha256` | 安裝包本體（約 5.3G） |
| `guidant-ai-<版>.ops.tar.gz`＋`.sha256` | 維運指令小檔（約 100K），升級第 0 步憑它秒級換指令 |
| `guidant-ai-<版>.release.json` | **未簽**發布清單（版號、主版號、`min_base_version`、整包與小檔的雜湊與大小、`min_agent_version`、`has_security_fix`；`issued_at` 留空） |
| `guidant-ai-<版>/` | 解開的 staging 目錄，開包驗用；驗完可刪 |

## 步驟 3　開包驗 {#inspect nav="3 開包驗"}

build log 說成功不代表修正有進包。**挑這一版 commit 新增的獨特字串去 grep 產物，回 0 就是沒進去**。下表是每次都要過的項目，`<版>` 換成版號：

```bash
cd /opt/guidant-ai-be/.build/bundle
V=<版>
# 1. 版號與 Agent 包
cat guidant-ai-$V/VERSION
ls guidant-ai-$V/agent/                     # guidant-agent-compose-<Agent 版>.tar.gz、.sha256、.notes.json
# 2. 系統檔（框架 PDF＋檢測基準檔）筆數，對 storage-seed.map 宣告的 key 數
find guidant-ai-$V/storage-seed -type f | wc -l
# 3. 維運指令小檔內容（VERSION、guidantai、guidantai_bugsink.sh、lib/、release-keys/、maintenance/）
tar -tzf guidant-ai-$V.ops.tar.gz | wc -l
tar -tzf guidant-ai-$V.ops.tar.gz | grep -E 'lib/|maintenance/' | head
# 4. 清單欄位：ops 兩欄要在、min_base_version 合理
python3 -m json.tool guidant-ai-$V.release.json
sha256sum guidant-ai-$V.ops.tar.gz; stat -c %s guidant-ai-$V.ops.tar.gz    # 要等於清單的 ops_sha256／ops_size
# 5. 版號區間與內容清單
cat guidant-ai-$V/images/bundle-info.txt
# 6. 主系統執行檔含本版新字串（grep dist，不要 grep 整顆 image，太慢）
grep -a -c '<本版新增的字串>' /opt/guidant-ai-be/.build/dist/guidant-ai-$V/guidant-ai
# 7. Agent 映像環境變數（Agent 有改設定時）
docker image inspect guidant-ai-agent:<Agent 版> --format '{{json .Config.Env}}' | tr ',' '\n' | grep -E 'LIBREOFFICE|<相關變數>'
```

- **前端改動**：壓縮後的 JS 變數名會被改掉，grep 變數名找不到；要 grep **字串常數的字面值序列**（例如一串狀態名 `rolled_back,cancelled,skipped`），在 FE image 的 html 目錄或 `/opt/guidant-ai-fe/dist` 找。
- **storage-seed** 由 `storage_seed_guard.sh` 每次從套件 assets 重建並對 map 核對，筆數對不上 `build_bundle.sh` 會擋；這裡再看一眼筆數是為了發現 map 本身漏宣告。
- 任一項不對：不要發布。修了程式就重出（步驟 2）；前一顆是壞包（裝起來有路走不通）就**同版號重出覆蓋**，舊包改名 `.BAD-<原因>` 保留；前一顆可用只是少東西就升版號。

## 步驟 4　簽章 {#sign nav="4 簽章"}

build 機與 CI 都沒有發布私鑰，`release.json` 出包時是未簽的。簽章只在 Mac 做。

| | 測試章 | 正式章 |
|---|---|---|
| 給誰 | 190／188／189 這些已裝測試章公鑰的機器 | 客戶 |
| 誰簽 | 發版執行者 | 決策者本人 |
| 工具 | Mac `.fr167-dev/publish/sign/sign-release.ts`（import update-center `cli/envelope.ts`，與正式 CLI 同一套 v2 信封） | update-center `guidant-release publish <版號>` |
| 公鑰在哪 | 測試機 `/etc/guidant/release-keys/` | `scripts/installer/release-keys/<kid>.pub`，隨包裝到客戶主機 `/etc/guidant/release-keys/` |
| 清單來源 | 188 的 `release.json`（含 ops 兩欄） | 更新站 D1 的草稿列（CI `register-draft` 登記） |

客戶主機只信自己機器上的公鑰。用哪把章簽，就只有裝了那把公鑰的機器收得下。

### 4.1 正式章：`guidant-release publish`

詳細見 update-center `cli/README.md`。流程是：CI `package` 段的 `publish_draft.sh` 先向更新站 `register-draft` 登記草稿並把三個檔傳到 R2 → 決策者在後台指定「內部先裝」機器、填三段說明 → 內部機器升到這版並回報成功 → 決策者在 Mac 跑：

```bash
cd ~/Projects/Billows/Audit-Manager/update-center
cloudflared access login <更新站網址>       # 第一次或憑證過期時
npm run cli -- publish <版號> --site <更新站網址>
```

`publish` 會核「內部先裝機器全部回報升到這版且成功」、要你輸入版號確認、從終端機讀 passphrase、蓋章後先用 `.pub` 自己驗一次再送回更新站。內部機器沒回報、失敗、或草稿沒指定任何內部機器，它不問 passphrase、什麼都不送。

**現況限制**：

| 限制 | 影響 | 目前做法 |
|---|---|---|
| 更新站草稿（`register-draft`、D1 `product_releases`）沒有 `ops_sha256`／`ops_size` 欄，`publish_draft.sh` 也只傳三個檔、不傳 ops 小檔 | 正式章簽出的清單不含 ops 兩欄，客戶升級第 0 步要從整包抽維運指令（時限依包大小，每 GB 60 秒、下限 300 秒），不能秒級完成 | 測試期改用 4.2 的腳本從 188 的 `release.json` 直接簽（含 ops 兩欄）；正式路徑補欄位另開卡 |
| CLI `publish` 成功時仍印「清單 90 天後到期」 | 客戶端已不檢查清單有效期，這句是舊提醒 | 忽略即可，不用每 90 天重蓋章 |

### 4.2 測試章：測試期腳本

Mac `~/Projects/Billows/Audit-Manager/.fr167-dev/publish/publish-<版>.sh`（不入版控；以最新一支複製改名）一次做完步驟 4、5：

1. 從 188 確認五個檔都在，取 ops 小檔的實際 sha256 與大小。
2. `scp` 未簽 `release.json` 回 Mac，核：版號＝參數、`sha256` 64 字、`size` 大於 1G、`ops_sha256`／`ops_size` 等於實際值。
3. `sign-release.ts` 用測試章簽成 v2 信封，解開 payload 確認 `ops_sha256`／`ops_size` 有進去。
4. 簽好的信封 `scp` 回 188 暫存，接步驟 5。

腳本頂端的 `S=` 是工作目錄（放 Mac 自己的 scratchpad），每次換 session 要改。

## 步驟 5　上傳 R2 與寫 D1 {#publish nav="5 上傳"}

### 5.1 R2 物件

bucket `guidant-ai-release`，key 一律 `v<主版號>/<完整版號>/<檔名>`，主版號資料夾是授權邊界：

| 物件 | 測試期 key | 誰讀 |
|---|---|---|
| 安裝包 | `v1/<版>/guidant-ai-<版>.tar.gz` | 客戶主機（經 `download-url` 拿 15 分鐘預簽網址） |
| 雜湊 | `v1/<版>/guidant-ai-<版>.tar.gz.sha256` | 人工核對 |
| 維運指令小檔 | `v1/<版>/guidant-ai-<版>.ops.tar.gz` | 客戶主機升級第 0 步（`download-url` 帶 `artifact: "ops"`） |
| 小檔雜湊 | `v1/<版>/guidant-ai-<版>.ops.tar.gz.sha256` | 人工核對 |
| 清單 | `v1/<版>/guidant-ai-<版>.release.json`（測試期放簽好的版） | 人工核對；客戶從 `check` 回應拿 D1 的 `signed_manifest`，不讀這個檔 |

正式路徑由 Worker `releaseObjectKeys` 回 key（未簽清單在 `v1/<版>/release.json`），CI 照回的 key 上傳，不自己拼。

測試期在 188 上傳，憑證用環境變數組臨時遠端、不寫 `rclone.conf`：

```bash
ssh jedi@192.168.50.188 bash -s <<'SH'
set -euo pipefail
set -a; . /home/jedi/.fr167-r2.env; set +a          # 內含 R2 金鑰與 endpoint，600
export RCLONE_CONFIG_R2_TYPE=s3 RCLONE_CONFIG_R2_PROVIDER=Cloudflare \
       RCLONE_CONFIG_R2_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID" RCLONE_CONFIG_R2_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY" \
       RCLONE_CONFIG_R2_ENDPOINT="$R2_ENDPOINT"
B=/opt/guidant-ai-be/.build/bundle; V=<版>; D=r2:guidant-ai-release/v1/$V
for f in guidant-ai-$V.tar.gz guidant-ai-$V.tar.gz.sha256 guidant-ai-$V.ops.tar.gz guidant-ai-$V.ops.tar.gz.sha256; do
  rclone copyto --s3-no-check-bucket "$B/$f" "$D/$f"
done
rclone copyto --s3-no-check-bucket /tmp/guidant-ai-$V.release.signed.json "$D/guidant-ai-$V.release.json"
rm -f /tmp/guidant-ai-$V.release.signed.json
rclone ls "$D/"
SH
```

5.3G 上傳約 5 分鐘。**大包先、清單後**：中途失敗時沒有清單，不會被當成完整的一版。

### 5.2 D1 一列與三段說明

更新站 D1 `guidant-update-center` 的 `product_releases` 一版一列。客戶在更新中心「更新內容」看到的三段來自這一列的 `release_notes`（字串陣列）、`impact`、`action`，**不在簽章範圍內**，發布前都能改。

三段的來源是步驟 1 release note 檔尾「更新中心顯示文字」。測試期寫成 Mac `.fr167-dev/publish/notes-<版>.json`：

```json
{"notes": ["<白話一句>", "<白話一句>", "<白話一句>"],
 "impact": "升級期間將看到維護頁，所有使用者暫時無法操作",
 "action": "建議排在非上班時段；升級前請確認沒有進行中的證據分類工作"}
```

隨包有新版 Agent 時，`notes` 最後一條寫「主系統更新完成後，請在更新中心的 Agent 區推送 <Agent 版>」。

測試期腳本把未簽清單的欄位、簽好的信封、三段說明組成一句 `INSERT INTO product_releases (...)`（`status` 預設 `published`；環境變數 `STATUS=draft` 只寫草稿），用 wrangler 寫進正式 D1，並把 SQL 留一份 `rel<版>-insert.sql`：

```bash
cd ~/Projects/Billows/Audit-Manager/update-center
npx wrangler d1 execute guidant-update-center --remote --file <insert.sql>
```

正式路徑不寫 SQL：三段在更新站後台貼，草稿列由 CI `register-draft` 建，`publish` 把 `status` 改成 `published` 並存信封。

## 步驟 6　驗更新站 {#verify-site nav="6 驗站"}

```bash
cd ~/Projects/Billows/Audit-Manager/update-center
# 1. D1：這一版在、published、kid 是預期那把章、大小與 r2_key 對
npx wrangler d1 execute guidant-update-center --remote --json \
  --command "SELECT version, status, kid, size, r2_key, min_base_version, min_agent_version FROM product_releases ORDER BY created_at"
# 2. R2：五個檔都在、大小對得上 188 的檔（5.1 上傳腳本最後一行 rclone ls 已印出，對照 188 的 ls -l）
ssh jedi@192.168.50.188 'ls -l /opt/guidant-ai-be/.build/bundle/ | grep <版>'
# 3. Worker 活著
curl -s https://<更新站網址>/healthz        # {"ok":true}
```

**跨版路徑（planPath）**：客戶從現行版到最新版每一跳挑「現在能直升的最高版」（`src/releases.ts` `planPath`）。這一版的 `min_base_version` 高於某些客戶現行版時，要確認那些客戶走得到：在 update-center `test/releases.spec.ts` 的 `planPath` 區塊照 D1 的實際列加一個案例（現行版＝三台機器的版號），`npx vitest run test/releases.spec.ts --exclude '.worktrees/**'` 跑過就刪掉，不 commit。`min_base_version` 都是 `1.0.0` 時每台都是一跳直升，可略過。

**端到端**：`/v1/check` 要帶真的身分票與機器指紋，不適合手打 curl。實際驗法是到 190 更新中心按「立即檢查」，看到新版號與「更新內容」三段說明。

## 步驟 7　三台升級與 Agent 推送 {#upgrade nav="7 升級"}

### 7.1 順序與放行

| 順序 | 機器 | 放行 | 為什麼這個順序 |
|---|---|---|---|
| 1 | 190 測試機 | 發版執行者可直接做 | 第一個裝新包的地方；失敗在這裡就停，不往下 |
| 2 | 188 STG | 決策者當次放行 | STG 資料較真實；同時也是 build 機，升級要避開正在跑的 build |
| 3 | 189 POC | 決策者當次放行 | 等同正式環境、對外 demo |

**升級前每台都查**：

```bash
for h in 190 188 189; do
  echo "== $h"; ssh jedi@192.168.50.$h 'curl -sk https://127.0.0.1/api/1.0/version; echo; df -h / | tail -1; ls /var/run/reboot-required 2>/dev/null; uname -r'
done
```

- 有 `/var/run/reboot-required`、或執行中核心比已安裝的舊：先 `sudo reboot`。預檢也會擋，但先處理省一輪。
- 磁碟要放得下：包＋資料庫複本×1.2＋新版映像＋2G 保留。

### 7.2 線上升級（三台一樣）

管理員登入該台 → 系統設定 → 更新中心 → 「立即檢查」 → 「更新到 X」：

1. 確認視窗看預檢結果（空間、版本路徑、簽章與雜湊、資料庫連線）。
2. 選「立即更新」或「排程更新」。
3. 「主系統更新完成後，接著更新 Agent」預設勾；要分開推就取消勾選。
4. 按確認。沒下載過的會先下載（約 5.3G）、驗章、驗雜湊，再寫更新單交主機。

升級中畫面與維護頁顯示八步：換成新版維運指令 → 預檢 → 停止接新工作，等進行中的做完 → 停服務、複製資料庫 → 在複本上升級 → 啟動新版、健康檢查 → 開放使用 → 清理舊版本。實測整次約 8 分鐘（第 0 步有 ops 小檔時約 1 秒）。

**升完驗**：

```bash
ssh jedi@192.168.50.<台> 'curl -sk https://127.0.0.1/api/1.0/version; echo; sudo guidantai status'
ssh jedi@192.168.50.<台> "docker exec guidant-db psql -U cmmgr -d guidant_ai -At -c \"SELECT event_code, message FROM public.system_logs WHERE event_code BETWEEN 6239 AND 6248 ORDER BY id DESC LIMIT 10\""
```

升完的主機每一步會補寫一筆系統紀錄（事件碼 6239～6247 對應第 0～8 步，6248 是整次結果）。更新站 D1 `instance_version_reports` 會出現這台的新版號（主系統每分鐘同步主機結果並回報更新站）。

### 7.3 `install.sh --upgrade` 這條路的限制

手動把包解開、`sudo ./install.sh --upgrade` 也能升，但：

- 不驗原廠發布清單的簽章（映像指紋照驗）。
- **主機的 `<資料目錄>/upgrade/bundles/` 不會有這一版**，更新中心 Agent 區顯示「這一版的 Agent 安裝包不在升級目錄」、推不了 Agent（`PRODUCT_UPDATE_412008`）。
- 要讓 Agent 跟進，改用 `sudo guidantai upgrade <包.tar.gz>`（包、`.sha256`、`.release.json` 三個檔放同一個資料夾），或走線上升級。

只有「這台還沒有更新中心」（第一次從沒有 `guidantai upgrade` 的舊版升上來）才需要走這條路。升級前先停 api（`docker stop guidant-api guidant-worker guidant-socketio`），不然 Agent 心跳會和 migration 互卡。

### 7.4 推送 Agent

主系統升完後，更新中心「Agent」區列出每台版本、在線、落後、必須更新：

- 確認視窗勾了「接著更新 Agent」：主系統開放使用後自動先推 1 台（最近一次回報最新的那台），成功再推其餘；任何一台失敗其餘自動暫停。
- 沒勾：勾選要更新的台 → 「更新選取的 N 台」→ 立即或排程（10 分鐘後～60 天內）。也可按「稍後提醒」，7 天內不再提醒這一版。
- Agent 升級中，主系統會向下發中的那幾台拉進度（`GET /update/progress`），Agent 區展開看得到下載、解開、預檢、換版、健康檢查各段。

**Agent 跟進的前提**：Agent 是 docker compose 形態、版本 1.4.4 以上（更早的要先手動升一次）、主機有 systemd。Agent 升完會回報主系統與更新站（D1 `agent_version_reports`）。

## 步驟 8　失敗處理 {#failures nav="8 失敗"}

### 8.1 出包常見失敗

| 症狀（log 最後幾行） | 原因 | 怎麼做 |
|---|---|---|
| `前提不足：缺 GUIDANT_BUGSINK_ADMIN_PASSWORD` | 第三道前提沒 export | 照 2.4 腳本從檔案讀進環境變數再跑 |
| `前提不足：PATH 加了 … 仍找不到 pip-audit` | BE `.venv` 沒建或沒裝 dev 依賴 | 188 `/opt/guidant-ai-be` 重建 venv |
| `⓪b audit_deps` 紅、列出漏洞 ID | 相依有未放行的漏洞 | 升套件版本；確定不受影響才寫進 `scripts/build/audit_ignore.txt`（每行要有理由） |
| `DB 前置斷言失敗（exit 1／2）` | 連錯庫或斷言壞掉（exit 3 是常態，會自動走臨時庫） | 看 `scripts/build/README.md`「build 機的 smoke 連哪個資料庫」 |
| `FAIL (a)：99-stamp.sql 補登清單與 manifest.tsv 不一致` | 本版有主線新 migration、基線沒重產 | 照 `sql-migration` skill「出貨基線同步」重產 `02-schema.sql`／`99-stamp.sql`，commit、pull 再出 |
| storage-seed 筆數與 map 不符 | 套件 assets 沒攤平或 map 漏宣告 | 看 `scripts/build/README.md`「套件 assets 攤平 → storage-seed」 |
| `Agent compose 交付包…不存在`（正式包） | 沒先出 Agent | 先做 2.3，或 `AGENT_COMPOSE_BUNDLE=<檔>` |
| Agent build `LICENSE_CENTER_API_TOKEN … 皆未設定` | 188 Agent repo `.env` 缺 token | 補 `.env`；`--check-only` 先驗 |
| Agent build `缺主控台前端產物` | 沒做 2.2 或路徑錯 | 重做 2.2，`FE_CONSOLE_DIST` 指到它 |
| `no space left on device`、linker crash | 磁碟滿、或兩個 build 同時跑 | 照「清理」段清空間；`pgrep` 確認只有一個 build |

### 8.2 主機升級失敗看哪裡

| 看什麼 | 在哪 |
|---|---|
| 更新中心失敗提示 | 「已自動退回」「這次不更新」「退回失敗」三種，附第幾步、原因；可按「下載診斷包」 |
| 進度檔 | `<資料目錄>/upgrade/upgrade-progress.json`（STG／POC 為 `/srv/guidant-ai/upgrade/upgrade-progress.json`） |
| 主機撿單與執行 | `sudo journalctl -u guidant-upgrade-order.service --since "1 hour ago"` |
| `install.sh --upgrade` 的全程輸出 | `<資料目錄>/log/upgrade-<日期時間>.log` |
| 診斷包 | `sudo guidantai diag`（含進度檔、更新單、最近三支升級與安裝紀錄，密碼金鑰已遮） |

**退回失敗（結束代碼 4、維護頁紅字）**：

```bash
sudo guidantai recover        # 走與自動退回同一支程式，重跑安全；換接已完成就不會拉回舊版
sudo guidantai status
```

`recover` 也失敗就產診斷包，不要手動動資料夾。換接完成後才發現新版有問題：不退回，發修正版再升。

**常見原因**：核心裝了沒重開（預檢擋「請先 sudo reboot」，`errno 524` 也是它）；磁碟不足（預檢列差多少）；主機 lib 的修正（第 0 步、維護頁這類升級途中才執行的東西）要裝了那版之後的**下一次**升級才生效，回報「這次會看到某修正」前先分清它是升級前就在跑、還是升級中才用到。

### 8.3 Agent 升級失敗看哪裡

| 看什麼 | 在哪 |
|---|---|
| 更新中心 Agent 區 | 該台「失敗」與原因 |
| 進度檔 | Agent 主機 `<安裝目錄>/upgrade/upgrade-progress.json` |
| 主機撿單 | `sudo journalctl -u guidant-agent-upgrade-order.service --since "1 hour ago"` |
| 升級輸出 | `<安裝目錄>/upgrade-<日期時間>.log` |

- **「無法讀取新映像」**：多半是磁碟滿（docker 解壓映像層失敗，錯誤被吞成這句）。先 `df -h`、`sudo journalctl -u docker --since "1 hour ago" | grep -i "no space"`；升級同時要放下載包、解開的包、載入的映像與快照，約需 12G 以上。清掉舊版 Agent 映像與舊包後，在 Agent 區勾那台重推，包已在主機上不必重下。
- Agent 升級會換容器，`docker logs` 只看得到新容器；判讀升級中狀態看主機層檔案（進度檔、journal）。

## 清理 build 機 {#cleanup nav="清理"}

188 一天出三包會吃掉 50G 以上。清之前先看誰還在用：

```bash
ssh jedi@192.168.50.188 'df -h / | tail -1; docker system df'
# 每個容器的 compose 工作目錄與設定檔：有命中的目錄就不是垃圾
ssh jedi@192.168.50.188 'docker inspect $(docker ps -aq) --format "{{.Name}}  {{index .Config.Labels \"com.docker.compose.project.working_dir\"}}  {{index .Config.Labels \"com.docker.compose.project.config_files\"}}"'
```

| 可以清 | 指令或做法 |
|---|---|
| build cache（留 20G 給下次命中） | `docker builder prune -f --keep-storage 20G` |
| 自家舊版 image tag（`guidant-ai-be／fe／init`、`guidant-ai-agent` 舊版號），**沒有任何容器引用** | 先 `docker ps -a --filter ancestor=<image:tag>` 確認空，再 `docker rmi <image:tag>` |
| 已在 R2 的舊版本機包 | `rm /opt/guidant-ai-be/.build/bundle/guidant-ai-<舊版>.{tar.gz,tar.gz.sha256,ops.tar.gz,ops.tar.gz.sha256,release.json}` |
| 解開的 staging 目錄 | `rm -r /opt/guidant-ai-be/.build/bundle/guidant-ai-<版>`（開包驗完就可刪） |
| Agent 舊 compose 包 | `/opt/guidant-ai-agent/.build/compose/guidant-agent-compose-<舊版>.*` |

| 🔴 不可清 | 為什麼 |
|---|---|
| STG 的 compose 工作目錄（`/home/jedi/guidant-ai-<版>/`，上面 `docker inspect` 有命中的那個） | 線上升級不重建 redis／seaweedfs／bugsink，它們的 compose 標籤仍指這個目錄；刪了下次 compose 操作就找不到設定 |
| 第三方映像（node、nginx、postgres、分類器、Harbor 拉的） | 重下載成本高，封閉網路可能拉不到 |
| `docker system prune`、`docker image prune` | 會一起刪第三方映像與現役 image，只准 `builder prune` |
| `/opt/guidant-ai-be/.build/dist/` | 開包驗要 grep 它；`--reuse-images` 的 SBOM 也沿用 |
| `/opt/guidant-ai-be/.build/models/prompt-guard-2/` | AI 閘道注入偵測模型，重轉要 HF 權杖與時間 |
| `/home/jedi/.fr167-r2.env`、`/home/jedi/fr167-test-bugsink-admin.txt` | 測試期上傳與出包要用 |

## 檢查清單 {#checklist nav="清單"}

| # | 項目 | 完成 |
|---|---|---|
| 1 | BE release note（含「更新中心顯示文字」）、`pyproject.toml`、FE `package.json` 同一版號 | ☐ |
| 2 | Agent 有改：`pyproject.toml` 升版、`RELEASE_NOTES.md` 加一段 | ☐ |
| 3 | `min_agent_version`、`manifest.tsv` `min_base`、基線重產是否需要 | ☐ |
| 4 | 三 repo 已 push；工作坊分支已合回 | ☐ |
| 5 | 188 三個 clone pull 到目標 commit、`pgrep` 無其他 build、磁碟夠 | ☐ |
| 6 | Mac `npm run build:CONSOLE`、scp 到 188 | ☐ |
| 7 | Agent `build_all.sh --with-compose` 成功、compose 包三檔在 | ☐ |
| 8 | 主系統 `build_all.sh --all`＋`build_bundle.sh` 成功（log 有 `ALL DONE`） | ☐ |
| 9 | 開包驗 7 項全過（版號、Agent 包、storage-seed、ops、清單欄位、執行檔新字串、Agent env） | ☐ |
| 10 | 簽章：測試章或正式章；信封 payload 含 ops 兩欄（測試章） | ☐ |
| 11 | R2 五檔在、D1 一列 `published`、三段說明對 | ☐ |
| 12 | 190「立即檢查」看到新版與說明；`min_base` 非 1.0.0 時 planPath 跑過 | ☐ |
| 13 | 190 升級成功、版本 API、`system_logs` 6239～6248 | ☐ |
| 14 | 188、189 決策者放行後升級、驗 | ☐ |
| 15 | 各台 Agent 推送成功；失敗的照 8.3 處理 | ☐ |
| 16 | 188 清理（只清可清表內的項目） | ☐ |

## 相關文件 {#refs nav="相關"}

- [發布清單與簽章規格](./release-signing-spec.md)：v2 信封、kid、印章輪換、客戶端六道驗證。
- [更新站規格](./update-site-spec.md)：Worker 端點、planPath、R2 key、後台、部署。
- [主機升級程式規格](./upgrade-runner-spec.md)：`guidantai upgrade` 八步、時限、進度檔、退回與 `recover`。
- [設計文件](./design.md)：線上更新整體設計與決策。
- 腳本檔頭：`scripts/build/README.md`、`build_all.sh --help`、`build_bundle.sh --help`；update-center `cli/README.md`；`version-bump` skill。
- 客戶端操作：落地版手冊〈五、升級與退回〉、檢測 Agent 手冊〈七、升級〉。
