Skip to content
Published on

GitHub Actions CI/CD Complete Guide: Go Builds, Branch Strategies, and Auto-Deploy

Authors

Part 1: GitHub Actions Architecture

1.1 Core Concepts

GitHub Actions is a CI/CD platform deeply integrated into GitHub that lets you automate build, test, and deployment workflows directly from your repository. Unlike external CI tools, it has native access to pull requests, issues, releases, and the entire GitHub ecosystem.

The architecture is built around five fundamental primitives:

  • Workflow -- A YAML file under .github/workflows/ that defines an automated process
  • Event -- A trigger that starts a workflow (push, pull_request, schedule, workflow_dispatch, etc.)
  • Job -- A set of steps that execute on the same runner, in parallel by default
  • Step -- An individual task within a job, either a shell command or an action
  • Runner -- The machine (virtual or physical) where a job executes
# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

concurrency:
  group: ci-BRANCH_REF
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - name: Run tests
        run: echo "Running tests..."

1.2 Event Types and Triggers

GitHub Actions supports over 35 event types. The most commonly used:

EventDescriptionUse Case
pushCode pushed to branchCI builds on main/develop
pull_requestPR opened/updated/closedCode review automation
scheduleCron-based triggerNightly builds, cleanup
workflow_dispatchManual triggerOn-demand deploys
releaseRelease publishedProduction deployments
repository_dispatchExternal webhookCross-repo triggers
workflow_callCalled by another workflowReusable workflows

Filtering events with paths and branches:

on:
  push:
    branches:
      - main
      - 'release/**'
    paths:
      - 'src/**'
      - 'go.mod'
      - 'go.sum'
    paths-ignore:
      - '**.md'
      - 'docs/**'

Schedule with cron expressions:

on:
  schedule:
    # Run at 2:00 AM UTC on weekdays
    - cron: '0 2 * * 1-5'

1.3 Concurrency Control

Concurrency groups prevent duplicate workflow runs, saving compute resources:

concurrency:
  group: deploy-production
  cancel-in-progress: false  # Don't cancel in-progress deploys

# For PR builds, cancel previous runs on the same PR
concurrency:
  group: pr-build-PR_NUMBER
  cancel-in-progress: true

1.4 Runner Types

RunnervCPURAMStorageCost (min)
ubuntu-latest416 GB14 GB SSD$0.008
ubuntu-latest-xl (4x)1664 GB150 GB SSD$0.032
macos-latest3 (M1)7 GB14 GB SSD$0.08
windows-latest27 GB14 GB SSD$0.016
self-hostedCustomCustomCustomFree (your infra)

1.5 Top 20 Essential Actions

Here are the most useful community and official actions:

# 1. Checkout code
- uses: actions/checkout@v4

# 2. Setup Go
- uses: actions/setup-go@v5
  with:
    go-version: '1.22'
    cache: true

# 3. Cache dependencies
- uses: actions/cache@v4
  with:
    path: ~/go/pkg/mod
    key: go-mod-HASH_OF_GO_SUM

# 4. Upload artifact
- uses: actions/upload-artifact@v4
  with:
    name: binary
    path: ./bin/

# 5. Download artifact
- uses: actions/download-artifact@v4

# 6. Docker build and push
- uses: docker/build-push-action@v6
  with:
    push: true
    tags: myapp:latest

# 7. Login to container registry
- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: GITHUB_ACTOR
    password: GITHUB_TOKEN_SECRET

# 8. Setup Docker Buildx
- uses: docker/setup-buildx-action@v3

# 9. GitHub release
- uses: softprops/action-gh-release@v2

# 10. Slack notification
- uses: slackapi/slack-github-action@v2

# 11. Setup Node.js
- uses: actions/setup-node@v4

# 12. Setup Python
- uses: actions/setup-python@v5

# 13. golangci-lint
- uses: golangci/golangci-lint-action@v6

# 14. CodeQL analysis
- uses: github/codeql-action/analyze@v3

# 15. Deploy to K8s
- uses: azure/k8s-deploy@v5

# 16. AWS credentials
- uses: aws-actions/configure-aws-credentials@v4

# 17. GCP auth
- uses: google-github-actions/auth@v2

# 18. Terraform
- uses: hashicorp/setup-terraform@v3

# 19. Create PR comment
- uses: peter-evans/create-or-update-comment@v4

