Logo
Logo

Atharva Pandey/Lesson 5: CI/CD for Go — GitHub Actions that actually catch bugs

Created Sun, 15 Dec 2024 00:00:00 +0000 Modified Sun, 15 Dec 2024 00:00:00 +0000

I’ve set up CI pipelines for Go services about a dozen times, and every iteration taught me something about what the pipeline should actually catch. The first version ran go test ./... and called it done. That caught compilation errors and test failures, but not data races, not staticcheck warnings, not formatting drift, not license issues, not security vulnerabilities in dependencies. Each of those failure categories has caused a production incident at some point. The current version catches most of them before the PR is merged.

A CI pipeline for Go has a natural order of operations: fast checks first, slow checks later, expensive checks only when strictly necessary. You want the feedback loop to be as tight as possible — a developer shouldn’t wait 20 minutes to find out their change didn’t compile.

The Problem

The minimal CI is better than nothing but misses entire bug categories:

# WRONG — this catches compilation errors and panicking tests, not much else
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.22' }
      - run: go test ./...

No formatting check — PRs that mix logic changes with whitespace noise are harder to review. No vet — catches common mistakes the compiler doesn’t. No linting — misses entire classes of bugs. No race detection — data races ship to production. No dependency vulnerability scanning — known CVEs in your dependencies go unnoticed.

The Idiomatic Way

Here’s the pipeline I reach for on new Go projects:

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

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

# Cancel in-progress runs for the same branch on new pushes.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  GO_VERSION: '1.22'

jobs:
  # ── Fast checks (< 1 min) ────────────────────────────────────────────────
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      # go fmt: fail if any file needs formatting
      - name: Check formatting
        run: |
          if [ "$(gofmt -l . | wc -l)" -gt 0 ]; then
            echo "Files need formatting:"
            gofmt -l .
            exit 1
          fi

      # go vet: catch common mistakes
      - name: go vet
        run: go vet ./...

      # golangci-lint: ~70 linters in one tool
      - uses: golangci/golangci-lint-action@v6
        with:
          version: v1.59
          args: --timeout=5m

  # ── Tests (2-5 min) ──────────────────────────────────────────────────────
  test:
    name: Test
    runs-on: ubuntu-latest
    services:
      # Integration test dependencies declared here
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      # Unit + integration tests
      - name: Run tests
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/testdb?sslmode=disable
        run: go test -v -count=1 -timeout=10m ./...

      # Race detector — separate job because it's ~3x slower
      - name: Run tests with race detector
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/testdb?sslmode=disable
        run: go test -race -count=1 -timeout=10m ./...

      # Coverage report
      - name: Coverage
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/testdb?sslmode=disable
        run: |
          go test -coverprofile=coverage.out -covermode=atomic ./...
          go tool cover -func=coverage.out | tail -1

  # ── Security (3-5 min) ───────────────────────────────────────────────────
  security:
    name: Security
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      # govulncheck: check dependencies against Go vulnerability database
      - name: Install govulncheck
        run: go install golang.org/x/vuln/cmd/govulncheck@latest

      - name: Run govulncheck
        run: govulncheck ./...

  # ── Build (1-2 min) ──────────────────────────────────────────────────────
  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: ${{ env.GO_VERSION }}
          cache: true

      - name: Build static binary
        run: |
          CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
            -ldflags="-s -w -X main.version=${{ github.sha }}" \
            -o dist/myapp \
            ./cmd/myapp

      # Verify the binary is actually static
      - name: Verify static binary
        run: |
          if ldd dist/myapp 2>&1 | grep -v "not a dynamic executable"; then
            echo "ERROR: binary has unexpected dynamic dependencies"
            exit 1
          fi

The golangci-lint configuration deserves its own file — it’s where you tune which linters run:

# .golangci.yml
linters:
  enable:
    - errcheck        # ensure errors are checked
    - staticcheck     # advanced static analysis
    - gosimple        # simplifications
    - govet           # same as go vet
    - ineffassign     # detect unused variable assignments
    - unused          # unused code
    - goimports       # import ordering
    - revive          # opinionated style linter
    - bodyclose       # ensure http response bodies are closed
    - noctx           # detect missing context in HTTP calls
    - sqlcloserows    # ensure sql.Rows are closed

linters-settings:
  errcheck:
    # Don't require checking errors on fmt.Fprintf to stderr
    exclude-functions:
      - fmt.Fprint
      - fmt.Fprintf
      - fmt.Fprintln
  revive:
    rules:
      - name: exported
        arguments: ["checkPrivateReceivers"]

issues:
  exclude-rules:
    # Allow unkeyed struct literals in test files for table-driven tests
    - path: _test\.go
      linters: [govet]
      text: "composites"

In The Wild

A build matrix ensures the code compiles across Go versions. If your service supports multiple Go minor versions — common in libraries — test them all:

strategy:
  matrix:
    go: ['1.21', '1.22', '1.23']

For services that deploy Docker images, trigger a build and push on merge to main:

  # ── Docker (on main only) ──────────────────────────────────────────────
  docker:
    name: Docker Build & Push
    runs-on: ubuntu-latest
    needs: [lint, test, security]
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            VERSION=${{ github.sha }}

The cache-from: type=gha / cache-to: type=gha uses GitHub Actions cache for Docker layer caching — dramatically speeds up repeated builds.

The Gotchas

-count=1 prevents test caching. By default, Go caches test results and won’t rerun a test if the inputs haven’t changed. In CI, you always want to rerun tests. Add -count=1 to disable caching.

Race detector changes timings. Tests that pass without -race can fail with it because the race detector slows execution by 2-20x, sometimes exposing timing-dependent bugs. If a test is flaky only with -race, that’s a bug in the test or the production code — don’t suppress it.

govulncheck vs nancy vs osv-scanner. The Go vulnerability database (golang.org/x/vuln) is the authoritative source for Go-specific CVEs. It’s maintained by the Go team and checks whether your code actually calls the vulnerable function, not just whether the vulnerable package is in your dependency tree. This reduces false positives significantly compared to generic scanners.

Don’t cache go env GOMODCACHE incorrectly. The actions/setup-go@v5 with cache: true handles module caching correctly. Don’t add a separate actions/cache step for the module cache — they’ll conflict.

Key Takeaway

A Go CI pipeline has five layers: format check, vet, lint, tests with race detector, and vulnerability scan. Each layer catches bugs that the others miss. Keep fast checks early so failures are reported in under a minute. The race detector and integration tests are slower — run them in parallel with lint so the total wall time stays under five minutes.


Previous: Lesson 4: Config Injection Next: Lesson 6: Race Detector in CI — Run -race on every PR or ship bugs