CI/CD 是现代软件交付的命脉。手动构建、手动部署不仅效率低下,更是事故的温床——“在我机器上能跑"的悲剧几乎都源于缺乏自动化流水线。GitHub Actions 作为 GitHub 原生的 CI/CD 平台,与代码仓库无缝集成,免费额度对开源项目友好,已成为最流行的 CI/CD 工具之一。从核心概念出发,结合 Go 项目和 Hugo 站点两个实战场景,完整讲解流水线设计。

参考来源:GitHub Actions 官方文档

一、GitHub Actions 核心概念

GitHub Actions 的架构围绕五个概念展开,理解它们的关系是设计流水线的基础:

Workflow(工作流)
  ├── Job A(任务)
  │     ├── Step 1 → Action: checkout 代码
  │     ├── Step 2 → Action: setup Go 环境
  │     └── Step 3 → Shell: go test ./...
  └── Job B(任务)
        ├── Step 1 → Action: 下载构建产物
        └── Step 2 → Shell: 部署到服务器
概念说明类比
Workflow一个 .yml 文件定义的完整流水线,放在 .github/workflows/ 目录一个"流水线模板”
JobWorkflow 内的独立任务单元,由多个 Step 组成;Job 间可串行或并行流水线上的"工位"
StepJob 内的最小执行单元,可以是 Shell 命令或 Action工位上的"操作步骤"
Action可复用的步骤单元,类似函数调用;可在 GitHub Marketplace 查找可复用的"函数"
Runner执行 Job 的服务器实例,分 GitHub 托管和自托管两种执行任务的"工人"

Runner 类型选择

Runner 类型规格费用适用场景
ubuntu-latest4 vCPU / 16GB / 14GB SSD公开仓库免费,私有仓库按分钟计费大多数 CI 场景
macos-latest3 vCPU / 14GB / 14GB SSD费用较高(10x)iOS/macOS 构建
windows-latest4 vCPU / 16GB / 14GB SSD费用较高(2x)Windows 应用构建
self-hosted自定义自担硬件成本私有环境、特殊依赖、GPU

二、触发机制

触发器(Trigger)决定了 Workflow 何时执行。GitHub Actions 支持丰富的触发方式:

name: CI

# ── 多触发条件(满足任一即触发)──
on:
  # 1. 代码推送
  push:
    branches:
      - main
      - 'release/*'
    paths:
      - '**.go'
      - 'go.mod'
      - 'go.sum'
    paths-ignore:
      - '**.md'
      - 'docs/**'

  # 2. Pull Request
  pull_request:
    branches: [main, develop]
    types: [opened, synchronize, reopened]

  # 3. 定时任务(Cron 语法,UTC 时区)
  schedule:
    # 每天北京时间 08:00 执行(UTC 00:00)
    - cron: '0 0 * * *'

  # 4. 手动触发
  workflow_dispatch:
    inputs:
      environment:
        description: '部署环境'
        required: true
        type: choice
        options:
          - staging
          - production
      debug_mode:
        description: '启用调试'
        required: false
        type: boolean
        default: false

  # 5. 发布 Release
  release:
    types: [published]

paths 过滤是减少不必要 CI 运行的关键。只修改文档时不应触发 Go 测试流水线,通过 pathspaths-ignore 可以精确控制。

三、实战 1:Go 项目 CI 流水线

这是一个生产级 Go 项目 CI 流水线,覆盖 lint、测试、构建和镜像推送:

# .github/workflows/go-ci.yml
name: Go CI

on:
  push:
    branches: [main]
    paths: ['**.go', 'go.mod', 'go.sum', '.github/workflows/go-ci.yml']
  pull_request:
    branches: [main]

env:
  GO_VERSION: '1.22'
  REGISTRY: ghcr.io

