---
title: 設計
subtitle: 流程、觸發、產物落點、為什麼這樣選
---

# 設計

本頁描述 GitLab CI 發版流程現在的規則與理由。實際行為以 `.gitlab-ci.yml` 與 `scripts/build/*.sh` 的檔頭註解為準；本頁與程式碼不一致時，相信程式碼。

## 流程

一次 pipeline（GitLab 上一次完整的自動化執行）分四段，前一段全部成功才會進下一段：

```
verify ──► build（四個 job 平行）──► package ──► deploy
 ~1 分        ≈ 17 分（由 BE 決定）      ~9 分        ~3.5 分
```

| 段 | 做什麼 | 時間 |
|---|---|---|
| **verify** | 確認 188 上沒人在手跑 build、工作樹乾淨 → 把 BE 與 FE 工作樹拉到這次 pipeline 的 commit → 從 lock 檔產應用層 SBOM（`sbom.sh --source`）→ 用 pip-audit 掃 Python 相依漏洞（`audit_deps.sh`），有未列白名單的漏洞就停 | 約 1 分 |
| **build-be** | Nuitka 編譯 BE → 產 Prompt Guard 模型 → smoke 驗證 → 打 BE image | 約 17 分 |
| **build-fe** | 打 FE image | 約 3 分 |
| **build-init** | 打 init image（建 schema、套 migration 用） | 1 分內 |
| **build-classifier** | 打證據分類服務 image | 1 分內 |
| **package** | 掃四顆 image 產 OS 層 SBOM（`sbom.sh --images`）→ 封安裝包（`build_bundle.sh`）→ 推 Harbor（`push_harbor.sh`） | 約 9 分 |
| **deploy** | 把安裝包 scp 到 190 → 核對 sha256 → `install.sh --upgrade --force` → 確認所有服務 healthy、health 端點回應正確 | 約 3.5 分 |

build 段的四個 job 同時跑（188 的 runner 設定 `concurrent = 3`，加上 BE 那條最長，實際總時間約等於 BE 那條）。

**`skip_be` 勾起來時**，build-be 不編譯，直接沿用 188 上同版號的 BE image（`guidant-ai-be:<版號>`）；188 上沒有這顆就失敗。適合只改 FE、init 或分類服務時，省掉 Nuitka 那 17 分鐘。

package 封的是**測試包**（帶 `--skip-prod-key-check`）；正式出貨要另跑一次不帶這個旗標。

## 觸發

**只有人主動發才會跑**：

- GitLab 網頁 Build → Pipelines → Run pipeline
- 命令列 `glab ci run`（GitLab 的官方 CLI，一樣是人主動發）

push、MR、打 tag **一律不觸發**（`workflow.rules` 只放行 `web` 與 `api` 兩種來源）。理由：發版會動到環境，該是人的決定，不該因為推了一個 commit 就自動發生。

**按下去就是部署指令**，中間沒有第二道確認。保護機制是「任何一段失敗 pipeline 就停在那段」——漏洞、smoke 紅、封包失敗都到不了部署。

執行時可選兩個參數（inputs）：

| 參數 | 意義 | 預設 |
|---|---|---|
| `environment` | 部署目標，目前只有 `e2e` | `e2e` |
| `skip_be` | 跳過 BE 的 Nuitka 編譯，沿用 188 上同版號的 BE image | 不勾 |

## tag 與版號

- **定版 tag（`v<X.Y.Z>`）由決策者手打，CI 完全不碰 tag。** CI 只負責「把指定 commit 做成一包、升到 e2e」，哪一包算定版是人的判斷。
- **image tag 是 `<pyproject 版號>-<commit 短 hash>`**，例如 `1.23.1-a1b2c3d`。定版前同一個版號會反覆出測試包，帶上 hash 就不會互相覆蓋，也能一眼看出哪個 commit 最後被打了 tag。
- 188 本機上的 image 只標版號（`guidant-ai-be:1.23.1`），推上 Harbor 時才加 hash 後綴。
- **證據分類服務的 image 例外**：tag 跟它自己的套件版號走，不跟產品版號、也不帶 hash——同一個套件版號內容就是同一份。

## SBOM 兩份

