CI/CD is the lifeblood of modern software delivery. Manual builds and deployments are not only inefficient but also breeding grounds for incidents — the “works on my machine” tragedy almost always stems from a lack of automated pipelines. As GitHub’s native CI/CD platform, GitHub Actions integrates seamlessly with code repositories, offers generous free tiers for open-source projects, and has become one of the most popular CI/CD tools. This article starts from core concepts and walks through two practical scenarios — a Go project and a Hugo site — to comprehensively explain pipeline design.

Reference: GitHub Actions Official Documentation

I. GitHub Actions Core Concepts

GitHub Actions’ architecture revolves around five concepts. Understanding their relationships is the foundation for pipeline design:

Workflow
  
  ├── Job A
       ├── Step 1  Action: checkout code
       ├── Step 2  Action: setup Go environment
       └── Step 3  Shell: go test ./...
  
  └── Job B
        ├── Step 1  Action: download build artifact
        └── Step 2  Shell: deploy to server
ConceptDescriptionAnalogy
WorkflowA complete pipeline defined in a .yml file, placed in .github/workflows/A “pipeline template”
JobAn independent task unit within a Workflow, composed of multiple Steps; Jobs can run serially or in parallelA “workstation” on the pipeline
StepThe smallest execution unit within a Job, can be a Shell command or an ActionAn “operation step” at a workstation
ActionA reusable step unit, similar to a function call; find them at GitHub MarketplaceA reusable “function”
RunnerThe server instance that executes Jobs, available as GitHub-hosted or self-hostedThe “worker” executing tasks

Runner Type Selection

Runner TypeSpecsCostUse Case
ubuntu-latest4 vCPU / 16GB / 14GB SSDFree for public repos, per-minute billing for private reposMost CI scenarios
macos-latest3 vCPU / 14GB / 14GB SSDHigher cost (10x)iOS/macOS builds
windows-latest4 vCPU / 16GB / 14GB SSDHigher cost (2x)Windows application builds
self-hostedCustomSelf-funded hardwarePrivate environments, special dependencies, GPU

II. Trigger Mechanisms

Triggers determine when a Workflow executes. GitHub Actions supports a rich set of trigger types:

name: CI

# ── Multiple trigger conditions (any one triggers) ──
on:
  # 1. Code push
  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. Scheduled task (Cron syntax, UTC timezone)
  schedule:
    # Run daily at 08:00 Beijing time (UTC 00:00)
    - cron: '0 0 * * *'

  # 4. Manual trigger
  workflow_dispatch:
    inputs:
      environment:
        description: 'Deployment environment'
        required: true
        type: choice
        options:
          - staging
          - production
      debug_mode:
        description: 'Enable debugging'
        required: false
        type: boolean
        default: false

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

paths filtering is key to reducing unnecessary CI runs. When only documentation changes, the Go test pipeline should not be triggered. Use paths and paths-ignore for precise control.

III. Practice 1: Go Project CI Pipeline

This is a production-grade Go project CI pipeline covering lint, test, build, and image push:

# .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: Code linting
  # ──────────────────────────────────────────
  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: Unit tests + coverage
  # ──────────────────────────────────────────
  test:
    name: Test
    runs-on: ubuntu-latest
    services:
      # Start PostgreSQL container for testing
      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: Multi-platform build + image push
  # ──────────────────────────────────────────
  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

Pipeline Design Highlights

push / pull_request
    ┌─────────┐     ┌─────────┐
    │  Lint   │     │  Test   │   ← Run in parallel, non-blocking
    └────┬────┘     └────┬────┘
         │               │
         └──────┬────────┘
                │  needs: [lint, test]
         ┌─────────────┐
         │   Build     │     ← Matrix parallel build for amd64 + arm64
         │  (x2 jobs)  │
         └─────────────┘
         Push image to GHCR

