CI/CD & Hardening

Updated

July 30, 2026

CI/CD Pipelines & Security Hardening

Overview

CI/CD should make the boring path automatic: format, lint, test, vuln scan, build, sign, and ship. For Go, the toolchain is friendly—fast tests, excellent race detector, first-party govulncheck—but pipelines still get compromised through unpinned actions, over-privileged tokens, and unsigned artifacts.

This chapter provides a 2026-oriented GitHub Actions workflow, binary hardening flags, image signing with Cosign, and a release checklist.

Goals of a Go Pipeline

push / PR
  --> checkout (pinned)
  --> setup-go (cache modules)
  --> vet / lint
  --> govulncheck
  --> test -race -cover
  --> build (reproducible flags)
  --> (main/tag) sign + publish

Every PR should fail closed on lint, tests, or reachable vulns. Releases should produce signed, versioned artifacts.

Baseline GitHub Actions Workflow

# .github/workflows/go.yml
name: Go Build & Test

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

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: "1.26.x"
          cache: true

      - name: Verify modules
        run: go mod verify

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

      - name: Lint
        uses: golangci/golangci-lint-action@v6
        with:
          version: v1.64.0

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

      - name: Test
        run: go test -race -count=1 -coverprofile=coverage.out ./...

      - name: Coverage report
        run: go tool cover -func=coverage.out | tail -n 1

      - name: Build
        run: |
          CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/app ./cmd/server

Hardening the workflow itself

  1. Least privilege tokens — default permissions: contents: read. Grant id-token: write only for OIDC/signing jobs; contents: write only for release jobs.
  2. Pin actions by digest in high-security repos:
# Example shape — replace with current digest from the action repo
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
  1. No pull_request_target with untrusted checkout — easy RCE pattern; avoid unless you fully understand the threat model.
  2. Cache carefullysetup-go cache is fine for modules; do not cache world-writable custom paths without validation.
  3. Secrets — only on main/tags, not on forks’ PR workflows that expose secrets.

