一、前言 #
嗨,這裡是拍拍君!⚙️
之前我們在 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 端設定:
- 登入 pypi.org,進入你的 package → Settings → Publishing
- 選 “Add a new publisher”
- 填入:
- Owner:
你的 GitHub 帳號 - Repository:
你的 repo 名稱 - Workflow name:
publish.yml - Environment:
pypi(建議設定)
- Owner:
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
這個結構的巧妙之處:
concurrency— 同一個 PR 如果 push 了新 commit,舊的 run 會自動取消,省錢!needs: lint— lint 失敗就不跑測試,省更多 CI 分鐘數- 部分 matrix + include — 全版本只跑 Ubuntu,只有最新版 Python 才跑 macOS 和 Windows,平衡覆蓋率和成本
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? #
常見原因:
- Workflow 檔名不一致(PyPI 上寫
publish.yml,但你的檔案叫release.yml) - Environment 名稱不一致(PyPI 上寫
pypi,但 workflow 沒設environment) - 忘了加
permissions: id-token: write
結語 #
Matrix strategy + Trusted Publisher 是 Python 開源專案的標配組合。設定一次之後,你就再也不用手動跑「這個版本測過了嗎?」「PyPI 上傳了嗎?」這些重複的事。
CI/CD 的核心精神就是:讓機器做重複的事,讓人專注在寫 code 上 🚀
如果你還沒看過基礎篇,記得先去 GitHub Actions:讓 GitHub 自動幫你跑測試 打底,再回來玩這些進階功能。然後也可以搭配 pre-commit 在本地先攔住問題,跟 CI 形成雙重保障!
下次見囉,拍拍!🎉