iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Software Development

30 天 Python 專案工坊:環境、結構、測試到部署全打通系列 第 25

Day 25 -CI/CD 範本:GitHub Actions(lint → test → build → publish)

  • 分享至 

  • xImage
  •  
  1. 讓每次 PR 與 main push 都跑出同樣的結果(依賴版本一致、同樣的檢查清單)。這靠 uv 的鎖檔與 cache 搭起來。
  2. 把一鍵化工作流搬到 CI 跑,縮小「本地 OK、CI 爆炸」的落差。
  3. 落實的風格統一與提交前後一致的規則。
  4. 用的測試藍圖與覆蓋率門檻,CI 直接 fail 掉不合格的變更。
  5. 依的發佈策略,對預發佈與正式版分流,安全地把 wheel 推上(Test)PyPI。

目錄與檔案

.github/
└─ workflows/
   ├─ ci.yml         # 每個 push / PR 都會跑
   └─ release.yml    # 打 tag 時建置並發佈

ci.yml(lint → typecheck → test → build)

  • 使用 astral-sh/setup-uv@v3 裝好 uv,uv sync --locked 依鎖檔還原依賴,並啟用快取加速。
  • 以 matrix 設多個 Python 版本,對應 Day 8 你用 Nox 做過的多版本驗證思路。
  • 覆蓋率輸出 coverage.xml,並設 -cov-fail-under 門檻。
name: CI

on:
  push:
    branches: [ main ]
  pull_request:

jobs:
  build-test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.11", "3.12"]
    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Set up uv with cache
        uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true  # 啟用 uv 快取,加速 CI 安裝

      - name: Sync dependencies from lock
        run: uv sync --locked

      # Lint(Ruff + Black --check)
      - name: Lint
        run: |
          uv run ruff check .
          uv run black --check .

      # Type check(Pyright 或 mypy,依你的專案)
      - name: Type check
        run: |
          if uv run -q pyright --version; then uv run pyright .; fi
          uv run mypy src/

      # Test with coverage(門檻請對齊 Day 11)
      - name: Test
        run: uv run pytest -q --cov=your_package --cov-report=xml:coverage.xml --cov-fail-under=80

      - name: Upload coverage xml
        uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ matrix.python-version }}
          path: coverage.xml

      # Build wheel/sdist 只是驗證能打包成功,產物上傳 artifact 供下載
      - name: Build
        run: uv run hatch build

      - name: Upload dist
        uses: actions/upload-artifact@v4
        with:
          name: dist-${{ matrix.python-version }}
          path: dist/

為什麼用 uv 而不是 pip install -r?因為我們在已經把「完整依賴樹」鎖進 uv.lock,CI 用 uv sync --locked 可確保版本 100% 一致,還能吃到快取。


release.yml(build → publish)

  • 只在打 tag 時觸發。以 tag 名稱判斷是否預發佈(a|b|rc)推 TestPyPI,否則推正式 PyPI。
  • 發佈使用 hatch publish 與平台 Secrets:TEST_PYPI_TOKENPYPI_TOKEN
name: Release

on:
  push:
    tags:
      - "v*.*.*"     # e.g. v0.2.0, v1.0.0, v1.2.0rc1

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true

      - name: Sync deps from lock (no dev)
        run: uv sync --locked --no-dev

      - name: Build package
        run: uv run hatch build -t wheel -t sdist

      - name: Upload dist as artifact
        uses: actions/upload-artifact@v4
        with:
          name: release-dist
          path: dist/

      # Decide target repo based on tag: pre-release -> TestPyPI, else PyPI
      - name: Publish to TestPyPI (pre-release)
        if: contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc')
        env:
          TEST_PYPI_TOKEN: ${{ secrets.TEST_PYPI_TOKEN }}
        run: |
          uv run hatch publish --repo testpypi --user __token__ --auth "$TEST_PYPI_TOKEN"

      - name: Publish to PyPI (stable)
        if: !(contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc'))
        env:
          PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
        run: |
          uv run hatch publish --user __token__ --auth "$PYPI_TOKEN"

穩定版走 PyPI,預發佈走 TestPyPI;一律用 token,絕不把憑證寫進 repo。


與專案既有工法對齊的細節

  • 一鍵化腳本:若你已把 ruff/black/mypy/pytest 兜在 Hatch scripts(hatch run ci),可在 CI 直接呼叫同一入口,減少兩邊漂移。
  • 型別與資料契約:CI 時同時跑 pyright/mypy,並保留 Pydantic v2 的契約測試區,讓「邊界資料」在 PR 階段就爆錯。
  • 覆蓋率門檻:以 -cov-fail-under 鎖住最低標;必要時再加上 -junitxml 輸出供平台收集。
  • Secrets 與設定:CI 注入必要環境變數,不把 .env committed,對應 Day 12 的祕密管理原則。

常見踩雷與修正

症狀 可能原因 快速修正
CI 安裝依賴忽快忽慢 沒吃到快取 setup-uv@v3 打開 enable-cache: true,並使用 uv sync --locked
本地過、CI 不過 本地與 CI 執行入口不同 把 Lint/Typecheck/Test 收斂到 Hatch scripts,CI 直接呼叫同一組腳本。
釋出上 PyPI 失敗 token 或 repo 判斷錯誤 檢查 tag 規則與條件區塊,預發佈分流到 TestPyPI;token 放 Secrets。
覆蓋率門檻不一致 本地/CI pytest 參數不同 固定在 pyproject.toml 的 pytest options,減少環境差異。

延伸:容器化與 Dev Container

  • 上一篇已把 多階段 Dockerfile非 root 的最佳實務講透,CI 要建映像只要把 uv.lock 帶進去,uv sync --frozen 即可重現。
  • Dev Container 配合 postCreateCommand 跑 Hatch 一鍵化腳本,讓本地、容器與 CI 的入口一致。

結語

這組範本的關鍵不是 Actions 的 YAML 花樣,而是把前面 24 天打好的地基搬進 CI/CD:

用鎖檔確保重現性、用一鍵化縮減心智負擔、用覆蓋率與型別檢查把品質前移、用 TestPyPI/ PyPI 雙軌避免一次到地獄。接下來,你只需要專心寫功能,剩下的交給 pipeline。


上一篇
Day 24 - 開發體驗:Dev Container / VS Code 與 Hatch scripts
系列文
30 天 Python 專案工坊:環境、結構、測試到部署全打通25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言