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
| Concept | Description | Analogy |
|---|---|---|
| Workflow | A complete pipeline defined in a .yml file, placed in .github/workflows/ | A “pipeline template” |
| Job | An independent task unit within a Workflow, composed of multiple Steps; Jobs can run serially or in parallel | A “workstation” on the pipeline |
| Step | The smallest execution unit within a Job, can be a Shell command or an Action | An “operation step” at a workstation |
| Action | A reusable step unit, similar to a function call; find them at GitHub Marketplace | A reusable “function” |
| Runner | The server instance that executes Jobs, available as GitHub-hosted or self-hosted | The “worker” executing tasks |
Runner Type Selection
| Runner Type | Specs | Cost | Use Case |
|---|---|---|---|
| ubuntu-latest | 4 vCPU / 16GB / 14GB SSD | Free for public repos, per-minute billing for private repos | Most CI scenarios |
| macos-latest | 3 vCPU / 14GB / 14GB SSD | Higher cost (10x) | iOS/macOS builds |
| windows-latest | 4 vCPU / 16GB / 14GB SSD | Higher cost (2x) | Windows application builds |
| self-hosted | Custom | Self-funded hardware | Private 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]
pathsfiltering is key to reducing unnecessary CI runs. When only documentation changes, the Go test pipeline should not be triggered. Usepathsandpaths-ignorefor 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:
lintandtestrun in parallel to shorten pipeline durationbuildusesneeds: [lint, test]to ensure checks pass before buildingmatrixstrategy buildslinux/amd64andlinux/arm64in parallel, supporting ARM serversdocker/metadata-actionauto-generates semantic image tagscache-from/cache-to: type=ghaleverages 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 Point | Description |
|---|---|
| paths filtering | Only triggers on content/layout/config changes, avoiding unnecessary builds |
| concurrency | cancel-in-progress: false ensures deployments aren’t interrupted; only one runs at a time |
| artifact passing | build uploads artifacts, deploy downloads them — decoupling build from deployment |
| Dual deployment targets | Select Pages/VPS/both via workflow_dispatch input parameter |
| environment protection | environment: 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@v5has built-in caching — no need to manually configureactions/cache. It automatically uses thego.sumfile 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 Method | Use Case | Advantage | Limitation |
|---|---|---|---|
actions/setup-go cache | Go projects | Zero config, auto-managed | Go modules only |
actions/cache@v4 | General file caching | Flexible, supports any path | Requires manual cache key management |
cache-from: type=gha | Docker builds | Caches Docker layers | Size limit (10GB) |
actions/cache/restore | Partial restore | Doesn’t write cache on miss | Must 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.
hashFilesensures a new cache is created when dependencies change, whilerestore-keysprogressive 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:
| Rule | Description |
|---|---|
| Auto-masking | Secret values are automatically replaced with *** in logs |
| Environment isolation | Use environment-level Secrets for key isolation across environments |
| Organization-level sharing | Organization-level Secrets can be shared across multiple repos |
| Cannot be used in if | Secrets 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
| Dimension | Long-lived Keys | OIDC |
|---|---|---|
| Key storage | Access Key stored in GitHub Secrets | No keys stored at all |
| Leak risk | Manual rotation required after leak | Token auto-expires in 1 hour |
| Permission control | Key permissions = IAM user permissions | Precise control via IAM Role |
| Audit capability | Hard to distinguish between Jobs | Each AssumeRole has audit logs |
| Configuration complexity | Low (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
subfield inConditionrestricts AssumeRole to only thelorock/sre.wangrepository’smainbranch, 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
| Dimension | Key Points |
|---|---|
| Trigger control | Use paths filtering to avoid irrelevant changes triggering CI; concurrency to prevent concurrent deployments |
| Job orchestration | Run independent checks in parallel; use needs for dependent Jobs |
| Build matrix | Use matrix to parallel-build multi-platform/multi-version artifacts |
| Cache optimization | Prefer Action built-in caching; use hashFiles for manual cache keys |
| Secrets security | Store sensitive info in Secrets; prefer OIDC keyless auth for cloud deployments |
| Environment protection | Use environment for production deployments with approvers and deployment logs |
| Artifact passing | Use upload-artifact / download-artifact between Jobs |
| Fail fast | Put the most likely-to-fail Jobs (like lint) first; use needs to gate subsequent Jobs |
| Observability | Add 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:
- GitHub Actions Official Documentation — GitHub, referenced for GitHub Actions Official Documentation
- GitHub Marketplace — GitHub, referenced for GitHub Marketplace
- Actions Security Hardening Guide — GitHub, referenced for Actions Security Hardening Guide
- OIDC Cloud Deployment Security Configuration — GitHub, referenced for OIDC Cloud Deployment Security Configuration