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

GitHub Actions 進階:Matrix Build + 自動發佈 PyPI

·5 分鐘· loading · loading · ·
Github-Actions Cicd Pypi Matrix-Build Python
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
版本控制: Git - 本文屬於一個選集。
§ 5: 本文

一、前言
#

嗨,這裡是拍拍君!⚙️

之前我們在 GitHub Actions 基礎篇 學過怎麼設定簡單的 CI workflow,像是跑 pytest、跑 linter。但實務上,你的 Python 套件可能要支援 Python 3.9 ~ 3.13,可能要在 Ubuntu 和 macOS 上都能跑,可能還想在每次打 tag 的時候自動發佈到 PyPI。

這些需求如果一個一個手動寫 job,workflow 檔案會爆炸性膨脹 💥。好在 GitHub Actions 有個超強功能叫 matrix strategy,可以用幾行設定就展開成多個平行任務。再搭配 PyPI 的 trusted publisher 機制,連 API token 都不用管,直接從 GitHub 推到 PyPI。

今天就來把這兩招學起來!

二、前置準備
#

你需要:

  • 一個 GitHub 帳號(廢話 😂)
  • 一個 Python 專案,有 pyproject.toml(我們會用範例)
  • 對 GitHub Actions 有基本認識(還沒看過的話,先去看 基礎篇

本篇的完整範例會放在 .github/workflows/ 目錄下,直接複製貼上就能用。

三、Matrix Strategy 基礎
#

什麼是 Matrix?
#

Matrix strategy 讓你定義多個變數的組合,GitHub Actions 會自動展開成多個平行 job。最常見的用法就是「多 Python 版本 × 多作業系統」。

基本語法
#

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12", "3.13"]
        os: [ubuntu-latest, macos-latest]

    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -e ".[dev]"

      - name: Run tests
        run: pytest -v

這段設定會自動展開成 4 × 2 = 8 個 job,每個都是獨立的虛擬機。在 Actions 頁面上你會看到一格一格的綠勾勾(或紅叉叉 😅):

ubuntu-latest macos-latest
Python 3.10
Python 3.11
Python 3.12
Python 3.13

⚠️ 字串陷阱
#

特別注意:3.10 在 YAML 中會被解讀成浮點數 3.1!所以 一定要加引號

# ❌ 錯誤 — 3.10 變成 3.1
python-version: [3.10, 3.11, 3.12]

# ✅ 正確 — 用字串
python-version: ["3.10", "3.11", "3.12"]

這是 GitHub Actions 新手最常踩的坑之一,拍拍君以前也中過 😤。

四、Matrix 進階技巧
#

4.1 include — 加入特殊組合
#

有時候你只想在特定組合上做額外的事,比如只在最新版 Python + Ubuntu 上跑 coverage:

strategy:
  matrix:
    python-version: ["3.10", "3.11", "3.12", "3.13"]
    os: [ubuntu-latest, macos-latest]
    include:
      - python-version: "3.13"
        os: ubuntu-latest
        coverage: true

然後在 step 裡用條件判斷:

- name: Run tests with coverage
  if: matrix.coverage
  run: pytest --cov=my_package --cov-report=xml

- name: Run tests without coverage
  if: "!matrix.coverage"
  run: pytest -v

4.2 exclude — 排除特定組合
#

假設你知道 Python 3.10 + macOS 有已知問題,可以跳過:

strategy:
  matrix:
    python-version: ["3.10", "3.11", "3.12", "3.13"]
    os: [ubuntu-latest, macos-latest]
    exclude:
      - python-version: "3.10"
        os: macos-latest

這樣就從 8 個 job 變成 7 個。

4.3 fail-fast 控制
#

預設行為是:一個 job 失敗,其他的也會被取消。如果你想讓所有組合都跑完(方便一次看到全部失敗),可以關掉 fail-fast:

strategy:
  fail-fast: false
  matrix:
    python-version: ["3.10", "3.11", "3.12", "3.13"]

4.4 max-parallel — 控制並行數
#

免費帳號的 concurrent job 上限是 20,如果你的 matrix 很大(比如 5 × 3 × 2 = 30),可以限制並行數:

strategy:
  max-parallel: 4
  matrix:
    python-version: ["3.10", "3.11", "3.12", "3.13"]
    os: [ubuntu-latest, macos-latest, windows-latest]

五、自動發佈到 PyPI
#

CI 搞定之後,下一步就是 CD(Continuous Delivery)。每次打 tag 就自動發佈到 PyPI,告別手動 python -m build && twine upload

5.1 設定 Trusted Publisher
#

PyPI 現在支援 Trusted Publisher,用 OIDC(OpenID Connect)讓 GitHub Actions 直接認證,不需要 API token。這比存 secret 安全多了!

在 PyPI 端設定:

  1. 登入 pypi.org,進入你的 package → Settings → Publishing
  2. 選 “Add a new publisher”
  3. 填入:
    • Owner: 你的 GitHub 帳號
    • Repository: 你的 repo 名稱
    • Workflow name: publish.yml
    • Environment: pypi(建議設定)

5.2 準備 pyproject.toml
#

確保你的 pyproject.toml 用了現代的 build system:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "pypykit"
version = "0.1.0"
description = "拍拍君的超好用工具包"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
    { name = "PypyJun", email = "pypy@example.com" },
]
dependencies = [
    "httpx>=0.27",
    "rich>=13",
]