jobs:
  # ──────────────────────────────────────────
  # Job 1: 代码检查
  # ──────────────────────────────────────────
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      - name: Run golangci-lint
        uses: golangci/golangci-lint-action@v6
        with:
          version: v1.59
          args: --timeout=5m

  # ──────────────────────────────────────────
  # Job 2: 单元测试 + 覆盖率
  # ──────────────────────────────────────────
  test:
    name: Test
    runs-on: ubuntu-latest
    services:
      # 启动 PostgreSQL 容器供测试使用
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5          

    steps:
      - uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      - name: Download dependencies
        run: go mod download

      - name: Run tests with coverage
        env:
          DB_HOST: localhost
          DB_PORT: 5432
          DB_PASSWORD: testpass
          DB_NAME: testdb
        run: |
          go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
          go tool cover -func=coverage.out          

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.out
          token: ${{ secrets.CODECOV_TOKEN }}

  # ──────────────────────────────────────────
  # Job 3: 多平台构建 + 镜像推送
  # ──────────────────────────────────────────
  build:
    name: Build & Push
    needs: [lint, test]
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    strategy:
      matrix:
        target:
          - goos: linux
            goarch: amd64
          - goos: linux
            goarch: arm64

    steps:
      - uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      - name: Build binary
        env:
          CGO_ENABLED: 0
          GOOS: ${{ matrix.target.goos }}
          GOARCH: ${{ matrix.target.goarch }}
        run: |
          LDFLAGS="-s -w -X main.Version=${GITHUB_REF_NAME} -X main.Commit=${GITHUB_SHA::8}"
          go build -ldflags="$LDFLAGS" -o app-${{ matrix.target.goos }}-${{ matrix.target.goarch }} ./cmd/server          

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ github.repository }}
          tags: |
            type=ref,event=branch
            type=sha,prefix={{branch}}-
            type=raw,value=latest,enable={{is_default_branch}}            

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/${{ matrix.target.goarch }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

流水线设计要点

push / pull_request
   ┌─────────┐     ┌─────────┐
   │  Lint   │     │  Test   │   ← 并行执行,互不阻塞
   └────┬────┘     └────┬────┘
        │               │
        └──────┬────────┘
               │  needs: [lint, test]
        ┌─────────────┐
        │   Build     │     ← matrix 并行构建 amd64 + arm64
        │  (x2 jobs)  │
        └─────────────┘
        推送镜像到 GHCR

关键设计决策

  • linttest 并行执行,缩短流水线耗时
  • build 通过 needs: [lint, test] 确保前置检查通过才构建
  • matrix 策略并行构建 linux/amd64linux/arm64,支持 ARM 服务器
  • 使用 docker/metadata-action 自动生成语义化镜像标签
  • cache-from/cache-to: type=gha 利用 GitHub Actions 缓存加速 Docker 构建

四、实战 2:Hugo 站点 CD 流水线

本文所在的 SRE 学习笔记站点就是用 Hugo 构建的。下面是一个完整的 Hugo 站点部署流水线,支持 GitHub Pages 和 VPS 两种部署目标:

# .github/workflows/hugo-deploy.yml
name: Hugo Deploy

on:
  push:
    branches: [main]
    paths:
      - 'content/**'
      - 'static/**'
      - 'layouts/**'
      - 'config.toml'
      - 'hugo.toml'
      - '.github/workflows/hugo-deploy.yml'
  workflow_dispatch:
    inputs:
      target:
        description: '部署目标'
        required: true
        type: choice
        options:
          - github-pages
          - vps
          - both

# 设置 Workflow 级权限
permissions:
  contents: read
  pages: write
  id-token: write

# 同一时间只允许一个部署并发
concurrency:
  group: pages
  cancel-in-progress: false

env:
  HUGO_VERSION: '0.129.0'

jobs:
  # ──────────────────────────────────────────
  # Job 1: 构建 Hugo 站点
  # ──────────────────────────────────────────
  build:
    name: Build Hugo Site
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: true        # 获取 Hugo 主题子模块
          fetch-depth: 0          # 获取完整 Git 历史(启用 lastmod)

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: ${{ env.HUGO_VERSION }}
          extended: true           # PaperMod 主题需要 extended 版本

      - name: Setup Go cache
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/go-build
            ~/go/pkg/mod            
          key: ${{ runner.os }}-hugo-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            ${{ runner.os }}-hugo-            

      - name: Build
        run: |
          hugo \
            --minify \
            --baseURL "https://www.sre.wang/" \
            --enableGitInfo          

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./public

  # ──────────────────────────────────────────
  # Job 2: 部署到 GitHub Pages
  # ──────────────────────────────────────────
  deploy-pages:
    name: Deploy to GitHub Pages
    needs: build
    runs-on: ubuntu-latest
    if: >-
      github.event_name == 'push' ||
      (github.event_name == 'workflow_dispatch' &&
       (github.event.inputs.target == 'github-pages' ||
        github.event.inputs.target == 'both'))      
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

  # ──────────────────────────────────────────
  # Job 3: 部署到 VPS(通过 rsync over SSH)
  # ──────────────────────────────────────────
  deploy-vps:
    name: Deploy to VPS
    needs: build
    runs-on: ubuntu-latest
    if: >-
      github.event_name == 'push' ||
      (github.event_name == 'workflow_dispatch' &&
       (github.event.inputs.target == 'vps' ||
        github.event.inputs.target == 'both'))      
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: github-pages

      - name: Deploy via rsync
        uses: burnett01/rsync-deployments@7.0.1
        with:
          switches: -avzr --delete
          path: ./
          remote_path: /var/www/sre.wang/
          remote_host: ${{ secrets.VPS_HOST }}
          remote_user: ${{ secrets.VPS_USER }}
          remote_key: ${{ secrets.VPS_SSH_KEY }}

CD 流水线设计要点

设计点说明
paths 过滤仅在内容/布局/配置变更时触发,避免无意义构建
concurrencycancel-in-progress: false 确保部署不被中断,同一时间只跑一个
artifact 传递build 上传产物,deploy 下载产物,实现构建与部署解耦
双部署目标通过 workflow_dispatch 输入参数选择部署到 Pages/VPS/两者
environment 保护environment: github-pages 可配置审批人、环境变量和部署日志

五、缓存策略:加速构建

缓存是缩短 CI 耗时的最有效手段。GitHub Actions 提供了 actions/cache 和多种内置缓存机制:

5.1 Go 模块缓存

- uses: actions/setup-go@v5
  with:
    go-version: '1.22'
    cache: true    # setup-go 内置缓存,自动缓存 ~/go/pkg/mod 和 ~/.cache/go-build

actions/setup-go@v5 自带缓存功能,无需手动配置 actions/cache。它自动以 go.sum 文件哈希作为缓存键。

5.2 通用缓存模式

- name: Cache Node modules
  uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      node_modules      
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-      

- name: Cache Docker layers
  uses: actions/cache@v4
  with:
    path: /tmp/.buildx-cache
    key: ${{ runner.os }}-buildx-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-buildx-      

5.3 缓存策略对比

缓存方式适用场景优势局限
actions/setup-go cacheGo 项目零配置,自动管理仅限 Go 模块
actions/cache@v4通用文件缓存灵活,支持任意路径需手动管理缓存键
cache-from: type=ghaDocker 构建缓存 Docker 层有大小限制(10GB)
actions/cache/restore部分恢复不在命中时不写缓存需配合 actions/cache/save

缓存键设计原则

# 缓存键的三层设计:精确匹配 → 部分匹配 → 系统级兜底
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}       # 精确:依赖文件变了就新建缓存
restore-keys: |
  ${{ runner.os }}-go-                                        # 部分:依赖文件变了也能恢复大部分缓存
  ${{ runner.os }}-                                           # 兜底:至少恢复系统级缓存  