Release Job Sketch

  release:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: write
      id-token: write  # for keyless Cosign / OIDC
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.26.x"
          cache: true

      - name: Build multi-arch binaries
        run: |
          mkdir -p dist
          for pair in linux/amd64 linux/arm64 darwin/arm64; do
            os=${pair%/*}; arch=${pair#*/}
            CGO_ENABLED=0 GOOS=$os GOARCH=$arch \
              go build -trimpath \
              -ldflags="-s -w -X main.version=${GITHUB_REF_NAME}" \
              -o "dist/app_${os}_${arch}" ./cmd/server
          done

      - name: Upload release assets
        uses: softprops/action-gh-release@v2
        with:
          files: dist/*

Hardening the Binary

CGO_ENABLED=0 go build \
  -trimpath \
  -buildmode=pie \
  -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
  -o app \
  ./cmd/server
Flag Purpose
-trimpath Strip local paths (reproducibility + privacy)
-s -w Strip symbol/DWARF tables (smaller; less free intel)
-buildmode=pie Position-independent executable; works with ASLR
-X main.version=… Embed release metadata without editing source

Optional: generate a Software Bill of Materials (SBOM) with syft or govulncheck tooling and attach it to the release.

Container Image Pipeline

      - name: Build image
        run: |
          docker build \
            --build-arg VERSION=${GITHUB_REF_NAME} \
            -t ghcr.io/${{ github.repository }}:${GITHUB_REF_NAME} .

      - name: Scan image
        run: |
          # install trivy or use a GH action
          trivy image --exit-code 1 --severity HIGH,CRITICAL \
            ghcr.io/${{ github.repository }}:${GITHUB_REF_NAME}

Signing with Cosign (Sigstore)

Signing proves the artifact came from your CI identity, not a random uploader.

# Keyless (OIDC) example in CI after cosign is installed
cosign sign --yes ghcr.io/org/myapp:1.2.3

# Verify elsewhere
cosign verify ghcr.io/org/myapp:1.2.3 \
  --certificate-identity-regexp='https://github.com/org/myapp/.*' \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com

Sign checksums for raw binaries:

(cd dist && sha256sum * > checksums.txt)
cosign sign-blob --yes --output-signature checksums.txt.sig checksums.txt

Go-Specific Quality Gates

# Race detector (linux/amd64/arm64; not all platforms)
go test -race ./...

# Fuzz (kept short in CI)
go test -fuzz=FuzzParse -fuzztime=20s ./internal/parser

# Module hygiene
go mod tidy
git diff --exit-code go.mod go.sum

Example test job matrix

strategy:
  matrix:
    os: [ubuntu-latest, macos-latest]
    go: ["1.25.x", "1.26.x"]

Use -race on Linux; on macOS it works but is slower—optional for PRs, required on main.

Supply Chain Mini-Checklist

Control Why
go.sum committed Pin module hashes
go mod verify Detect sum database mismatches
govulncheck Reachable vuln analysis (not just “depends on”)
Pinned actions digests Action repo compromise resistance
Minimal permissions Stolen token blast radius
Cosign / provenance Consumer verification
Immutable image tags + digests Prevent tag overwrite attacks

Application Snippet: Fail Build on Bad Config

CI should also run a config dry-run so broken env schemas never ship:

package config

import (
    "fmt"
    "os"
    "strconv"
)

type Config struct {
    Addr string
    Env  string
}

func Load() (Config, error) {
    addr := os.Getenv("LISTEN_ADDR")
    if addr == "" {
        addr = ":8080"
    }
    env := os.Getenv("APP_ENV")
    if env == "" {
        return Config{}, fmt.Errorf("APP_ENV required")
    }
    if _, err := strconv.Atoi(os.Getenv("READY_TIMEOUT_SEC")); err != nil && os.Getenv("READY_TIMEOUT_SEC") != "" {
        return Config{}, fmt.Errorf("READY_TIMEOUT_SEC: %w", err)
    }
    return Config{Addr: addr, Env: env}, nil
}
APP_ENV=ci go test ./internal/config -run TestLoad

Production Checklist

  • PR pipeline: vet, lint, govulncheck, go test -race
  • permissions default to read-only
  • Actions pinned (version at minimum; digest for high assurance)
  • Release builds use -trimpath, -s -w, version ldflags
  • Artifacts checksummed; containers scanned
  • Cosign (or equivalent) sign on tag release
  • Secrets not available to untrusted fork PRs
  • go.mod/go.sum tidy enforced
  • Branch protection requires the CI check

Common Pitfalls

  1. go test without -race — data races ship silently under load.
  2. Latest action tags onlyuses: foo@main is a supply-chain footgun.
  3. Over-powered GITHUB_TOKEN — write access on every PR job.
  4. Skipping vuln checks “to go green” — track ignore rules with expiry, don’t delete the step.
  5. Building with CGO in CI by accident — non-portable binaries and missing cross-compile.
  6. Signing only containers, not binaries — attackers swap the .tar.gz on the release page.
  7. No module verify — tampered cache or mirror goes unnoticed.

Exercises

  1. Green pipeline — Add the baseline workflow to a sample module; break a test and confirm the PR fails.
  2. Pin an action — Replace actions/checkout@v4 with a full commit SHA and document the version in a comment.
  3. Race lab — Introduce a deliberate data race; show -race fails and the fix (mutex or channel) passes.
  4. govulncheck — Run against a module with an old vulnerable dependency; upgrade until clean.
  5. Cosign dry-run — Generate a local key pair with Cosign, sign a blob, verify it (keyless optional later).
  6. Permissions audit — Read your workflow YAML and list every permission granted; remove any unused write scope.

More examples

Race-prone counter vs fixed counter

CI should run go test -race. This program shows the bug class the race detector catches, then a mutex-fixed version.

mkdir -p /tmp/go-ci-race && cd /tmp/go-ci-race
go mod init example.com/ci-race

Save as main.go:

package main

import (
    "fmt"
    "sync"
)

func racey(n int) int {
    var c int
    var wg sync.WaitGroup
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c++
        }()
    }
    wg.Wait()
    return c
}

func fixed(n int) int {
    var c int
    var mu sync.Mutex
    var wg sync.WaitGroup
    for i := 0; i < n; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            c++
            mu.Unlock()
        }()
    }
    wg.Wait()
    return c
}

func main() {
    // Deterministic path: only exercise the fixed counter in default runs.
    // To see the race: temporarily print racey(1000) under `go run -race .`
    got := fixed(1000)
    fmt.Println("fixed count:", got)
    fmt.Println("ok:", got == 1000)
}
go run .
# optional: go test -race (after adding a _test.go that calls racey)

Expected output:

fixed count: 1000
ok: true

Module sum verification helper

mkdir -p /tmp/go-ci-verify && cd /tmp/go-ci-verify
go mod init example.com/ci-verify

Save as main.go:

package main

import (
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "os"
)

func fileSHA256(path string) (string, error) {
    b, err := os.ReadFile(path)
    if err != nil {
        return "", err
    }
    sum := sha256.Sum256(b)
    return hex.EncodeToString(sum[:]), nil
}

func main() {
    path := "artifact.bin"
    _ = os.WriteFile(path, []byte("release-bytes-v1"), 0o644)
    defer os.Remove(path)

    sum, err := fileSHA256(path)
    if err != nil {
        panic(err)
    }
    fmt.Println("sha256:", sum)
    // Simulate CI check against a known digest.
    want, _ := fileSHA256(path)
    fmt.Println("matches pinned digest:", sum == want)
}
go run .

Expected output:

sha256: <64 hex chars>
matches pinned digest: true

Runnable example

CI gates are YAML; the Go work they gate is tests, races, and release-shaped builds. This package includes a race-prone function, a fixed version, and a small test file—run the same commands your pipeline should run.

mkdir -p /tmp/go-ci-gate && cd /tmp/go-ci-gate
go mod init example.com/ci-gate

Save as sum.go:

package cigate

import "sync"

// BrokenAdd races on total (do not use in production).
func BrokenAdd(n int) int {
    var total int
    var wg sync.WaitGroup
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            total++
        }()
    }
    wg.Wait()
    return total
}

// SafeAdd uses an atomic-style mutex critical section.
func SafeAdd(n int) int {
    var total int
    var mu sync.Mutex
    var wg sync.WaitGroup
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            mu.Lock()
            total++
            mu.Unlock()
        }()
    }
    wg.Wait()
    return total
}

Save as sum_test.go:

package cigate

import "testing"

func TestSafeAdd(t *testing.T) {
    if got := SafeAdd(1000); got != 1000 {
        t.Fatalf("SafeAdd: got %d want 1000", got)
    }
}

func TestBrokenAdd_DocumentRace(t *testing.T) {
    // Without -race this may flakily pass; with -race it should fail.
    // Comment out after you have seen the race detector fire once.
    t.Skip("uncomment to demo: go test -race (expect FAIL on BrokenAdd)")
    _ = BrokenAdd(1000)
}

Save as cmd/check/main.go:

package main

import (
    "fmt"
    "runtime/debug"

    cigate "example.com/ci-gate"
)

func main() {
    fmt.Println("safe_sum:", cigate.SafeAdd(100))
    if info, ok := debug.ReadBuildInfo(); ok {
        fmt.Println("go:", info.GoVersion)
        fmt.Println("path:", info.Path)
    }
    fmt.Println("ci gate sample ok")
}
go vet ./...
go test -count=1 ./...
go test -race -count=1 ./...
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /tmp/ci-check ./cmd/check
/tmp/ci-check
# optional: go install golang.org/x/vuln/cmd/govulncheck@latest && govulncheck ./...

Expected output (illustrative):

ok      example.com/ci-gate 0.012s
safe_sum: 100
go: go1.22.x
path: example.com/ci-gate
ci gate sample ok

To see the race detector, temporarily call BrokenAdd from a test without t.Skip and run go test -race.

What to notice

  • Pipelines should fail on go test -race for concurrent packages—silent races ship without it.
  • Release flags (-trimpath, -s -w, CGO_ENABLED=0) belong in the same job that publishes artifacts.
  • go vet is free correctness signal before heavier linters and govulncheck.

Try next

  • Unskip the race test, watch -race fail, then delete BrokenAdd.
  • Add a Makefile target sec: go vet && go test -race ./... matching the workflow in this chapter.