Key design decisions:

  • lint and test run in parallel to shorten pipeline duration
  • build uses needs: [lint, test] to ensure checks pass before building
  • matrix strategy builds linux/amd64 and linux/arm64 in parallel, supporting ARM servers
  • docker/metadata-action auto-generates semantic image tags
  • cache-from/cache-to: type=gha leverages GitHub Actions cache to accelerate Docker builds

IV. Practice 2: Hugo Site CD Pipeline

The SRE learning notes site where this article lives is built with Hugo. Below is a complete Hugo site deployment pipeline supporting both GitHub Pages and VPS deployment targets:

# .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: 'Deployment target'
        required: true
        type: choice
        options:
          - github-pages
          - vps
          - both

# Set Workflow-level permissions
permissions:
  contents: read
  pages: write
  id-token: write

# Only allow one deployment at a time
concurrency:
  group: pages
  cancel-in-progress: false

env:
  HUGO_VERSION: '0.129.0'

jobs:
  # ──────────────────────────────────────────
  # Job 1: Build Hugo site
  # ──────────────────────────────────────────
  build:
    name: Build Hugo Site
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: true        # Fetch Hugo theme submodule
          fetch-depth: 0          # Get full Git history (enables lastmod)

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: ${{ env.HUGO_VERSION }}
          extended: true           # PaperMod theme requires extended version

      - 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: Deploy to 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: Deploy to VPS (via 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 Pipeline Design Highlights

Design PointDescription
paths filteringOnly triggers on content/layout/config changes, avoiding unnecessary builds
concurrencycancel-in-progress: false ensures deployments aren’t interrupted; only one runs at a time
artifact passingbuild uploads artifacts, deploy downloads them — decoupling build from deployment
Dual deployment targetsSelect Pages/VPS/both via workflow_dispatch input parameter
environment protectionenvironment: github-pages enables approvers, environment variables, and deployment logs

V. Cache Strategy: Accelerating Builds

Caching is the most effective way to reduce CI duration. GitHub Actions provides actions/cache and various built-in caching mechanisms:

5.1 Go Module Cache

- uses: actions/setup-go@v5
  with:
    go-version: '1.22'
    cache: true    # setup-go built-in cache, auto-caches ~/go/pkg/mod and ~/.cache/go-build

actions/setup-go@v5 has built-in caching — no need to manually configure actions/cache. It automatically uses the go.sum file hash as the cache key.

5.2 General Cache Pattern

- 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 Cache Strategy Comparison

Cache MethodUse CaseAdvantageLimitation
actions/setup-go cacheGo projectsZero config, auto-managedGo modules only
actions/cache@v4General file cachingFlexible, supports any pathRequires manual cache key management
cache-from: type=ghaDocker buildsCaches Docker layersSize limit (10GB)
actions/cache/restorePartial restoreDoesn’t write cache on missMust be paired with actions/cache/save

Cache Key Design Principles

# Three-tier cache key design: exact match → partial match → system-level fallback
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}       # Exact: new cache when dependency files change
restore-keys: |
  ${{ runner.os }}-go-                                        # Partial: restore most cache even if deps changed
  ${{ runner.os }}-                                           # Fallback: at least restore system-level cache  

Cache key design directly impacts hit rate. hashFiles ensures a new cache is created when dependencies change, while restore-keys progressive matching ensures that even when the exact key misses, most cache content is still restored.

VI. Secrets Management: GitHub Secrets and OIDC

6.1 GitHub Secrets

GitHub Secrets is the standard way to store sensitive information. Add them in the repository’s Settings → Secrets and variables → Actions:

steps:
  - name: Deploy to production
    env:
      DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}     # Database password
      API_KEY: ${{ secrets.API_KEY }}                   # Third-party API key
      DEPLOY_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}        # SSH private key
    run: ./deploy.sh

Secrets usage rules:

RuleDescription
Auto-maskingSecret values are automatically replaced with *** in logs
Environment isolationUse environment-level Secrets for key isolation across environments
Organization-level sharingOrganization-level Secrets can be shared across multiple repos
Cannot be used in ifSecrets cannot be used in if conditionals (for security reasons)

6.2 OIDC Keyless Authentication