缓存键的设计直接影响命中率。hashFiles 保证依赖变更时新建缓存,restore-keys 的渐进式匹配确保即使精确键未命中,也能恢复大部分缓存内容。

六、密钥管理:GitHub Secrets 与 OIDC

6.1 GitHub Secrets

GitHub Secrets 是存储敏感信息的标准方式。在仓库 Settings → Secrets and variables → Actions 中添加:

steps:
  - name: Deploy to production
    env:
      DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}     # 数据库密码
      API_KEY: ${{ secrets.API_KEY }}                   # 第三方 API Key
      DEPLOY_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}        # SSH 私钥
    run: ./deploy.sh

Secrets 使用规则

规则说明
自动脱敏Secret 值在日志中自动替换为 ***
环境隔离通过 environment 级 Secrets 实现不同环境的密钥隔离
组织级共享Organization 级 Secrets 可在多个仓库间共享
不可在 if 中引用Secrets 不能用于 if 条件判断(出于安全考虑)

6.2 OIDC 无密钥认证

传统方式中,CI 需要长期有效的云厂商 Access Key 来部署资源。这些密钥一旦泄露,攻击者可以完全控制你的云账户。OIDC(OpenID Connect) 通过短期令牌替代长期密钥,是更安全的方案。

# Go CI 中使用 OIDC 推送镜像到 AWS ECR
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write     # 必须启用,用于获取 OIDC 令牌
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: ap-northeast-1

      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/app:latest

