Introduction
A good CI/CD pipeline for Go catches bugs before they reach production: it runs tests with the race detector, lints code, builds a Docker image, and deploys automatically. This guide builds a complete pipeline using GitHub Actions.
Prerequisites: A Go project with tests, a Dockerfile, and a GitHub repository.
Complete GitHub Actions Pipeline
# .github/workflows/ci.yml
name: CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
GO_VERSION: '1.22'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# โโโ Job 1: Test โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
test:
name: Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
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: ${{ env.GO_VERSION }}
cache: true # caches go module download
- name: Download dependencies
run: go mod download
- name: Run tests with race detector
run: go test -v -race -coverprofile=coverage.out ./...
env:
DATABASE_URL: postgres://test:test@localhost:5432/testdb?sslmode=disable
- name: Check test coverage
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "Coverage: ${COVERAGE}%"
# Fail if coverage drops below 70%
if (( $(echo "$COVERAGE < 70" | bc -l) )); then
echo "Coverage ${COVERAGE}% is below minimum 70%"
exit 1
fi
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage.out
# โโโ Job 2: Lint โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
args: --timeout=5m
- name: Check formatting
run: |
if [ -n "$(gofmt -l .)" ]; then
echo "The following files are not formatted:"
gofmt -l .
exit 1
fi
- name: go vet
run: go vet ./...
- name: Check for vulnerabilities
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
# โโโ Job 3: Build Docker Image โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
build:
name: Build
runs-on: ubuntu-latest
needs: [test, lint]
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
image-digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
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 }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=sha-
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
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 }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
# โโโ Job 4: Deploy โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to Cloud Run
uses: google-github-actions/deploy-cloudrun@v2
with:
service: myapp
region: us-central1
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
golangci-lint Configuration
# .golangci.yml
linters:
enable:
- errcheck # check for unchecked errors
- gosimple # simplification suggestions
- govet # go vet checks
- ineffassign # detect ineffectual assignments
- staticcheck # static analysis
- unused # find unused code
- gofmt # formatting
- goimports # import organization
- misspell # spelling mistakes
- gocritic # various checks
- revive # replacement for golint
- gosec # security issues
linters-settings:
errcheck:
check-type-assertions: true
govet:
enable-all: true
revive:
rules:
- name: exported
severity: warning
issues:
exclude-rules:
- path: _test\.go
linters:
- errcheck # allow unchecked errors in tests
Makefile for Local Development
# Makefile
.PHONY: test lint build run clean
GO_VERSION := 1.22
BINARY := server
DOCKER_IMAGE := myapp
# Run tests with race detector
test:
go test -v -race -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# Run linter
lint:
golangci-lint run
gofmt -l .
# Build binary
build:
CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-w -s -X main.Version=$(shell git describe --tags --always)" \
-o bin/$(BINARY) ./cmd/server
# Build Docker image
docker-build:
docker build -t $(DOCKER_IMAGE):$(shell git rev-parse --short HEAD) .
# Run locally
run:
go run ./cmd/server
# Run with hot reload (requires air)
dev:
air
# Clean build artifacts
clean:
rm -rf bin/ coverage.out coverage.html
# Install development tools
tools:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install github.com/cosmtrek/air@latest
go install golang.org/x/vuln/cmd/govulncheck@latest
GitLab CI Alternative
# .gitlab-ci.yml
stages:
- test
- lint
- build
- deploy
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: ""
GO_VERSION: "1.22"
.go-cache:
variables:
GOPATH: $CI_PROJECT_DIR/.go
before_script:
- mkdir -p .go
cache:
paths:
- .go/pkg/mod/
test:
stage: test
image: golang:${GO_VERSION}-alpine
extends: .go-cache
services:
- postgres:16-alpine
variables:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
DATABASE_URL: postgres://test:test@postgres/testdb?sslmode=disable
script:
- go test -v -race -coverprofile=coverage.out ./...
- go tool cover -func=coverage.out
coverage: '/total:\s+\(statements\)\s+(\d+\.\d+)%/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
lint:
stage: lint
image: golangci/golangci-lint:latest
extends: .go-cache
script:
- golangci-lint run --timeout 5m
- gofmt -l . | tee /dev/stderr | wc -l | xargs -I{} test {} -eq 0
build:
stage: build
image: docker:latest
services:
- docker:dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- |
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
docker push $CI_REGISTRY_IMAGE:latest
fi
deploy:
stage: deploy
image: bitnami/kubectl:latest
script:
- kubectl set image deployment/myapp server=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- kubectl rollout status deployment/myapp --timeout=5m
only:
- main
environment:
name: production
Testing Best Practices for CI
// Use table-driven tests for comprehensive coverage
func TestCalculate(t *testing.T) {
tests := []struct {
name string
input int
want int
wantErr bool
}{
{"positive", 5, 25, false},
{"zero", 0, 0, false},
{"negative", -1, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Calculate(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Calculate() = %v, want %v", got, tt.want)
}
})
}
}
// Use build tags to separate integration tests
//go:build integration
func TestDatabaseIntegration(t *testing.T) {
// Only runs with: go test -tags integration ./...
}
# Run only unit tests (fast)
go test ./...
# Run with integration tests
go test -tags integration ./...
# Run specific test
go test -run TestCalculate ./...
# Verbose with race detector
go test -v -race ./...
Comments