GitHub Actions: Matrix Build 進階

深入 Matrix Build 的 include/exclude、動態矩陣、fail-fast、並行控制與跨平台測試的進階應用

GitHub Actions: Matrix Build 進階

標籤:#DevOps #GitHub Actions #Matrix #並行化 #CI/CD

把單一 workflow 一次跑成 N 個並行 job,涵蓋 include / exclude / 動態矩陣 / fail-fast / 跨平台測試的完整實戰


目錄


Matrix 的核心價值

Matrix 不只是「同一個程式跑多個 Go / Node 版本」,核心價值是:

  1. 並行化降時間:30 分鐘 sequential → 5 分鐘 parallel
  2. 覆蓋多組合:OS × language version × DB version,一次測完
  3. 共用流程定義:同一份 YAML 描述「N 個 job 的差異」

一個 job 同時跑多個版本是「順序執行」,matrix 是「同時開 N 個 job 並行執行」


基本語法

最簡單的 matrix

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

→ 產生 3 個並行 job:test (18)test (20)test (22)

多維度組合(Cartesian Product)

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node: [18, 20, 22]
    steps:
      - uses: actions/setup-node@v4
        with: { node-version: ${{ matrix.node }} }
      - run: npm test

→ 產生 3 × 3 = 9 個並行 job

多變數使用同一個 matrix entry

strategy:
  matrix:
    config:
      - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
      - { os: macos-latest, target: x86_64-apple-darwin }
      - { os: windows-latest, target: x86_64-pc-windows-msvc }

steps:
  - run: cargo build --target ${{ matrix.config.target }}
    name: Build on ${{ matrix.config.os }}

把多個關聯屬性綁在一起,避免 cartesian product 產生不要的組合。


Include 與 Exclude

include — 增加額外組合(或補充欄位)

用法 1:加入 cartesian product 外的組合

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest]
    node: [18, 20]
    include:
      - os: ubuntu-latest
        node: 22       # 多測一個 Ubuntu + Node 22
      - os: windows-latest
        node: 20       # 多加一個 Windows + Node 20

最終組合:

os node
ubuntu-latest 18
ubuntu-latest 20
ubuntu-latest 22 ← include 加的
macos-latest 18
macos-latest 20
windows-latest 20 ← include 加的

用法 2:給特定組合加上額外欄位

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    include:
      - os: ubuntu-latest
        cache-path: ~/.cache
      - os: macos-latest
        cache-path: ~/Library/Caches
      - os: windows-latest
        cache-path: D:\.cache

steps:
  - uses: actions/cache@v4
    with:
      path: ${{ matrix.cache-path }}
      key: ${{ runner.os }}-build

exclude — 移除特定組合

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest, windows-latest]
    node: [18, 20, 22]
    exclude:
      - os: windows-latest
        node: 18       # Windows 不測 Node 18
      - os: macos-latest
        node: 22       # macOS 不測 Node 22

從 9 個降到 7 個。

include / exclude 處理順序

GitHub 處理順序:

1. 先計算 cartesian product
2. 套用 exclude(移除組合)
3. 套用 include(增加組合,或補充欄位)

所以 include 不會exclude 移除。

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest]
    node: [18, 20]
    exclude:
      - os: macos-latest
        node: 20
    include:
      - os: macos-latest
        node: 20       # 雖然在 exclude,但 include 還是會加回來

最終 macOS + Node 20 仍會被 include 加回。

實戰:複雜矩陣設計

針對「主流測試 + 邊緣測試」做出不同密度:

strategy:
  matrix:
    # 主流組合:全部測
    os: [ubuntu-latest]
    node: [18, 20, 22]

    # 邊緣組合:只測 LTS
    include:
      - os: macos-latest
        node: 20
      - os: windows-latest
        node: 20
      - os: ubuntu-latest
        node: 22
        extra: "experimental"   # 額外欄位標記

動態矩陣

當 matrix 內容無法在 workflow 寫死(例如要根據 PR 變更的檔案決定),用動態矩陣:

動態矩陣模式

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set.outputs.matrix }}
    steps:
      - uses: actions/checkout@v4
      - id: set
        run: |
          # 偵測有哪些 package 改動
          PACKAGES=$(./scripts/changed-packages.sh | jq -R -s -c 'split("\n") | map(select(length > 0))')
          echo "matrix=$PACKAGES" >> "$GITHUB_OUTPUT"

  test:
    needs: detect
    if: needs.detect.outputs.matrix != '[]'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        package: ${{ fromJSON(needs.detect.outputs.matrix) }}
    steps:
      - uses: actions/checkout@v4
      - run: cd packages/${{ matrix.package }} && npm test

