一. 前言:CI 綠燈,不代表環境真的可重現 #
本機測試全過,推上 GitHub 卻失敗。 或更麻煩一點:
- 昨天的 CI 是綠燈,今天沒有改程式卻突然紅燈
- 同一個 commit 在兩次 workflow 裝到不同依賴
pyproject.toml改了,卻忘記一起更新uv.lock- 每個 job 都重新下載套件,CI 時間越拉越長
- 為了加速而快取整個
.venv,結果又出現詭異污染 這些問題的共同點,不是測試寫得不夠多。 而是 依賴安裝流程沒有被當成 CI 契約。 今天拍拍君要用 uv 和 GitHub Actions,整理一條簡單但夠嚴謹的 Python CI:
- 固定 uv 的行為
- 驗證
uv.lock沒有落後 - 從 lockfile 同步環境
- 正確保存 uv cache
- 執行 lint、型別檢查與測試
- 在 Python matrix 裡找相容性問題
如果你還不熟 uv 的 project、
uv sync與uv.lock,可以先看 uv workspace 與 lockfile 實戰。 如果手上的舊專案仍以requirements.in/requirements.txt為核心,則先看 uv pip-compile migration。 本篇只處理一件事:如何讓 GitHub Actions 忠實重現 repo 宣告的 Python 環境。
二. 準備一個最小 Python 專案 #
先建立範例:
uv init pypy-ci-demo --package
cd pypy-ci-demo
uv add httpx
uv add --dev pytest ruff mypy
專案結構大致如下:
pypy-ci-demo/
├── .github/
│ └── workflows/
│ └── ci.yml
├── src/
│ └── pypy_ci_demo/
│ └── __init__.py
├── tests/
│ └── test_health.py
├── pyproject.toml
├── uv.lock
└── README.md
在 src/pypy_ci_demo/__init__.py 放一個小函式:
def health_message(name: str) -> str:
clean_name = name.strip()
if not clean_name:
raise ValueError("name cannot be empty")
return f"{clean_name}: ready"
再寫測試:
import pytest
from pypy_ci_demo import health_message
def test_health_message() -> None:
assert health_message("拍拍君") == "拍拍君: ready"
def test_empty_name_is_rejected() -> None:
with pytest.raises(ValueError, match="cannot be empty"):
health_message(" ")
本機先跑一次:
uv run pytest
uv run ruff check .
uv run mypy src
最重要的準備則是:
git add pyproject.toml uv.lock
git commit -m "Add project dependencies"
pyproject.toml 和 uv.lock 必須一起進版控。
前者描述「允許哪些依賴」,後者記錄「這次實際解析出哪些版本」。
三. 第一條可用的 GitHub Actions Workflow #
建立 .github/workflows/ci.yml:
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v9
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install Python
run: uv python install 3.13
- name: Sync dependencies
run: uv sync --locked --all-groups
- name: Run tests
run: uv run --locked pytest
這份 workflow 的資料流很單純:
checkout
↓
install uv
↓
install Python
↓
sync exactly from uv.lock
↓
run tests
astral-sh/setup-uv 是 uv 官方維護的 Action。
它會安裝 uv、加入 PATH,並可代管 GitHub Actions cache。
正式或高風險 repo 還可以把 Action 從 major tag 改成完整 commit SHA,降低上游 tag 變動造成的供應鏈風險。
四. --locked 才是 CI 的關鍵開關
#
單純執行:
uv sync
uv 會在需要時重新解析並更新 lockfile。 這對本機開發很方便,對 CI 卻不一定是好事。 CI 的工作應該是驗證目前 commit,而不是偷偷替它修檔案。 因此要改成:
uv sync --locked
--locked 會要求:
uv.lock必須存在- lockfile 必須與專案 metadata 相容
- 如果需要重鎖,指令直接失敗
- CI 不會靜默產生一份沒被 commit 的新 lockfile 也可以獨立做快速檢查:
uv lock --check
這很適合放在安裝前:
- name: Verify lockfile
run: uv lock --check
- name: Sync dependencies
run: uv sync --locked --all-groups
如果有人只改 pyproject.toml,PR 會立刻告訴他需要在本機執行:
uv lock
git add uv.lock
--locked 與 --frozen 不一樣
#
這兩個選項很容易混在一起:
| 選項 | lockfile 不存在 | metadata 與 lockfile 不一致 | 適合場景 |
|---|---|---|---|
--locked |
失敗 | 失敗 | PR、一般 CI |
--frozen |
失敗 | 不檢查,直接使用 lockfile | 特殊建置階段 |
一般 CI 優先使用 --locked。 |
|||
--frozen 的語意是「不要重鎖,也不要確認專案 metadata 是否已同步」。 |
|||
| 例如 Docker 分層建置尚未複製全部 workspace members 時,才可能暫時需要它。 |
五. Cache 要存 uv 的下載成果,不是整個 .venv
#
CI 每次都從空機器開始。 如果專案有 NumPy、PyTorch 或需要編譯的 extension,依賴安裝可能比測試還久。 setup-uv 內建 cache 支援:
- name: Install uv
uses: astral-sh/setup-uv@v9
with:
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
它保存的是 uv cache 裡的下載或建構成果。
下一次執行仍會由 uv sync --locked 建立正確環境,只是可以重用 artifact。
這比直接快取 .venv 穩定,因為 .venv 可能綁定:
- runner 作業系統
- Python patch version
- ABI 與平台 wheel
- workspace 的 editable install 路徑
- 上一次 job 留下的額外套件 拍拍君的原則是:
cache 可以加速重建,但不該取代重建。
Cache key 應該跟誰一起變? #
project 模式最重要的是 uv.lock。
如果是 monorepo,也可以明確列出所有輸入:
cache-dependency-glob: |
uv.lock
pyproject.toml
packages/*/pyproject.toml
而使用 uv pip + requirements 的舊專案,則應改用:
cache-dependency-glob: |
requirements*.txt
constraints*.txt
不要只用 branch 名稱當 cache key。 依賴檔變了,cache 身分也應跟著變。
六. 用 Python Matrix 驗證相容性 #
如果 pyproject.toml 宣告:
[project]
requires-python = ">=3.11"
至少要測試最低支援版本與目前主力版本。
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v9
with:
enable-cache: true
cache-suffix: py${{ matrix.python-version }}
- name: Install Python
run: uv python install ${{ matrix.python-version }}
- name: Sync dependencies
env:
UV_PYTHON: ${{ matrix.python-version }}
run: uv sync --locked --all-groups
- name: Run tests
env:
UV_PYTHON: ${{ matrix.python-version }}
run: uv run --locked pytest -q
fail-fast: false 讓某一版失敗時,其他版本仍然跑完。
這樣才能知道是「全部壞掉」,還是「只有最低版本不相容」。
Lockfile 不是每個 Python 版本各一份 #
uv lockfile 可以記錄環境 marker 與多平台解析結果。 因此通常不需要為 3.11、3.12、3.13 各 commit 一份 lockfile。 真正要注意的是:
requires-python是否涵蓋 matrix- 依賴是否真的支援各 Python 版本
- optional dependency group 是否都有被測到
- 原生 extension 是否提供 runner 對應的 wheel
七. 常見失敗與診斷順序 #
1. uv.lock needs to be updated
#
原因通常是 pyproject.toml 變了,lockfile 沒更新。
本機修正:
uv lock
git diff -- pyproject.toml uv.lock
git add uv.lock
不要在 CI 裡拿掉 --locked 來掩蓋問題。
2. Cache hit,但安裝仍然很慢 #
Cache hit 不代表 .venv 已經存在。
它只代表部分下載或 build artifact 可以重用。
先檢查:
uv cache dir
uv sync --locked -v
如果依賴多為體積小的預編譯 wheel,網路下載本來就可能比 cache restore 更快。
3. Matrix 只有某個 Python 版本失敗 #
依序檢查:
requires-python- 失敗套件的 Python 支援範圍
- 平台 wheel 是否存在
- 程式是否用了新版本語法或標準庫 API 不要立刻把那個版本從 matrix 刪掉。 先決定它是不是仍在你的公開支援範圍。
4. 本機成功,CI import 不到 package #
確認專案真的被安裝:
uv sync --locked
uv run python -c "import pypy_ci_demo; print(pypy_ci_demo.__file__)"
也要檢查 src layout、build backend 與 package name。
不要靠手動設定 PYTHONPATH=src 永久繞過 packaging 問題。
5. Workflow YAML 看起來正確,Action 卻沒有權限 #
先看 job 的 permissions,再看事件來源。
pull_request、fork PR、push、tag 與 protected environment 的權限不完全相同。
單純測試通常只需要:
permissions:
contents: read
發版則應放在另一個 workflow,另外設計權限與審核。
結語 #
uv 放進 GitHub Actions 的價值,不只是安裝比較快。 真正重要的是把依賴流程變成可以被驗證的契約:
pyproject.toml宣告需求uv.lock固定解析結果uv lock --check阻止 lockfile driftuv sync --locked重建一致環境- setup-uv cache 加速 artifact 重用
- matrix 驗證公開支援的 Python 版本
- 最小權限與獨立 secrets 降低風險 先把最小 workflow 跑通,再逐步加入 cache、quality job 與 matrix。 CI 不必炫技。 它只要能對每個 commit 誠實回答:「這份程式,在宣告的環境裡真的能工作嗎?」就很有價值了。🧪