FR-167 · 操作手冊 · 2026-11-01

發版操作手冊

從定版號到三台主機升完:188 出包、開包驗、蓋章、上傳 R2 與 D1、驗更新站、依序升級、失敗處理與 build 機清理

給原廠內部發版的人照著做。每一步寫明在哪台機器、跑什麼指令、成功長什麼樣、失敗看哪裡。指令都對過 repo 內的腳本與旗標;188 上的暫存腳本與 Mac 上的測試期發布腳本不在 repo,本頁寫出它們的內容與位置。本頁不含任何密碼、金鑰或帳號編號,需要時只寫存放位置。

§1

角色與前提

角色 做什麼 在哪台
決策者 定版號、蓋正式章(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)。

§2

流程總圖

%%{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
圖 1 — 一次發版從版號到三台升完

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

§3

步驟 1 版號與 release note

主系統(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 得到才算數。

§4

步驟 2 188 出包

2.1 開跑前三件事

# ① 三個 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:

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 一定先出。

# 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,不入版控),內容照這個寫:

#!/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 落檔:
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 會提前誤判:

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 只換版號的快速出包

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

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 目錄,開包驗用;驗完可刪
§5

步驟 3 開包驗

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

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-<原因> 保留;前一顆可用只是少東西就升版號。
§6

步驟 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 跑:

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 要改。

§7

步驟 5 上傳 R2 與寫 D1

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:

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:

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

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

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

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 並存信封。

§8

步驟 6 驗更新站

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 更新中心按「立即檢查」,看到新版號與「更新內容」三段說明。

§9

步驟 7 三台升級與 Agent 推送

7.1 順序與放行

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

升級前每台都查:

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 秒)。

升完驗:

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)。

§10

步驟 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、維護頁紅字):

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)。
§11

清理 build 機

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

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 測試期上傳與出包要用
§12

檢查清單

# 項目 完成
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 清理(只清可清表內的項目) ☐
§13

相關文件

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