# 20. Label PR
- uses: actions/labeler@v5

1.6 Secrets Management and OIDC

Repository and environment secrets:

Secrets are encrypted and only exposed to workflows. They are masked in logs automatically.

jobs:
  deploy:
    environment: production
    steps:
      - name: Deploy
        env:
          API_KEY: SECRETS_API_KEY_REF
          DB_PASSWORD: SECRETS_DB_PASSWORD_REF
        run: ./deploy.sh

Note: In the YAML above, SECRETS_API_KEY_REF and SECRETS_DB_PASSWORD_REF represent references to GitHub secrets configured in repository settings.

OIDC for cloud provider authentication (no static credentials):

jobs:
  deploy-aws:
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions
          aws-region: us-east-1

OIDC eliminates long-lived credentials by establishing a trust relationship between GitHub and your cloud provider. The workflow requests a short-lived token that is automatically scoped and rotated.

1.7 Environments and Deployment Protection

Environments allow you to define protection rules for deployments:

jobs:
  deploy-staging:
    environment: staging
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh staging

  deploy-production:
    needs: deploy-staging
    environment:
      name: production
      url: https://myapp.example.com
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh production

Environment protection rules include:

  • Required reviewers (up to 6 people)
  • Wait timer (delay before deployment proceeds)
  • Branch restrictions (only main can deploy to production)
  • Custom deployment branch policies

Part 2: Go Project CI/CD

2.1 Go Build Fundamentals

Go compiles to static binaries with no runtime dependencies, making it ideal for containerized deployments. Key build flags for production:

# Production build with all optimizations
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build \
  -ldflags="-s -w -X main.version=1.2.3 -X main.commit=abc123 -X main.buildTime=2026-03-23T10:00:00Z" \
  -trimpath \
  -o ./bin/myapp \
  ./cmd/server/

Build flags explained:

FlagPurpose
CGO_ENABLED=0Disable cgo for fully static binary
-ldflags="-s -w"Strip debug info, reduce binary size ~30%
-ldflags="-X main.version=..."Inject version info at build time
-trimpathRemove local filesystem paths from binary
-raceEnable race detector (testing only, not production)
-coverBuild with coverage instrumentation

2.2 Cross-Compilation Matrix

Go excels at cross-compilation. A single CI job can build for multiple platforms:

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        goos: [linux, darwin, windows]
        goarch: [amd64, arm64]
        exclude:
          - goos: windows
            goarch: arm64
      fail-fast: false

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Build
        env:
          GOOS: matrix-goos-value
          GOARCH: matrix-goarch-value
        run: |
          BINARY_NAME="myapp-matrix-goos-value-matrix-goarch-value"
          if [ "matrix-goos-value" = "windows" ]; then
            BINARY_NAME="myapp-matrix-goos-value-matrix-goarch-value.exe"
          fi
          go build -ldflags="-s -w" -trimpath -o "./dist/BINARY_NAME_VAR" ./cmd/server/

      - uses: actions/upload-artifact@v4
        with:
          name: binary-matrix-goos-value-matrix-goarch-value
          path: ./dist/

Note: In the matrix build above, matrix-goos-value and matrix-goarch-value represent the dynamically resolved values from the strategy matrix configuration.

2.3 Testing Strategy

A comprehensive Go testing pipeline includes unit tests, integration tests, and benchmarks:

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          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
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Unit tests with coverage
        run: |
          go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
          go tool cover -func=coverage.out

      - name: Integration tests
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb?sslmode=disable
        run: |
          go test -v -tags=integration -count=1 ./tests/integration/...

      - name: Benchmark tests
        run: |
          go test -bench=. -benchmem -run=^$ ./... | tee benchmark.txt

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage.out
          token: CODECOV_TOKEN_REF

2.4 Linting and Static Analysis

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

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

      - name: go vet
        run: go vet ./...

      - name: staticcheck
        uses: dominikh/staticcheck-action@v1
        with:
          version: '2024.1'

      - name: govulncheck
        run: |
          go install golang.org/x/vuln/cmd/govulncheck@latest
          govulncheck ./...

golangci-lint configuration (.golangci.yml):

run:
  timeout: 5m
  go: '1.22'