SBOM（Software Bill of Materials，軟體物料清單）是出貨附給客戶的「成分表」，列出產品裡用了哪些套件與版本，格式是 CycloneDX JSON。分兩份，因為能產出的時間點不同：

| 層 | 來源 | 在哪段產 | 會不會擋 |
|---|---|---|---|
| **應用層** | BE `poetry.lock`、FE `package-lock.json` | verify | 會擋：產不出來就停；漏洞由同段的 `audit_deps.sh` 擋 |
| **OS 層** | 四顆 image 裡用 apt 裝的東西（LibreOffice、OpenSSL、字型等） | package | 不擋：osv-scanner 出一份漏洞報告僅供參考 |

**為什麼應用層不能改成掃 image**：BE 是 Nuitka 編成 standalone（編譯後的獨立執行檔），Python 套件的 `dist-info` 中繼資料在編譯時全被拿掉，掃描工具 syft 掃 image 看不到任何 Python 套件。lock 檔是應用層唯一的真相來源。

**目前的缺口**：FE 的 `package-lock.json` 沒有進版控，188 上的 FE clone 裡沒有這支檔，`sbom.sh` 會印警告並略過 FE 應用層 SBOM，實際只產出 BE 那份。補 FE lock 檔另案處理。

**為什麼 OS 層只報告不擋**：基底 image 的漏洞幾乎天天有新的，拿它當閘門就永遠出不了貨。

兩份 SBOM 都會收進安裝包的 `sbom/` 目錄，也會上傳成 GitLab artifacts（CI 執行完留下、可在網頁下載的檔案）。

## 部署路徑

**所有環境都走安裝包升級**，和客戶升級是同一條路：

1. 把 188 上的安裝包（`guidant-ai-<版號>.tar.gz` 與 `.sha256`）scp 到 190 的 `/home/jedi/`
2. 核對 sha256，不符就停（傳輸壞掉或被動過）
3. 解開後執行 `sudo ./install.sh --upgrade --force`，install.sh 會依序：完整備份資料庫 → 載入新 image 並比對 digest → 套增量 migration → 換版重啟並等 healthcheck 全綠
4. 再用 docker 逐一確認服務都是 healthy、打 health 端點確認回應正確
5. 刪掉解壓目錄，只留 tar.gz 與 .sha256

e2e 這樣升有額外好處：**順便驗證安裝包本身升不升得上去**。如果 e2e 改成從 Harbor pull image，就驗不到客戶實際拿到的那一包。

**Harbor 照推**，但它是出版的一環（留一份可追溯的 image），不是部署來源。

**為什麼固定帶 `--force`**：install.sh 預設把「新舊版號相同」當成誤操作擋下。但定版前同一版號會反覆出測試包（commit 不同、版號相同），不帶 `--force` 第二次就升不上去。`--force` 不會略過任何步驟——備份、digest 比對、migration 照做——只是不因版號相同而中止。

**為什麼升完要刪解壓目錄**：維運指令 `guidantai` 與 compose 檔，install.sh 已經複製到 `/usr/local/bin` 與資料目錄。解壓目錄留著，`docker compose ls` 會指向一個日後被刪掉的路徑。

## 為什麼在 188 的 `/opt/guidant-ai-be` 直接跑

CI 設定 `GIT_STRATEGY: none`（不讓 runner 自己 clone），verify 自己 `git fetch` 後 `reset --hard` 到 pipeline 的 commit。

理由：建置需要的東西都在 `/opt/guidant-ai-be`——`.venv`（Python 虛擬環境）、Prompt Guard 模型、smoke 用的 `.env`，FE 也有 clone 在 `/opt/guidant-ai-fe`。另外 clone 要全部重建；而且部署機更新本來就是走 git（禁止 scp／rsync 改程式）。

代價是 **CI 和人手跑共用同一棵工作樹**，對策：

- **有人在手跑就拒跑**：verify 開頭用 `pgrep` 找 nuitka／build_all.sh／build_release.sh／build_image.sh，找到就停。兩條 build 同時跑會互撞，linker 直接 crash。
- **工作樹不乾淨就拒跑**：`git status` 有任何未提交的改動就停並列出來，不會悄悄 reset 掉別人的東西。
- build／package 開頭再確認一次工作樹確實停在 pipeline 的 commit，不是的話代表 verify 沒跑到，直接停。

