快轉到主要內容
  1. 教學文章/

uv + GitHub Actions 實戰:Lockfile、Cache 與可重現 CI

·5 分鐘· loading · loading · ·
Python Uv GitHub Actions Ci Lockfile Cache Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 101: 本文

featured

一. 前言:CI 綠燈,不代表環境真的可重現
#

本機測試全過,推上 GitHub 卻失敗。 或更麻煩一點:

  • 昨天的 CI 是綠燈,今天沒有改程式卻突然紅燈
  • 同一個 commit 在兩次 workflow 裝到不同依賴
  • pyproject.toml 改了,卻忘記一起更新 uv.lock
  • 每個 job 都重新下載套件,CI 時間越拉越長
  • 為了加速而快取整個 .venv,結果又出現詭異污染 這些問題的共同點,不是測試寫得不夠多。 而是 依賴安裝流程沒有被當成 CI 契約。 今天拍拍君要用 uv 和 GitHub Actions,整理一條簡單但夠嚴謹的 Python CI:
  1. 固定 uv 的行為
  2. 驗證 uv.lock 沒有落後
  3. 從 lockfile 同步環境
  4. 正確保存 uv cache
  5. 執行 lint、型別檢查與測試
  6. 在 Python matrix 裡找相容性問題 如果你還不熟 uv 的 project、uv syncuv.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.tomluv.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 版本失敗
#

依序檢查:

  1. requires-python
  2. 失敗套件的 Python 支援範圍
  3. 平台 wheel 是否存在
  4. 程式是否用了新版本語法或標準庫 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 drift
  • uv sync --locked 重建一致環境
  • setup-uv cache 加速 artifact 重用
  • matrix 驗證公開支援的 Python 版本
  • 最小權限與獨立 secrets 降低風險 先把最小 workflow 跑通,再逐步加入 cache、quality job 與 matrix。 CI 不必炫技。 它只要能對每個 commit 誠實回答:「這份程式,在宣告的環境裡真的能工作嗎?」就很有價值了。🧪

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 101: 本文

相關文章

Python uv build/publish 實戰:從 wheel 到 private package workflow
·11 分鐘· loading · loading
Python Uv Packaging Wheel PyPI Private-Package Developer-Tools
Python uv scripts 實戰:PEP 723、inline dependencies 與單檔工具
·6 分鐘· loading · loading
Python Uv PEP 723 Script Developer-Tools Automation
Python OpenTelemetry 實戰:Trace、Span 與 FastAPI 觀測流程
·6 分鐘· loading · loading
Python OpenTelemetry Observability FastAPI Tracing Developer-Tools
Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台
·9 分鐘· loading · loading
Python Streamlit SQLModel SQLite CRUD Developer-Tools
Python socket 實戰:TCP client/server、timeout 與簡易通訊協定
·8 分鐘· loading · loading
Python Socket TCP Networking Standard-Library Developer-Tools
Python shutil 實戰:檔案複製、搬移、壓縮與安全清理
·7 分鐘· loading · loading
Python Shutil Filesystem Automation Standard-Library Developer-Tools