linters:
  enable:
    - errcheck
    - gosimple
    - govet
    - ineffassign
    - staticcheck
    - unused
    - bodyclose
    - gocritic
    - gofumpt
    - gosec
    - misspell
    - prealloc
    - revive
    - unconvert

linters-settings:
  gocritic:
    enabled-checks:
      - nestingReduce
      - truncateCmp
      - unnamedResult
  gosec:
    excludes:
      - G104 # Unhandled errors
  revive:
    rules:
      - name: unexported-return
        disabled: true

issues:
  exclude-rules:
    - path: _test\.go
      linters:
        - gosec
        - errcheck

2.5 Caching Strategies for Go

Effective caching can reduce Go build times by 60-80%:

steps:
  - uses: actions/setup-go@v5
    with:
      go-version: '1.22'
      cache: true # Built-in module cache

  # Additional build cache for faster compilation
  - uses: actions/cache@v4
    with:
      path: |
        ~/.cache/go-build
        ~/go/pkg/mod
      key: go-build-OS-HASH_OF_GO_SUM
      restore-keys: |
        go-build-OS-

Cache size comparison:

Cache TypeTypical SizeTime Saved
Go module cache100-500 MB30-60s
Go build cache200-800 MB45-120s
Docker layer cache500 MB-2 GB60-300s
golangci-lint cache50-200 MB20-40s

2.6 Multi-Stage Docker Build

# Stage 1: Build
FROM golang:1.22-alpine AS builder

RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /app

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download && go mod verify

# Build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build \
    -ldflags="-s -w -X main.version=VERSION_ARG -X main.commit=COMMIT_ARG" \
    -trimpath \
    -o /app/server \
    ./cmd/server/

# Stage 2: Runtime
FROM scratch

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /app/server /server

EXPOSE 8080

ENTRYPOINT ["/server"]

Note: In the Dockerfile above, VERSION_ARG and COMMIT_ARG are placeholder values to be replaced by your CI pipeline with the actual version and commit hash.

Docker build workflow:

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: GITHUB_ACTOR_REF
          password: GITHUB_TOKEN_REF

      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ghcr.io/OWNER/myapp
          tags: |
            type=sha
            type=ref,event=branch
            type=semver,pattern=v{{version}}
            type=semver,pattern=v{{major}}.{{minor}}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: steps-meta-outputs-tags
          labels: steps-meta-outputs-labels
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            VERSION=v1.2.3
            COMMIT=GITHUB_SHA_REF

2.7 GoReleaser Integration

GoReleaser automates the entire release process: building, packaging, and publishing.

# .goreleaser.yml
version: 2

before:
  hooks:
    - go mod tidy
    - go generate ./...

builds:
  - id: server
    main: ./cmd/server/
    binary: myapp
    env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    ldflags:
      - -s -w
      - -X main.version={{.Version}}
      - -X main.commit={{.Commit}}
      - -X main.date={{.Date}}

archives:
  - format: tar.gz
    name_template: 'myapp_{{.Version}}_{{.Os}}_{{.Arch}}'
    format_overrides:
      - goos: windows
        format: zip

dockers:
  - image_templates:
      - 'ghcr.io/OWNER/myapp:{{.Version}}'
      - 'ghcr.io/OWNER/myapp:latest'
    dockerfile: Dockerfile.goreleaser
    build_flag_templates:
      - '--label=org.opencontainers.image.version={{.Version}}'

changelog:
  sort: asc
  filters:
    exclude:
      - '^docs:'
      - '^test:'
      - '^ci:'

release:
  github:
    owner: myorg
    name: myapp

Workflow for GoReleaser:

name: Release
on:
  push:
    tags:
      - 'v*'

permissions:
  contents: write
  packages: write

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - uses: goreleaser/goreleaser-action@v6
        with:
          version: '~> v2'
          args: release --clean
        env:
          GITHUB_TOKEN: GITHUB_TOKEN_SECRET_REF

Part 3: Branch Strategies

3.1 Git Flow

Git Flow is a comprehensive branching model well-suited for projects with scheduled releases:

Branches:

  • main -- Production-ready code, tagged with version numbers
  • develop -- Integration branch for features
  • feature/xxx -- Short-lived branches for new features
  • release/x.y.z -- Release preparation and stabilization
  • hotfix/xxx -- Emergency production fixes