Traditionally, CI requires long-lived cloud provider Access Keys to deploy resources. If these keys leak, an attacker can fully compromise your cloud account. OIDC (OpenID Connect) replaces long-lived keys with short-lived tokens — a more secure approach.

# Using OIDC in Go CI to push images to AWS ECR
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write     # Must be enabled to obtain OIDC tokens
      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 Long-lived Keys:

Traditional (long-lived keys):
  GitHub Secrets → AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
  ┌──────────┐         ┌──────────┐
  │  CI Job   │ ──key──→│   AWS    │   Key leak = account compromise
  └──────────┘         └──────────┘

OIDC (short-lived tokens):
  ┌──────────┐  OIDC Token  ┌──────────┐  AssumeRole  ┌──────────┐
  │  CI Job   │ ──────────→ │  AWS IAM  │ ───────────→ │   AWS    │
  └──────────┘  (JWT, 1h)   └──────────┘  (temp creds)  └──────────┘
  No long-lived keys stored    Trust pre-configured       Token expires in 1 hour
DimensionLong-lived KeysOIDC
Key storageAccess Key stored in GitHub SecretsNo keys stored at all
Leak riskManual rotation required after leakToken auto-expires in 1 hour
Permission controlKey permissions = IAM user permissionsPrecise control via IAM Role
Audit capabilityHard to distinguish between JobsEach AssumeRole has audit logs
Configuration complexityLow (just configure keys)Medium (requires IAM trust relationship)

6.3 IAM Trust Policy Configuration

Configure the IAM Role trust relationship on the AWS side to allow GitHub Actions to authenticate via 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"
        }
      }
    }
  ]
}

The sub field in Condition restricts AssumeRole to only the lorock/sre.wang repository’s main branch, preventing identity spoofing from other repos. This is the core of the OIDC security model — precisely controlling “who can assume what role under what conditions” through trust policies.

VII. Pipeline Design Best Practices

DimensionKey Points
Trigger controlUse paths filtering to avoid irrelevant changes triggering CI; concurrency to prevent concurrent deployments
Job orchestrationRun independent checks in parallel; use needs for dependent Jobs
Build matrixUse matrix to parallel-build multi-platform/multi-version artifacts
Cache optimizationPrefer Action built-in caching; use hashFiles for manual cache keys
Secrets securityStore sensitive info in Secrets; prefer OIDC keyless auth for cloud deployments
Environment protectionUse environment for production deployments with approvers and deployment logs
Artifact passingUse upload-artifact / download-artifact between Jobs
Fail fastPut the most likely-to-fail Jobs (like lint) first; use needs to gate subsequent Jobs
ObservabilityAdd name to key steps; use if: always() to ensure notification Jobs always run

VIII. Complete Directory Structure Reference

.github/
├── workflows/
│   ├── go-ci.yml              # Go project CI pipeline
│   ├── hugo-deploy.yml        # Hugo site deployment pipeline
│   ├── release.yml             # Release pipeline (tag-triggered)
│   └── scheduled-check.yml     # Scheduled security scan
└── actions/                    # Custom Composite Actions (optional)
    └── setup-env/
        └── action.yml

The core value of CI/CD pipelines lies not in automation itself, but in building confidence — every code change goes through unified checks and verification, reducing the “works on my machine” uncertainty. Starting from the Go CI and Hugo CD examples in this article, you can flexibly combine triggers, Job orchestration, caching, and secrets strategies based on your project’s language and deployment targets to build pipelines that fit your team.

Further Reading:

References & Acknowledgments

This article referenced the following materials during writing. We thank the original authors for their contributions:

  1. GitHub Actions Official Documentation — GitHub, referenced for GitHub Actions Official Documentation
  2. GitHub Marketplace — GitHub, referenced for GitHub Marketplace
  3. Actions Security Hardening Guide — GitHub, referenced for Actions Security Hardening Guide
  4. OIDC Cloud Deployment Security Configuration — GitHub, referenced for OIDC Cloud Deployment Security Configuration