## 產物落點

| 產物 | 在哪 | 理由 |
|---|---|---|
| SBOM（`*.cdx.json`、`osv-*.txt`） | GitLab artifacts，保留 30 天 | 小檔，方便網頁下載 |
| build-be 的 log | GitLab artifacts（`out/logs/`），保留 14 天 | 失敗時要能在網頁看 |
| 安裝包（約 2.5GB） | 188 的 `/opt/guidant-ai-be/.build/bundle/` | gitlab.com 的 artifacts 上限 1GB，上傳會被拒（HTTP 413）讓整個 job 紅；package 段 log 最後一行印出路徑 |
| image | 188 本機 docker ＋ Harbor `harbor.jedicogy.com.tw:8081/guidant-ai/` | 本機給下次 `skip_be` 沿用，Harbor 留出版紀錄 |

artifacts 路徑只能寫相對於 runner 自己工作目錄（`CI_PROJECT_DIR`）的路徑，不能直接指到 `/opt`，所以每個 job 收尾時把 `.build/sbom` 與 `.build/logs` 用 rsync 搬到 `$CI_PROJECT_DIR/out/` 再上傳；失敗時也會搬，log 才看得到。

## 擴環境怎麼加

要加 stg 或 poc 時：

1. `.gitlab-ci.yml` 的 `spec.inputs.environment.options` 加上環境名
2. 照 `deploy-e2e` 複製一個 deploy job，`rules` 改成比對新環境名、`environment.name／url` 改掉
3. 寫對應的部署腳本（或讓 `deploy_e2e.sh` 吃目標 host，它已經讀 `E2E_HOST`、`E2E_DROP` 環境變數）

**限制誰能按**：用 GitLab 的 Protected environments（受保護環境），設定哪些人可以部署到該環境。stg／poc 等同上版動作，加之前要決策者放行。

## 踩過的坑（換機器會再撞的規則）

- **runner 使用者的 `~/.bash_logout` 要移除**：shell executor（runner 直接用本機 shell 跑指令的模式）會以登入 shell 執行 job。Ubuntu 預設的 `.bash_logout` 會呼叫 `clear_console`，在沒有終端機的 CI 環境裡回傳失敗，job 在「準備環境」階段就紅（GitLab 官方文件記載的已知問題）。188 的 runner 以 `jedi` 跑，所以要移除的是 `/home/jedi/.bash_logout`。
- **收集 artifacts 前先清 `out/`**：shell executor 的工作目錄跨 job 重用，前一個 job 留下的檔案會被下一個 job 一起打包。`.collect` 片段第一步就是 `rm -rf out/`，拿掉它就可能把 2.5GB 的安裝包誤包進 artifacts，觸發 413。
- **證據分類 image 的來源 clone 要在 verify 先 `git pull`**：`evidence-classifier` 不是從 Nexus 裝，是拿 188 上 `/opt/jedi-python-package` 這個 git clone 直接 build。不 pull 的話，套件改了內容沒 bump 版號時 CI 會 build 出舊的，而且沒有任何錯誤訊息。verify 固定跟 `main`。那個 clone 的 remote 目前靠嵌在 URL 裡的 token 讀取（188 的 ssh key 在 `commonservice3` group 沒權限），token 到期前要改成把 key 加進該 group 的 deploy keys。
- **`.venv/bin` 要進 CI 的 PATH**：人手跑時會先 `source .venv/bin/activate`，CI 的 shell 不會。pip-audit 等裝在 venv 裡的工具，要靠 `.gitlab-ci.yml` 頂層的 `PATH` 變數把 `/opt/guidant-ai-be/.venv/bin` 放最前面才找得到。
- **`pgrep` 要用 `-x` 整串比對**：單用 `pgrep -f` 是在整條指令列裡找片段，連 ssh 帶過來的指令字串、CI 自己的 job 指令都會被抓到，變成誤判「有人在手跑」而拒跑。現在用 `pgrep -x -f` 搭配完整程序名的正規式。
- **Nuitka 當機會留下 `nuitka-crash-report.xml`**：這支檔案會讓工作樹變髒、被 verify 擋下。已加進 `.gitignore`；換機器或換 repo 時要確認這條還在。