Flow:

  1. Create feature branch from develop
  2. Develop and test the feature
  3. Merge feature into develop via PR
  4. Create release branch from develop
  5. Stabilize, bump version, merge to main AND develop
  6. Tag the main branch with version
  7. For hotfixes: branch from main, fix, merge to main AND develop

Pros: Clear structure, parallel development, release management Cons: Complex, many branches, slow for fast-moving projects

3.2 GitHub Flow

GitHub Flow is a simplified model focused on continuous delivery:

Branches:

  • main -- Always deployable
  • feature/xxx -- All work happens in feature branches

Flow:

  1. Branch from main
  2. Add commits
  3. Open a Pull Request
  4. Code review and discussion
  5. Deploy and test (optionally to staging)
  6. Merge to main

Pros: Simple, fast, encourages small PRs Cons: No release management, everything goes to main

3.3 Trunk-Based Development

Trunk-Based Development minimizes branch lifetime and emphasizes continuous integration:

Branches:

  • main (trunk) -- Everyone commits here, always releasable
  • Short-lived feature branches (less than 1-2 days)
  • Optional release branches (for cherry-picking fixes)

Flow:

  1. Pull latest from main
  2. Make small, incremental changes
  3. Run all tests locally
  4. Push directly to main (or very short-lived PR)
  5. CI validates immediately
  6. Deploy continuously via feature flags

Pros: Fastest feedback, minimal merge conflicts, simplest model Cons: Requires strong CI, feature flags, disciplined team

3.4 Strategy Comparison

AspectGit FlowGitHub FlowTrunk-Based
ComplexityHighLowVery Low
Branch lifetimeDays to weeksHours to daysHours
Release cadenceScheduledContinuousContinuous
Team sizeLargeSmall to mediumAny size
CI/CD maturity requiredLowMediumHigh
Feature flags neededNoOptionalYes
Merge conflictsFrequentOccasionalRare
Best forEnterprise, versionedSaaS, web appsHigh-performance
Deploy frequencyWeekly/monthlyDailyMultiple per day

3.5 Branch Protection Rules

# Branch protection via GitHub API / Settings

# Main branch protection
branches:
  main:
    protection:
      required_status_checks:
        strict: true
        contexts:
          - 'ci/lint'
          - 'ci/test'
          - 'ci/build'
      required_pull_request_reviews:
        required_approving_review_count: 2
        dismiss_stale_reviews: true
        require_code_owner_reviews: true
      restrictions:
        users: []
        teams: ['core-maintainers']
      enforce_admins: true
      required_linear_history: true
      allow_force_pushes: false
      allow_deletions: false

3.6 CODEOWNERS

The CODEOWNERS file defines who is automatically requested for review:

# .github/CODEOWNERS

# Default owners for everything
* @myorg/backend-team

# Go source code
/cmd/ @myorg/go-team
/internal/ @myorg/go-team
/pkg/ @myorg/go-team

# Infrastructure
/.github/ @myorg/devops-team
/deploy/ @myorg/devops-team
/terraform/ @myorg/devops-team
Dockerfile @myorg/devops-team

# Documentation
/docs/ @myorg/docs-team
*.md @myorg/docs-team

# API definitions
/api/ @myorg/api-team @myorg/backend-team

3.7 Repository Rulesets

Rulesets (introduced in 2023) provide more flexible branch protection than traditional branch protection rules:

  • Apply to multiple branches/tags via pattern matching
  • Layered rules (organization + repository level)
  • Bypass permissions for specific roles
  • Tag protection
  • Push restrictions with verified signatures
  • Metadata restrictions (commit message format, author email)
Ruleset configuration example:
  Name: production-protection
  Target: branches matching "main" and "release/**"
  Rules:
    - Require pull request before merge (2 approvals)
    - Require status checks: lint, test, build
    - Require signed commits
    - Block force pushes
    - Require linear history
  Bypass: Repository admins during emergencies

Part 4: Full Pipeline -- Go Microservice

4.1 Project Structure

myapp/
  cmd/
    server/
      main.go
  internal/
    handler/
    service/
    repository/
  pkg/
    middleware/
  api/
    openapi.yaml
  deploy/
    k8s/
      deployment.yaml
      service.yaml
      ingress.yaml
    docker/
      Dockerfile
  .github/
    workflows/
      ci.yml
      deploy.yml
      release.yml
    CODEOWNERS
  .golangci.yml
  .goreleaser.yml
  go.mod
  go.sum
  Makefile