動態矩陣的關鍵點

  1. 必須輸出合法 JSON:["a", "b"] 或更複雜的 array of objects
  2. fromJSON() 在 caller 解析
  3. 空陣列要處理:if: needs.detect.outputs.matrix != '[]' 避免 matrix 為空時報錯
  4. outputs 有大小限制:單一 output 最大 1MB

範例:從 PR 變更檔案產生 matrix

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      packages: ${{ steps.filter.outputs.packages }}
    steps:
      - uses: actions/checkout@v4

      - id: filter
        uses: dorny/paths-filter@v3
        with:
          list-files: json
          filters: |
            api:
              - 'packages/api/**'
            web:
              - 'packages/web/**'
            mobile:
              - 'packages/mobile/**'

      - run: |
          MATRIX=$(echo '${{ steps.filter.outputs.changes }}' | jq -c '.')
          echo "packages=$MATRIX" >> "$GITHUB_OUTPUT"

  test:
    needs: changes
    if: needs.changes.outputs.packages != '[]'
    strategy:
      matrix:
        package: ${{ fromJSON(needs.changes.outputs.packages) }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cd packages/${{ matrix.package }} && npm test

Fail-fast 與容錯

fail-fast(預設 true)

strategy:
  fail-fast: false   # 預設 true
  matrix:
    node: [18, 20, 22]
  • fail-fast: true(預設):matrix 內任何一個 job 失敗,其他還沒跑完的會被取消
  • fail-fast: false:讓所有 matrix job 跑完,即使某些失敗

選擇建議:

  • 開發中:true(快速看到失敗就停)
  • 全面測試 / pre-release 驗證:false(看完整失敗分佈)

continue-on-error

strategy:
  matrix:
    config:
      - { node: 18, stable: true }
      - { node: 20, stable: true }
      - { node: 22, stable: true }
      - { node: 23, stable: false }  # 預發版,容許失敗
jobs:
  test:
    runs-on: ubuntu-latest
    continue-on-error: ${{ !matrix.config.stable }}
    steps:
      - run: npm test

continue-on-error: true 的 job 即使失敗,整個 workflow 仍會顯示為 success(會打勾但 job log 仍標示失敗)。

組合用法

strategy:
  fail-fast: false
  matrix:
    target:
      - { node: 18, allow-failure: false }
      - { node: 20, allow-failure: false }
      - { node: 22, allow-failure: false }
      - { node: 23, allow-failure: true }   # nightly
jobs:
  test:
    runs-on: ubuntu-latest
    continue-on-error: ${{ matrix.target.allow-failure }}

→ 跑完所有版本,但 Node 23 失敗不影響整體狀態


並行數量控制

max-parallel — 限制同時執行數

strategy:
  max-parallel: 3
  matrix:
    target: [a, b, c, d, e, f, g, h]

→ 雖然有 8 個組合,但同時只跑 3 個,排隊執行

用途:

  • runner 配額有限
  • 對外部資源有壓力(API rate limit、DB 連線)
  • self-hosted runner 容量不夠

concurrency 對比

機制 控制範圍 用途
strategy.max-parallel 單一 matrix 內 控制 matrix 內並行數
concurrency 整個 workflow 避免同時跑多個 workflow run
# 多個觸發只跑最新
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    strategy:
      max-parallel: 5    # 配合用
      matrix: ...

跨平台測試實戰

範例 1:Go 跨平台 build

jobs:
  build:
    name: Build (${{ matrix.target.os }} / ${{ matrix.target.arch }})
    runs-on: ${{ matrix.target.runner }}
    strategy:
      fail-fast: false
      matrix:
        target:
          - { runner: ubuntu-latest, os: linux, arch: amd64 }
          - { runner: ubuntu-latest, os: linux, arch: arm64 }
          - { runner: macos-latest, os: darwin, arch: amd64 }
          - { runner: macos-latest, os: darwin, arch: arm64 }
          - { runner: windows-latest, os: windows, arch: amd64 }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: "1.22" }

      - name: Build
        env:
          GOOS: ${{ matrix.target.os }}
          GOARCH: ${{ matrix.target.arch }}
        run: |
          OUT="myapp-${GOOS}-${GOARCH}"
          [ "$GOOS" = "windows" ] && OUT="$OUT.exe"
          go build -o "$OUT" ./cmd/myapp

      - uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.target.os }}-${{ matrix.target.arch }}
          path: myapp-*

