GitHub Actions 進階:Path Filter、Concurrency、Cache 與 GitOps 自動更新

這篇記錄把 GitHub Actions pipeline 從基礎補到可用: trigger 設計、path filter、concurrency、cache, 到 CI 自動更新 deployment.yaml 完成 GitOps 閉環。最後對比 Jenkins 和 CircleCI。

Trigger 設計

三種 trigger 各自的用途:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'dev'
        type: choice
        options: [dev, prod]
Trigger 觸發時機 用途
push merge 到 main 主線 CI, 每次合併後跑
pull_request PR 開啟或更新 review 前驗證, 保護 main
workflow_dispatch 手動從 GitHub UI hotfix 補跑、手動指定環境 deploy

workflow_dispatch 加了 inputs 之後, GitHub UI 會出現下拉選單, 讓你在觸發時選擇環境。inputs 支援 stringbooleannumberchoice 四種類型。

Path Filter:只在相關檔案變動時觸發

push README 不應該觸發 CI。paths filter 讓 workflow 只在有意義的檔案變動時才跑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
on:
  push:
    branches: [main]
    paths:
      - '**.go'
      - 'Dockerfile'
      - 'go.mod'
      - 'go.sum'
      - '.github/workflows/**'
  pull_request:
    branches: [main]
    paths:
      - '**.go'
      - 'Dockerfile'
      - 'go.mod'
      - 'go.sum'
      - '.github/workflows/**'

** 匹配任意深度的子目錄, * 只匹配單層。**.go 能匹配 handlers/health.gomiddleware/auth.go, 而 *.go 只匹配根目錄的 .go 檔。

workflow_dispatch 不需要 paths, 手動觸發永遠跑。

if 條件:控制 Job 和 Step 執行

PR 的目的是 review, 不是上線。deploy job 不應該在 PR 觸發:

1
2
3
deploy:
  needs: build
  if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'

github.event_name 是 GitHub Actions 內建的 context, 值對應觸發的 trigger 名稱。pull_request 被排除在外, test 和 build 還是會跑, 確認 PR 可以合。

if 可以放在兩個層級:

層級 效果 範例
job 層級 整個 job 跳過或執行 deploy 只在 push 跑
step 層級 單一 step 跳過或執行 prod 環境才跑某個 step

step 層級常見的內建函式:

1
2
3
4
if: success()    # all previous steps passed (default)
if: failure()    # any previous step failed
if: always()     # runs regardless — cleanup, notifications
if: cancelled()  # workflow was cancelled

Concurrency:防止同時跑多個 Deploy

sequenceDiagram
    participant C1 as Commit A
    participant C2 as Commit B
    participant D as Deploy Job

    C1->>D: trigger deploy (3 min)
    C2->>D: trigger deploy (30s later)
    Note over D: cancel-in-progress: true
cancel A, run B
sequenceDiagram
    participant C1 as Commit A
    participant C2 as Commit B
    participant D as Deploy Job

    C1->>D: trigger deploy (3 min)
    C2->>D: trigger deploy (30s later)
    Note over D: cancel-in-progress: true
cancel A, run B
sequenceDiagram
    participant C1 as Commit A
    participant C2 as Commit B
    participant D as Deploy Job

    C1->>D: trigger deploy (3 min)
    C2->>D: trigger deploy (30s later)
    Note over D: cancel-in-progress: true
cancel A, run B

concurrency 讓同一組的 job 不能同時跑:

1
2
3
4
deploy:
  concurrency:
    group: deploy-${{ github.ref }}
    cancel-in-progress: true

group 是識別 key, github.ref 是 branch 名稱, 讓 main branch 的 deploy 共用同一個 lock。

cancel-in-progress: true cancel-in-progress: false
行為 新的進來, 取消舊的 新的排隊等
適合 deploy(要最新版本) database migration(不能中斷)

concurrency 只放在 deploy job, 不放 test 和 build——每個 commit 都需要完整的測試記錄。

Go Module Cache:加速 CI

每次 runner 是全新環境, 所有 dependencies 重新下載。go.sum 沒變, 重新下載完全是浪費。

actions/setup-go v4 之後內建 cache:

1
2
3
4
- uses: actions/setup-go@v6
  with:
    go-version-file: go.mod
    cache: true

cache: true 自動以 runner.os + go.sum hash 為 key。go.sum 沒變就命中 cache 跳過下載。

key 設計原則:把決定 cache 內容的檔案 hash 進 key

情境 Key 包含什麼
Go runner.os + hashFiles('**/go.sum')
Node.js runner.os + hashFiles('**/package-lock.json')
Python runner.os + hashFiles('**/requirements.txt')

ENV_TAG:環境感知的 Image Tag

flowchart LR
    A{trigger?} -->|workflow_dispatch| B[inputs.environment]
    A -->|push to main| C[prod]
    A -->|other branch| D[dev]
    B --> E[ENV_TAG]
    C --> E
    D --> E