4.2 Complete CI Workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
  pull_request:
    branches: [main]

concurrency:
  group: ci-REF_NAME
  cancel-in-progress: true

env:
  GO_VERSION: '1.22'
  GOLANGCI_LINT_VERSION: v1.57
  REGISTRY: ghcr.io
  IMAGE_NAME: myorg/myapp

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: GO_VERSION_ENV

      - uses: golangci/golangci-lint-action@v6
        with:
          version: GOLANGCI_LINT_VERSION_ENV

      - name: Check formatting
        run: |
          if [ -n "$(gofmt -l .)" ]; then
            echo "Files not formatted:"
            gofmt -l .
            exit 1
          fi

      - name: Verify dependencies
        run: |
          go mod verify
          go mod tidy
          git diff --exit-code go.mod go.sum

  test:
    name: Test
    runs-on: ubuntu-latest
    needs: lint
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: GO_VERSION_ENV

      - name: Unit tests
        run: |
          go test -v -race -count=1 \
            -coverprofile=coverage.out \
            -covermode=atomic \
            ./...

      - name: Integration tests
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/testdb?sslmode=disable
          REDIS_URL: redis://localhost:6379
        run: |
          go test -v -tags=integration -count=1 ./tests/integration/...

      - name: Coverage report
        run: |
          COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}')
          echo "Total coverage: COVERAGE_VAR"
          # Fail if coverage drops below threshold
          COVERAGE_NUM=$(echo "COVERAGE_VAR" | tr -d '%')
          if (( $(echo "COVERAGE_NUM_VAR < 70" | bc -l) )); then
            echo "Coverage below 70% threshold"
            exit 1
          fi

      - uses: codecov/codecov-action@v4
        with:
          files: ./coverage.out

  security:
    name: Security Scan
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: GO_VERSION_ENV

      - name: Vulnerability check
        run: |
          go install golang.org/x/vuln/cmd/govulncheck@latest
          govulncheck ./...

      - name: License check
        run: |
          go install github.com/google/go-licenses@latest
          go-licenses check ./...

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [test, security]
    outputs:
      image-tag: steps-meta-outputs-version
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: GITHUB_ACTOR_REF
          password: GITHUB_TOKEN_REF

      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: REGISTRY_ENV/IMAGE_NAME_ENV
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=ref,event=pr

      - uses: docker/build-push-action@v6
        with:
          context: .
          file: ./deploy/docker/Dockerfile
          push: PUSH_CONDITION
          tags: steps-meta-outputs-tags
          labels: steps-meta-outputs-labels
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            VERSION=GITHUB_SHA_SHORT
            COMMIT=GITHUB_SHA_REF

Note: References like GO_VERSION_ENV represent environment variable references, and PUSH_CONDITION represents conditional logic (e.g., push only on main branch, not on PRs).

4.3 Deployment Workflow

# .github/workflows/deploy.yml
name: Deploy

on:
  workflow_run:
    workflows: ['CI']
    branches: [main]
    types: [completed]

concurrency:
  group: deploy-ENVIRONMENT
  cancel-in-progress: false

jobs:
  deploy-staging:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.myapp.example.com
    steps:
      - uses: actions/checkout@v4

      - name: Configure kubectl
        uses: azure/setup-kubectl@v4

      - name: Set K8s context
        uses: azure/k8s-set-context@v4
        with:
          method: kubeconfig
          kubeconfig: SECRETS_KUBECONFIG_STAGING_REF

      - name: Deploy to staging
        run: |
          kubectl set image deployment/myapp \
            myapp=ghcr.io/myorg/myapp:COMMIT_SHA \
            -n staging
          kubectl rollout status deployment/myapp -n staging --timeout=300s

      - name: Run smoke tests
        run: |
          sleep 10
          curl -sf https://staging.myapp.example.com/healthz || exit 1

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://myapp.example.com
    steps:
      - uses: actions/checkout@v4

      - name: Configure kubectl
        uses: azure/setup-kubectl@v4

      - name: Set K8s context
        uses: azure/k8s-set-context@v4
        with:
          method: kubeconfig
          kubeconfig: SECRETS_KUBECONFIG_PRODUCTION_REF

      - name: Deploy to production
        run: |
          kubectl set image deployment/myapp \
            myapp=ghcr.io/myorg/myapp:COMMIT_SHA \
            -n production
          kubectl rollout status deployment/myapp -n production --timeout=300s

      - name: Verify deployment
        run: |
          curl -sf https://myapp.example.com/healthz || exit 1

      - name: Notify on failure
        if: failure()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: SECRETS_SLACK_WEBHOOK_REF
          webhook-type: incoming-webhook
          payload: |
            {
              "text": "Production deployment FAILED for commit COMMIT_SHA_SHORT"
            }