範例 2:Python + 多個 DB

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python: ["3.10", "3.11", "3.12"]
        db:
          - { image: "postgres:15", port: 5432, type: "postgres" }
          - { image: "postgres:16", port: 5432, type: "postgres" }
          - { image: "mysql:8", port: 3306, type: "mysql" }
    services:
      database:
        image: ${{ matrix.db.image }}
        env:
          POSTGRES_PASSWORD: postgres
          MYSQL_ROOT_PASSWORD: mysql
        ports:
          - ${{ matrix.db.port }}:${{ matrix.db.port }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: ${{ matrix.python }} }
      - run: pip install -r requirements.txt
      - run: pytest
        env:
          DB_TYPE: ${{ matrix.db.type }}
          DB_PORT: ${{ matrix.db.port }}

→ 3 × 3 = 9 組合

範例 3:Node + 多 OS + browser 測試

jobs:
  e2e:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        browser: [chromium, firefox, webkit]
        exclude:
          - os: ubuntu-latest
            browser: webkit       # ubuntu 上 webkit 有問題
        include:
          - os: ubuntu-latest
            shell: bash
          - os: macos-latest
            shell: bash
          - os: windows-latest
            shell: pwsh
    defaults:
      run:
        shell: ${{ matrix.shell }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npx playwright install ${{ matrix.browser }}
      - run: npx playwright test --project=${{ matrix.browser }}

Matrix 與 Reusable Workflow 組合

Reusable workflow 也能接收 matrix 風格的 input,讓 caller 帶矩陣:

# reusable-test.yml
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        required: true
      os:
        type: string
        required: true
jobs:
  test:
    runs-on: ${{ inputs.os }}
    steps:
      - uses: actions/setup-node@v4
        with: { node-version: ${{ inputs.node-version }} }
      - run: npm test
# caller.yml
jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        node: [18, 20, 22]
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: ${{ matrix.node }}
      os: ${{ matrix.os }}

注意:jobs.<id>uses: 時,只能在外層放 strategy.matrix,不能在 reusable workflow 內部再做 matrix


Matrix 輸出聚合

Matrix 內每個 job 都是獨立的,要彙整結果需要技巧。

方法 1:用 artifact 收集

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - run: npm test -- --shard=${{ matrix.shard }}/4 --reporter=json > result-${{ matrix.shard }}.json
      - uses: actions/upload-artifact@v4
        with:
          name: test-results-${{ matrix.shard }}
          path: result-${{ matrix.shard }}.json

  aggregate:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: test-results-*
          merge-multiple: true
      - run: jq -s 'add' result-*.json > all.json

方法 2:整合測試報告

jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - run: ./run-tests.sh --shard ${{ matrix.shard }}
      - uses: actions/upload-artifact@v4
        with:
          name: junit-${{ matrix.shard }}
          path: junit.xml

  report:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: junit-*
          path: reports
      - uses: dorny/test-reporter@v1
        with:
          name: Combined Tests
          path: reports/**/*.xml
          reporter: java-junit

方法 3:聚合 outputs(有限制)

Matrix outputs 不能直接合併,要用 strategy 內 outputs 或 artifact。最簡單的還是 artifact。


最佳實踐

1. 給 matrix job 取好讀的名字

預設名字會把所有 matrix 變數連起來:test (ubuntu-latest, 20, postgres:15),長且不好讀。

jobs:
  test:
    name: Test on ${{ matrix.target.label }}
    strategy:
      matrix:
        target:
          - { label: "Ubuntu / Node 20", os: ubuntu-latest, node: 20 }
          - { label: "macOS / Node 20", os: macos-latest, node: 20 }

2. 大矩陣要謹慎

100 個 matrix job × 5 分鐘 = 500 分鐘 runner 時間,費用很驚人。考慮:

  • 主流組合每個 PR 跑
  • 完整矩陣每天 nightly 跑一次
# pr-ci.yml — 主流測試
strategy:
  matrix:
    node: [20]  # 只測 LTS
    os: [ubuntu-latest]

# nightly.yml — 完整矩陣
on:
  schedule:
    - cron: "0 2 * * *"
strategy:
  matrix:
    node: [18, 20, 22, 23]
    os: [ubuntu-latest, macos-latest, windows-latest]

3. 善用 include 避免無意義組合

不要這樣:

# ❌ 16 個組合,但很多不合理
matrix:
  os: [ubuntu, macos, windows, ubuntu-arm]
  arch: [x86, x64, arm, arm64]

改成:

# ✅ 只列合理組合
matrix:
  include:
    - { os: ubuntu-latest, arch: x86_64 }
    - { os: ubuntu-latest, arch: aarch64 }
    - { os: macos-latest, arch: x86_64 }
    - { os: macos-latest, arch: aarch64 }
    - { os: windows-latest, arch: x86_64 }

4. 動態矩陣加上「空陣列」檢查

test:
  needs: detect
  if: needs.detect.outputs.matrix != '[]' && needs.detect.outputs.matrix != ''
  strategy:
    matrix:
      target: ${{ fromJSON(needs.detect.outputs.matrix) }}

不然 fromJSON('[]') 會讓 matrix 為空,GitHub 顯示為 error。

5. 用 matrix 做 sharding 加速測試

把測試切分到 N 個 shard 並行跑:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4, 5, 6, 7, 8]
    steps:
      - run: ./run-tests.sh --shard=${{ matrix.shard }}/8

