CI/CD & Hardening
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/serverHardening the workflow itself
- Least privilege tokens — default
permissions: contents: read. Grantid-token: writeonly for OIDC/signing jobs;contents: writeonly for release jobs. - 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- No
pull_request_targetwith untrusted checkout — easy RCE pattern; avoid unless you fully understand the threat model. - Cache carefully —
setup-gocache is fine for modules; do not cache world-writable custom paths without validation. - 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.comSign checksums for raw binaries:
(cd dist && sha256sum * > checksums.txt)
cosign sign-blob --yes --output-signature checksums.txt.sig checksums.txtGo-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.sumExample 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 TestLoadProduction Checklist
- PR pipeline: vet, lint,
govulncheck,go test -race permissionsdefault 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.sumtidy enforced- Branch protection requires the CI check
Common Pitfalls
go testwithout-race— data races ship silently under load.- Latest action tags only —
uses: foo@mainis a supply-chain footgun. - Over-powered
GITHUB_TOKEN— write access on every PR job. - Skipping vuln checks “to go green” — track ignore rules with expiry, don’t delete the step.
- Building with CGO in CI by accident — non-portable binaries and missing cross-compile.
- Signing only containers, not binaries — attackers swap the
.tar.gzon the release page. - No module verify — tampered cache or mirror goes unnoticed.
Exercises
- Green pipeline — Add the baseline workflow to a sample module; break a test and confirm the PR fails.
- Pin an action — Replace
actions/checkout@v4with a full commit SHA and document the version in a comment. - Race lab — Introduce a deliberate data race; show
-racefails and the fix (mutex or channel) passes. - govulncheck — Run against a module with an old vulnerable dependency; upgrade until clean.
- Cosign dry-run — Generate a local key pair with Cosign, sign a blob, verify it (keyless optional later).
- 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-raceSave 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-verifySave 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-gateSave 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 -racefor concurrent packages—silent races ship without it. - Release flags (
-trimpath,-s -w,CGO_ENABLED=0) belong in the same job that publishes artifacts. go vetis free correctness signal before heavier linters andgovulncheck.
Try next
- Unskip the race test, watch
-racefail, then deleteBrokenAdd. - Add a Makefile target
sec: go vet && go test -race ./...matching the workflow in this chapter.