4.4 Kubernetes Manifests

# deploy/k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  labels:
    app: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: ghcr.io/myorg/myapp:latest
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          env:
            - name: LOG_LEVEL
              value: 'info'
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: myapp-secrets
                  key: database-url
# deploy/k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: myapp
spec:
  type: ClusterIP
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 8080
      protocol: TCP

4.5 Reusable Workflows

Create reusable workflows to share CI/CD logic across repositories:

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

on:
  workflow_call:
    inputs:
      go-version:
        description: 'Go version to use'
        required: false
        type: string
        default: '1.22'
      coverage-threshold:
        description: 'Minimum coverage percentage'
        required: false
        type: number
        default: 70
    secrets:
      CODECOV_TOKEN:
        required: false

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: inputs-go-version
      - run: go test -race -coverprofile=coverage.out ./...
      - run: go vet ./...

Calling the reusable workflow:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  go-ci:
    uses: myorg/.github/.github/workflows/reusable-go-ci.yml@main
    with:
      go-version: '1.22'
      coverage-threshold: 80
    secrets:
      CODECOV_TOKEN: SECRETS_CODECOV_TOKEN_REF

4.6 Path-Based Triggering

For monorepos, trigger workflows only when relevant files change:

on:
  push:
    paths:
      - 'services/api/**'
      - 'shared/pkg/**'
      - 'go.mod'
      - 'go.sum'
    paths-ignore:
      - '**.md'
      - 'docs/**'

Detecting changes in a monorepo:

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      api: steps-filter-outputs-api
      web: steps-filter-outputs-web
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            api:
              - 'services/api/**'
              - 'shared/**'
            web:
              - 'services/web/**'
              - 'shared/**'

  build-api:
    needs: changes
    if: needs.changes.outputs.api == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building API service"

  build-web:
    needs: changes
    if: needs.changes.outputs.web == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building Web service"

4.7 Rollback Strategy

# .github/workflows/rollback.yml
name: Rollback

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options:
          - staging
          - production
      revision:
        description: 'Revision to rollback to (leave empty for previous)'
        required: false
        type: string

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: inputs-environment
    steps:
      - name: Configure kubectl
        uses: azure/setup-kubectl@v4

      - name: Rollback deployment
        run: |
          if [ -z "REVISION_INPUT" ]; then
            kubectl rollout undo deployment/myapp -n TARGET_ENV
          else
            kubectl rollout undo deployment/myapp \
              --to-revision=REVISION_INPUT -n TARGET_ENV
          fi
          kubectl rollout status deployment/myapp -n TARGET_ENV --timeout=300s

      - name: Verify rollback
        run: |
          curl -sf https://TARGET_ENV.myapp.example.com/healthz || exit 1

      - name: Notify
        uses: slackapi/slack-github-action@v2
        with:
          webhook: SECRETS_SLACK_WEBHOOK_REF
          webhook-type: incoming-webhook
          payload: |
            {
              "text": "Rollback completed for TARGET_ENV environment"
            }

Part 5: Performance and Cost Optimization

5.1 Caching Strategies Deep Dive

Layer 1: Go module cache (actions/setup-go built-in)

The setup-go action automatically caches the Go module directory. No additional configuration needed.

Layer 2: Go build cache

- uses: actions/cache@v4
  with:
    path: ~/.cache/go-build
    key: go-build-OS-HASH_OF_ALL_GO_FILES
    restore-keys: |
      go-build-OS-

Layer 3: Docker layer cache with GitHub Actions cache backend

- uses: docker/build-push-action@v6
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

Layer 4: golangci-lint cache

golangci-lint-action handles its own caching automatically. The cache key is based on the Go version and golangci-lint configuration.