OIDC vs 长期密钥对比

传统方式(长期密钥):
  GitHub Secrets → AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
  ┌──────────┐         ┌──────────┐
  │  CI Job   │ ──key──→│   AWS    │   密钥泄露 = 账户沦陷
  └──────────┘         └──────────┘

OIDC 方式(短期令牌):
  ┌──────────┐  OIDC Token  ┌──────────┐  AssumeRole  ┌──────────┐
  │  CI Job   │ ──────────→ │  AWS IAM  │ ───────────→ │   AWS    │
  └──────────┘  (JWT, 1h)   └──────────┘  (临时凭证)    └──────────┘
  无需存储任何长期密钥         信任关系预先配置            令牌 1 小时过期
维度长期密钥OIDC
密钥存储GitHub Secrets 中存储 Access Key无需存储任何密钥
泄露风险密钥泄露后需手动轮换令牌 1 小时自动过期
权限控制密钥权限 = IAM 用户权限通过 IAM Role 精确控制
审计能力难以区分不同 Job 的操作每次 AssumeRole 都有审计日志
配置复杂度低(仅需配置密钥)中(需配置 IAM 信任关系)

6.3 IAM 信任策略配置

在 AWS 端配置 IAM Role 的信任关系,允许 GitHub Actions 通过 OIDC 认证:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:lorock/sre.wang:ref:refs/heads/main"
        }
      }
    }
  ]
}

Condition 中的 sub 字段限制只有 lorock/sre.wang 仓库的 main 分支才能 AssumeRole,防止其他仓库冒用身份。这是 OIDC 安全模型的核心——通过信任策略精确控制"谁能在什么条件下扮演什么角色"

七、流水线设计好的实践

维度实践要点
触发控制paths 过滤避免无关变更触发 CI;concurrency 防止并发部署
Job 编排独立检查并行执行;有依赖关系的 Job 用 needs 串行
构建矩阵matrix 并行构建多平台/多版本产物
缓存优化优先使用 Action 内置缓存;手动缓存用 hashFiles 做缓存键
密钥安全敏感信息存 Secrets;云资源部署优先用 OIDC 无密钥认证
环境保护生产部署使用 environment 配置审批人和部署日志
产物传递Job 间用 upload-artifact / download-artifact 传递构建产物
失败快速把最可能失败的 Job(如 lint)放前面,用 needs 让后续 Job 依赖它
可观测性关键步骤加 name;用 if: always() 确保通知 Job 总是执行

八、完整目录结构参考

.github/
├── workflows/
│   ├── go-ci.yml              # Go 项目 CI 流水线
│   ├── hugo-deploy.yml        # Hugo 站点部署流水线
│   ├── release.yml             # 发布流水线(tag 触发)
│   └── scheduled-check.yml     # 定期安全扫描
└── actions/                    # 自定义 Composite Action(可选)
    └── setup-env/
        └── action.yml

CI/CD 流水线的核心价值不在于自动化本身,而在于建立信心——每次代码变更都经过统一的检查和验证,减少了"在我的机器上能跑"的不确定性。从本文的 Go CI 和 Hugo CD 两个实战出发,你可以根据自己项目的语言和部署目标,灵活组合触发器、Job 编排、缓存和密钥策略,构建出适合团队的流水线。

延伸阅读

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. GitHub Actions 官方文档 — GitHub 开源社区,参考了GitHub Actions 官方文档相关内容
  2. GitHub Marketplace — GitHub 开源社区,参考了GitHub Marketplace相关内容
  3. Actions 安全加固指南 — GitHub 开源社区,参考了Actions 安全加固指南相关内容
  4. OIDC 云部署安全配置 — GitHub 开源社区,参考了OIDC 云部署安全配置相关内容