→ 原本 40 分鐘 → 8 shard × 5 分鐘並行 → 5 分鐘完成

6. Self-hosted runner + matrix 要小心

strategy:
  matrix:
    node: [18, 20, 22]
runs-on: [self-hosted, linux]

如果只有 2 個 self-hosted runner,3 個 matrix job 會有 1 個排隊。要評估 runner 容量。


常見問題

Q1:Matrix job 名字怎麼自訂?

name: 並引用 matrix 變數:

jobs:
  test:
    name: Test ${{ matrix.label }}
    strategy:
      matrix:
        include:
          - { label: "Linux Node 20", os: ubuntu-latest, node: 20 }

Q2:Matrix 內變數可以在 step if: 用嗎?

可以,而且 step 層級的 condition 也能取 matrix:

steps:
  - name: macOS only step
    if: matrix.os == 'macos-latest'
    run: brew install something

Q3:Matrix outputs 怎麼合併?

Matrix 內每個 job 的 outputs 是獨立的,不能直接合併成陣列。實務做法:

  • 用 artifact 上傳每個 job 的結果,後續 job 下載聚合
  • 或用 needs.<job>.outputs.<key> 但只能拿單一 job 的值

Q4:動態矩陣產生失敗,顯示 matrix must define at least one vector

通常是 fromJSON 收到空字串或 []。加 if: 保護:

if: needs.detect.outputs.matrix != '[]'

或在 detect job 直接給 fallback:

echo "matrix=${PACKAGES:-[]}" >> "$GITHUB_OUTPUT"

Q5:fail-fast: false 但 workflow 還是顯示失敗?

fail-fast 控制的是「matrix 內彼此是否互相取消」,不影響整個 workflow 的最終狀態。要避免失敗影響整體,需要在 job 層級設 continue-on-error


Q6:Matrix 內可以呼叫 reusable workflow 嗎?

可以,語法:

jobs:
  test:
    strategy:
      matrix:
        target: [a, b, c]
    uses: ./.github/workflows/reusable.yml
    with:
      target: ${{ matrix.target }}

但 reusable workflow 內不能再宣告 matrix(matrix 必須在 caller 層級)。


Q7:Matrix 上限是多少?

GitHub 規定:

  • 單一 workflow 內,最多 256 個 job(包含 matrix 展開後的)
  • 單一 matrix 最多 256 個組合(扣掉 exclude 後)

超過會直接拒絕執行。


Q8:能不能讓 matrix 等待某個共用準備 job 後再跑?

可以,用 needs:

jobs:
  build-image:
    runs-on: ubuntu-latest
    steps: ...

  test:
    needs: build-image
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps: ...

build-image 跑完一次,4 個 matrix shard 並行用它的結果。


總結

核心要點

  • 基本語法:strategy.matrix.<key>: [values] → cartesian product
  • include:加組合或補充欄位(在 product 後加)
  • exclude:從 product 移除組合(在 include 前處理)
  • 動態矩陣:fromJSON(needs.x.outputs.matrix),要保護空陣列
  • fail-fast: false:跑完所有 matrix job,看完整失敗
  • continue-on-error:允許某些 matrix job 失敗不影響整體
  • max-parallel:限制 matrix 內並行數
  • Output 聚合:用 artifact,不能直接合併 outputs

速查 — 處理順序

1. cartesian product (key 陣列展開)
2. exclude 移除
3. include 加入(不會被 exclude 影響)
4. max-parallel 限制並行
5. fail-fast 決定失敗時是否取消其他

設計檢查清單

  • Job 名字易讀(用 name:include + label)
  • 主流組合每個 PR 跑,完整矩陣 nightly 跑
  • 動態矩陣有空陣列檢查
  • Sharding 切分長測試
  • OS-specific 步驟用 if: 包起來
  • Self-hosted runner 容量足夠

速查骨架

strategy:
  fail-fast: false
  max-parallel: 5
  matrix:
    os: [ubuntu-latest, macos-latest]
    node: [18, 20, 22]
    exclude:
      - { os: macos-latest, node: 22 }
    include:
      - { os: windows-latest, node: 20 }
      - { os: ubuntu-latest, node: 20, extra: "ssr" }

jobs:
  test:
    runs-on: ${{ matrix.os }}
    continue-on-error: ${{ matrix.extra == 'experimental' }}
    steps: ...

建立日期:2026-05-25

🔗相關文章