Cache effectiveness monitoring:

Track cache hit rates in your workflow logs. A healthy pipeline should see 80%+ cache hit rates for module and build caches.

5.2 Parallel Job Execution

Structure your workflow DAG for maximum parallelism:

  lint ------+
             |
  test ------+--> build --> deploy-staging --> deploy-production
             |
  security --+
jobs:
  lint:
    runs-on: ubuntu-latest
    # No dependencies, runs immediately

  test:
    runs-on: ubuntu-latest
    # No dependencies, runs in parallel with lint

  security:
    runs-on: ubuntu-latest
    # No dependencies, runs in parallel with lint and test

  build:
    needs: [lint, test, security]
    # Waits for all three to complete

  deploy-staging:
    needs: build

  deploy-production:
    needs: deploy-staging

5.3 Self-Hosted Runners

For cost-heavy or specialized workloads, self-hosted runners eliminate per-minute billing:

jobs:
  build:
    runs-on: [self-hosted, linux, x64, gpu]
    steps:
      - uses: actions/checkout@v4
      - run: nvidia-smi # GPU access on self-hosted

When to use self-hosted runners:

ScenarioRecommendation
Build time under 10 minGitHub-hosted
Build time 10-30 minConsider self-hosted
Build time over 30 minSelf-hosted strongly recommended
GPU/specialized hardwareSelf-hosted (required)
Compliance (data locality)Self-hosted (required)
Network access to internal resourcesSelf-hosted (required)

Self-hosted runner setup with Docker:

# docker-compose.yml for self-hosted runner
version: '3.8'
services:
  runner:
    image: myoung34/github-runner:latest
    environment:
      REPO_URL: https://github.com/myorg/myrepo
      RUNNER_TOKEN: your-registration-token
      RUNNER_NAME: custom-runner-1
      RUNNER_WORKDIR: /tmp/github-runner
      LABELS: self-hosted,linux,x64
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    restart: unless-stopped

5.4 Billing and Cost Calculation

GitHub Actions pricing (as of 2026):

PlanIncluded MinutesStoragePrice/Extra Min (Linux)
Free2,000/month500 MBN/A
Team3,000/month2 GB$0.008
Enterprise50,000/month50 GB$0.008

OS multipliers:

OSMultiplier
Linux1x
Windows2x
macOS10x

Cost optimization tips:

  1. Cancel redundant workflow runs with concurrency groups
  2. Use path filters to skip unnecessary builds
  3. Maximize caching (module, build, Docker layers)
  4. Avoid macOS runners unless absolutely necessary (10x cost)
  5. Use matrix strategy wisely -- each matrix entry is a separate job
  6. Self-host for high-volume repositories
  7. Use timeout-minutes to prevent runaway workflows
  8. Combine small jobs to reduce job startup overhead (~15-30s per job)

5.5 Monitoring Workflow Performance

Track key metrics to identify bottlenecks:

# Add timing to key steps
- name: Build with timing
  run: |
    START_TIME=$(date +%s)
    go build -o ./bin/myapp ./cmd/server/
    END_TIME=$(date +%s)
    DURATION=$((END_TIME - START_TIME))
    echo "Build took DURATION_VAR seconds"
    echo "build_duration=DURATION_VAR" >> GITHUB_OUTPUT_FILE

Key metrics to track:

  • Total workflow duration
  • Individual job and step duration
  • Cache hit/miss rates
  • Queue wait time (time before runner picks up job)
  • Failure rate by job type
  • Cost per workflow run

5.6 Local Testing with act

Test GitHub Actions locally before pushing:

# Install act
brew install act

# Run default event (push)
act

# Run specific workflow
act -W .github/workflows/ci.yml

# Run specific job
act -j test

# Use specific event
act pull_request

# Pass secrets
act -s GITHUB_TOKEN=your-token

# Use larger runner image
act -P ubuntu-latest=catthehacker/ubuntu:full-latest

Limitations of act:

  • No services support (need to mock databases)
  • Some GitHub context variables may not be available
  • Docker-in-Docker can be problematic
  • Some actions may not work identically to GitHub-hosted runners

Quiz

Q1: What is the difference between needs and uses in a GitHub Actions workflow?