[project.optional-dependencies]
dev = [
    "pytest>=8",
    "pytest-cov>=5",
    "ruff>=0.4",
]

5.3 發佈 Workflow
#

# .github/workflows/publish.yml
name: Publish to PyPI

on:
  push:
    tags:
      - "v*"  # 只在 v 開頭的 tag 觸發

jobs:
  # 先跑測試
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install -e ".[dev]"
      - run: pytest -v

  # 測試通過才 build + publish
  publish:
    needs: test  # 等 test job 全部通過
    runs-on: ubuntu-latest
    environment: pypi  # 對應 PyPI trusted publisher 設定
    permissions:
      id-token: write  # OIDC 需要這個權限

    steps:
      - uses: actions/checkout@v4

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

      - name: Install build tools
        run: pip install build

      - name: Build package
        run: python -m build

      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        # 不需要設定 password!Trusted publisher 自動處理

5.4 發佈流程
#

有了這個 workflow,發佈新版本就是三行指令:

# 1. 更新版本號(在 pyproject.toml 裡)
# 2. commit
git add .
git commit -m "bump: v0.2.0"

# 3. 打 tag + push
git tag v0.2.0
git push origin main --tags

GitHub Actions 會自動:跑所有 Python 版本的測試 → 全部通過 → build → 發佈到 PyPI ✨

六、完整範例:CI + CD 合體
#

把 CI 和 CD 合併成一個 workflow 也可以,不過拍拍君建議分開維護比較清楚。這裡給一個實戰常見的 CI workflow 結構:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:  # 手動觸發按鈕

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true  # PR 更新時取消舊的 run

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: pip install ruff
      - run: ruff check .
      - run: ruff format --check .

  test:
    needs: lint  # lint 通過才跑測試(省 CI 分鐘數)
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.11", "3.12", "3.13"]
        os: [ubuntu-latest]
        include:
          - python-version: "3.13"
            os: macos-latest
          - python-version: "3.13"
            os: windows-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: "pip"  # 快取 pip 套件,加速安裝
      - run: pip install -e ".[dev]"
      - run: pytest -v --tb=short

這個結構的巧妙之處:

  1. concurrency — 同一個 PR 如果 push 了新 commit,舊的 run 會自動取消,省錢!
  2. needs: lint — lint 失敗就不跑測試,省更多 CI 分鐘數
  3. 部分 matrix + include — 全版本只跑 Ubuntu,只有最新版 Python 才跑 macOS 和 Windows,平衡覆蓋率和成本
  4. cache: "pip" — 快取相依套件,第二次 run 通常快很多

七、常見問題排解
#

Q: Matrix job 太多,免費額度不夠用?
#

GitHub 免費帳號每月有 2,000 分鐘(Linux),macOS 的計算倍率是 10x。所以一個 macOS job 跑 5 分鐘 = 消耗 50 分鐘額度!

💡 省錢秘訣:

  • macOS / Windows 只跑最新版 Python
  • needs 確保 lint 先過
  • 設定 concurrency 取消重複 run
  • PR 用 pull_request 而不是 push

Q: 怎麼在 matrix 裡傳遞變數給 step?
#

${{ matrix.變數名 }} 就行:

strategy:
  matrix:
    db: [sqlite, postgres]
    include:
      - db: postgres
        db-url: "postgresql://localhost:5432/test"
      - db: sqlite
        db-url: "sqlite:///test.db"
steps:
  - run: pytest -v
    env:
      DATABASE_URL: ${{ matrix.db-url }}

Q: Trusted publisher 設定後還是 403?
#

常見原因:

  1. Workflow 檔名不一致(PyPI 上寫 publish.yml,但你的檔案叫 release.yml
  2. Environment 名稱不一致(PyPI 上寫 pypi,但 workflow 沒設 environment
  3. 忘了加 permissions: id-token: write

結語
#

Matrix strategy + Trusted Publisher 是 Python 開源專案的標配組合。設定一次之後,你就再也不用手動跑「這個版本測過了嗎?」「PyPI 上傳了嗎?」這些重複的事。

CI/CD 的核心精神就是:讓機器做重複的事,讓人專注在寫 code 上 🚀

如果你還沒看過基礎篇,記得先去 GitHub Actions:讓 GitHub 自動幫你跑測試 打底,再回來玩這些進階功能。然後也可以搭配 pre-commit 在本地先攔住問題,跟 CI 形成雙重保障!

下次見囉,拍拍!🎉

延伸閱讀
#

版本控制: Git - 本文屬於一個選集。
§ 5: 本文

相關文章

Python Profiling:cProfile + line_profiler 效能分析完全指南
·8 分鐘· loading · loading
Python Profiling CProfile Line_profiler Performance Optimization
Python subprocess:外部命令執行與管道串接完全指南
·8 分鐘· loading · loading
Python Subprocess Shell Automation Cli
Rust for Python 開發者:Ownership 與 Borrowing 入門
·7 分鐘· loading · loading
Rust Python Ownership Borrowing Memory
Python 裝飾器:讓你的函式穿上超能力外套
·7 分鐘· loading · loading
Python Decorator 裝飾器 進階語法 設計模式
Python functools 完全攻略:讓函式變得更強大的秘密武器
·7 分鐘· loading · loading
Python Functools Cache Functional-Programming
Rust CLI 實戰:用 clap 打造命令列工具(Python Typer 對照版)
·5 分鐘· loading · loading
Rust Cli Clap Typer Python