flowchart LR
    A{trigger?} -->|workflow_dispatch| B[inputs.environment]
    A -->|push to main| C[prod]
    A -->|other branch| D[dev]
    B --> E[ENV_TAG]
    C --> E
    D --> E
flowchart LR
    A{trigger?} -->|workflow_dispatch| B[inputs.environment]
    A -->|push to main| C[prod]
    A -->|other branch| D[dev]
    B --> E[ENV_TAG]
    C --> E
    D --> E
1
2
env:
  ENV_TAG: ${{ inputs.environment || (github.ref == 'refs/heads/main' && 'prod') || 'dev' }}

SHORT_SHA:跨 Step 共用變數

每個 run: block 是獨立的 shell, 變數不能直接跨 step 傳遞。GITHUB_ENV 是 GitHub Actions 的特殊檔案, 寫進去的變數在後續所有 step 都能讀到:

1
2
3
4
5
6
7
- name: Set SHORT_SHA
  run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV

- name: Docker push to AR
  run: |
    docker build -t ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${ENV_TAG}-${SHORT_SHA} .
    docker push ${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${ENV_TAG}-${SHORT_SHA}

${GITHUB_SHA::7} 是 bash 字串截斷語法, 取前 7 字元。Docker push 和 deployment.yaml 更新都用同一個 SHORT_SHA, 保證 tag 一致。

GitOps 自動更新 deployment.yaml

CI build 完之後, 自動更新 k8s/base/deployment.yaml 的 image tag, push 到 gitops branch, ArgoCD 偵測到變更後 sync:

1
2
3
4
5
6
7
8
9
- name: Update image tag
  run: |
    sed -i "s|image: .*-docker.pkg.dev/.*/go-api/go-api:.*|image: us-east1-docker.pkg.dev/${GCP_PROJECT_ID}/go-api/go-api:${ENV_TAG}-${SHORT_SHA}|" k8s/base/deployment.yaml
    git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
    git config user.name "github-actions[bot]"
    git checkout -b gitops/update-image-${SHORT_SHA}
    git add k8s/base/deployment.yaml
    git commit -m "chore: update image tag to ${ENV_TAG}-${SHORT_SHA}"
    git push origin gitops/update-image-${SHORT_SHA}

sed| 而不是 / 作為分隔符, 是因為 image URL 裡有 /, 避免語法混淆。push 到獨立的 gitops branch 而不是直接 push main, 是因為 main 有 branch protection rule。contents: write permission 讓 runner 有寫入 repo 的權限。

Image Tagging:git SHA vs Semver

Git SHA Semver (v1.2.3)
唯一性 天生唯一 需要人工維護
可追蹤性 直接對應 commit 需要額外記錄 tag 對應的 commit
語意 有, v1.3.0 vs v1.2.1 一眼看出
適合 內部服務、CI/CD 自動化 對外 API、library

pipeline 用 {env}-{sha} 格式(例如 prod-7639a24), 一眼看出環境和 commit。

Jenkins vs GitHub Actions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
pipeline {
    agent any
    environment {
        GCP_REGION = 'us-east1'
    }
    stages {
        stage('Test') {
            steps { sh 'go test ./...' }
        }
        stage('Build') {
            steps { sh 'go build ./...' }
        }
        stage('Deploy') {
            when { branch 'main' }
            steps { sh 'docker build -t go-api .' }
        }
    }
    post {
        failure { echo 'Pipeline failed' }
        always { cleanWs() }
    }
}
GitHub Actions Jenkins
設定檔 .github/workflows/*.yml Jenkinsfile
語法 YAML Groovy DSL
Job 依賴 needs: [test] stage 預設循序執行
Branch 限制 if: github.event_name == 'push' when { branch 'main' }
Cleanup step-level if: always() post { always {} }
架設 雲端, 不用管 infra 需要自己維護 server
整合 GitHub 深度整合 plugin 生態豐富
適合 雲端原生、開源專案 企業、on-premise、複雜 pipeline

CircleCI 對比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# .circleci/config.yml
version: 2.1

orbs:
  go: circleci/go@1.11

workflows:
  ci:
    jobs:
      - test
      - build:
          requires: [test]
      - deploy:
          requires: [build]
          context: gcp-prod
          filters:
            branches:
              only: main

jobs:
  test:
    docker:
      - image: cimg/go:1.23
    steps:
      - checkout
      - go/load-cache
      - run: go test ./...
      - go/save-cache
概念 GitHub Actions CircleCI
Job 依賴 needs: [test] requires: [test]
Branch 限制 if: condition filters: branches: only:
可重用套件 Marketplace actions Orbs
跨 repo 共用 credentials 每個 repo 各設 secrets Contexts(org 層級共用)
手動觸發 + 參數 workflow_dispatch + UI API call + pipeline.parameters

CircleCI 的核心優勢是 Contexts 跨 repo 共用——多個 repo 指向同一個 Context, credentials 集中管理, 改一次全部生效。這個優勢在中大型企業多 repo 環境才有明顯差異。

References