Answer: needs defines job dependencies -- it specifies which jobs must complete successfully before the current job runs. For example, needs: [lint, test] means the job waits for both lint and test to finish. uses references an action or reusable workflow. In a step, uses: actions/checkout@v4 runs the checkout action. At the job level, uses: org/repo/.github/workflows/ci.yml@main calls a reusable workflow.

Q2: Why is CGO_ENABLED=0 important for Go Docker builds?

Answer: Setting CGO_ENABLED=0 produces a fully statically-linked binary with no C library dependencies. This is critical when using minimal base images like scratch or distroless that do not include glibc. Without CGO_ENABLED=0, the binary would dynamically link against libc and fail to run in these minimal containers. It also makes cross-compilation straightforward since you do not need a C cross-compiler toolchain.

Q3: When should you choose Trunk-Based Development over Git Flow?

Answer: Trunk-Based Development is ideal when you need rapid iteration with multiple deployments per day, your team has strong CI/CD practices with automated testing, and you can use feature flags to manage incomplete features. Git Flow is better when you need formal release management, support multiple versions simultaneously, or have less mature CI/CD infrastructure. The key decision factor is deployment frequency: if you deploy multiple times daily, Trunk-Based is superior. If you release monthly with version numbers, Git Flow provides the structure you need.

Q4: How does OIDC improve security over traditional secrets for cloud provider authentication?

Answer: OIDC (OpenID Connect) eliminates long-lived static credentials (access keys) by establishing a federated trust relationship between GitHub and your cloud provider. Instead of storing permanent access keys as repository secrets, workflows request short-lived tokens that are automatically scoped to specific repositories, branches, and environments. These tokens expire within minutes, reducing the blast radius if compromised. OIDC also provides an audit trail of which workflow requested which permissions, and there are no credentials to rotate or accidentally leak.

Q5: What caching strategy provides the best cost-to-performance ratio for Go CI pipelines?

Answer: The most impactful caching layers in order of effectiveness are: (1) Go module cache via actions/setup-go built-in caching -- this eliminates dependency downloads and typically saves 30-60 seconds. (2) Go build cache stored via actions/cache -- this avoids recompiling unchanged packages and saves 45-120 seconds. (3) Docker layer cache using GitHub Actions cache backend (type=gha) -- this avoids rebuilding unchanged Docker layers and saves 60-300 seconds. (4) golangci-lint cache (handled automatically by the action). Combined, these can reduce a 10-minute pipeline to under 3 minutes. The module cache has the best cost-to-performance ratio since it is free (built into setup-go) and saves the most time relative to cache size.


References

  1. GitHub Actions Documentation -- https://docs.github.com/en/actions
  2. GitHub Actions Workflow Syntax -- https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
  3. GitHub Actions Contexts and Expressions -- https://docs.github.com/en/actions/learn-github-actions/contexts
  4. GitHub Actions Environments -- https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment
  5. GitHub OIDC with Cloud Providers -- https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
  6. Go Build Documentation -- https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies
  7. Go Cross-Compilation -- https://go.dev/doc/install/source#environment
  8. golangci-lint -- https://golangci-lint.run/
  9. GoReleaser Documentation -- https://goreleaser.com/
  10. Docker Multi-Stage Builds -- https://docs.docker.com/build/building/multi-stage/
  11. Docker Build Push Action -- https://github.com/docker/build-push-action
  12. Git Flow by Vincent Driessen -- https://nvie.com/posts/a-successful-git-branching-model/
  13. GitHub Flow -- https://docs.github.com/en/get-started/using-github/github-flow
  14. Trunk-Based Development -- https://trunkbaseddevelopment.com/
  15. Kubernetes Deployment Strategies -- https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
  16. GitHub Actions Caching -- https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows
  17. GitHub Actions Self-Hosted Runners -- https://docs.github.com/en/actions/hosting-your-own-runners
  18. GitHub Actions Reusable Workflows -- https://docs.github.com/en/actions/using-workflows/reusing-workflows
  19. GitHub Repository Rulesets -- https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets
  20. GitHub Actions Billing -- https://docs.github.com/en/billing/managing-billing-for-github-actions
  21. act - Run GitHub Actions Locally -- https://github.com/nektos/act
  22. CODEOWNERS Documentation -- https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
  23. Codecov GitHub Action -- https://github.com/codecov/codecov-action
  24. dorny/paths-filter -- https://github.com/dorny/paths-filter