DEV Community

Cover image for ๐Ÿ‘ท๐Ÿปโ€โ™‚๏ธ Golang CI/CD Done Right: Build a Rock-Solid Pipeline with GitHub Actions
Giovanni De Giorgio
Giovanni De Giorgio

Posted on

๐Ÿ‘ท๐Ÿปโ€โ™‚๏ธ Golang CI/CD Done Right: Build a Rock-Solid Pipeline with GitHub Actions

๐Ÿš€ TL;DR

  • Did you know I just came back from a vacation? ๐ŸŒด
  • Built a simple Go CLI with Cobra
  • Automated PR checks, linting, and testing with GitHub Actions
  • Enforced Conventional Commits and semantic versioning
  • Generated changelogs and cross-platform releases with Goreleaser
  • Secured the pipeline using CodeQL and Dependabot

๐ŸŒด About My Vacation

I just got back from a much-needed vacation, and during that time, I finally sat down and read The Phoenix Project. If youโ€™re not familiar with it, itโ€™s a novel about IT, DevOps, and the chaos that can happen when delivery processes are broken.

Itโ€™s part entertaining, part terrifying, and completely inspiring.

So when I returned to my Golang CLI projects, I decided to revisit and improve my CI/CD setup from the ground up. In this post, I will walk you through the stack I ended up with: a clean, modular, and production-ready CI/CD pipeline built entirely with GitHub Actions.

Letโ€™s dive in.

๐Ÿ’ก What I Learned from The Phoenix Project

The story really drove home how much trouble teams can get into without:

  • Automated security checks to catch vulnerabilities early
  • Reliable, automated testing to prevent regressions
  • Controlled deployments that minimize risk
  • Formal change management to track and govern what gets released

Without these safeguards, teams face outages, firefighting, and lost time, problems that could have been avoided with better automation and process.

This reinforced for me that CI/CD is more than just running builds and tests. Itโ€™s about embedding control, safety, and visibility into every step of software delivery.

๐Ÿฆซ Create a Simple Golang Application

Iโ€™ve always been fascinated by how easy and powerful it is to build CLI applications in Go. With the help of Cobra, creating a structured, user-friendly command-line tool takes just a few lines of code.

main.go

package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

func main() {

    var rootCmd = &cobra.Command{
        Use:   "hello-cli",
        Short: "A simple CLI that says hello",
        Run: func(cmd *cobra.Command, args []string) {
            cmd.Println("Hello, world!")
        },
    }

    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

Enter fullscreen mode Exit fullscreen mode

main_test.go

package main

import (
    "bytes"
    "strings"
    "testing"
)

func TestHelloCLI(t *testing.T) {
    var buf bytes.Buffer
    var rootCmd = &cobra.Command{
        Use:   "hello-cli",
        Short: "A simple CLI that says hello",
        Run: func(cmd *cobra.Command, args []string) {
            cmd.Println("Hello, world!")
        },
    }

    rootCmd.SetOut(&buf)
    rootCmd.SetArgs([]string{})

    if err := rootCmd.Execute(); err != nil {
        t.Fatalf("Command failed: %v", err)
    }

    output := buf.String()
    expected := "Hello, world!\n"

    if strings.TrimSpace(output) != strings.TrimSpace(expected) {
        t.Errorf("unexpected output:\n  got:  %q\n  want: %q", output, expected)
    }
}

Enter fullscreen mode Exit fullscreen mode
go run main.go

Hello, world!
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ It All Starts with a Pull Request

The first step in building a reliable CI/CD pipeline is to ensure consistency and quality at the source your pull requests. If every PR follows a standard format and passes basic checks, the rest of the pipeline becomes much more predictable and automated.

To enable automated versioning and changelog generation later in the pipeline, I decided to adopt Conventional Commits. These are structured commit messages that follow a simple pattern:

feat: add new controller endpoint
fix: resolve panic on nil pointer in handler
chore: update dependencies
Enter fullscreen mode Exit fullscreen mode

The next step is to automate enforcement. Manual checks donโ€™t scale.

The goal here is simple:

  • โœ… Ensure PR Title follows Conventional Commits
  • โœ… Ensure all code builds successfully
  • โœ… Run tests automatically
  • โœ… Enforce formatting and linting rules

.github/workflows/pr-checks.yml

name: Pull Request checks

on:
  pull_request:
    branches: [main]

jobs:
  build:
    name: ๐Ÿ”จ Build , Lint & Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: "1.24.3"

      - name: Install dependencies
        run: go mod tidy

      - name: Build project
        run: go build -v ./...

      - name: Run tests
        run: go test -v ./...

      - name: Lint code
        uses: golangci/golangci-lint-action@v8
        with:
          version: v2.1

      - name: Lint Pull Request Title
        uses: amannn/action-semantic-pull-request@v5
        env:
          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

Pull Request Checks

The initial results are promising, confirming our basic CI checks work as expected. Now, letโ€™s push forward and expand the pipelineโ€™s capabilities.

๐Ÿ†• Version please!

In The Phoenix Project, Bill Palmer, the VP of IT Operations, faces major headaches because developers are inconsistently managing versioning and leading to confusion, deployment failures, and firefighting. This real-world scenario highlights how crucial it is to properly version every code change.

With structured commit messages in place, we can now introduce semantic versioning and automate changelog generation directly within our CI/CD pipeline.

By analyzing commit history, we can:

  • Determine the appropriate version bump (major, minor, or patch)
  • Automatically generate a well-formatted CHANGELOG.md
  • Create and push a GitHub tag for the release

This not only enforces consistent versioning across releases, but also removes the manual overhead of maintaining changelogs and tagging code, a critical step toward reliable, reproducible deployments.

Once the project is ready for a new release, we can trigger an on-demand GitHub Actions workflow to automatically generate a new tag based on commit history.

.github/workflows/create-new-tag.yml

name: Release a new version

on: [workflow_dispatch]

jobs:
  release-tag:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: "0"

      - name: Tag repository
        id: tagRepo
        uses: anothrNick/github-tag-action@1.36.0
        env:
          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
          WITH_V: true
          DEFAULT_BUMP: patch
Enter fullscreen mode Exit fullscreen mode

We can now respond to new tag events by automatically generating and publishing a changelog.

.github/workflows/changelog.yml

name: Generate Changelog

on:
  push:
    tags:
      - v[0-9]+.[0-9]+.[0-9]+

jobs:
  changelog:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4

      - name: Generate and Commit Changelog
        uses: requarks/changelog-action@v1
        with:
          token: ${{ secrets.GH_TOKEN }}
          tag: ${{ github.ref_name }}
          commitMessage: "docs(changelog): update for ${{ github.ref_name }}"
          outputFile: "CHANGELOG.md"

      - name: Create GitHub Release
        uses: ncipollo/release-action@v1
        with:
          tag: ${{ github.ref_name }}
          bodyFile: "CHANGELOG.md"
          token: ${{ secrets.GH_TOKEN }}

Enter fullscreen mode Exit fullscreen mode

As our CLI evolves, itโ€™s important to demonstrate how feature development ties directly into our CI/CD workflow. Letโ€™s implement a simple enhancement: a --name flag that allows users to personalize the greeting message.

For example, running the following command:

go run main.go --name Giovanni
Enter fullscreen mode Exit fullscreen mode

Should output:

Hello, Giovanni!
Enter fullscreen mode Exit fullscreen mode

main.go

package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var name string

var rootCmd = &cobra.Command{
    Use:   "hello-cli",
    Short: "A simple CLI that says hello",
    Run: func(cmd *cobra.Command, args []string) {
        if name != "" {
            cmd.Printf("Hello, %s!\n", name)
        } else {
            cmd.Println("Hello, world!")
        }
    },
}

func init() {
    rootCmd.Flags().StringVar(&name, "name", "", "Custom name to include in greeting")
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Once our pull request has been reviewed, approved, and successfully merged, ensuring all checks have passed, we can trigger the release workflow.

Release Workflow

Aaaaaand, here we go! ๐Ÿš€

With everything in place, our pipeline now effortlessly handles releases, tagging, changelog updates and letting us focus on what truly matters: writing great code.

๐Ÿ“ฆ Stop running go run main.go

Weโ€™re almost there but thereโ€™s one crucial piece missing.

We donโ€™t want to clone the entire project every time I want to run hello-cli; I need to execute the binary directly. And hereโ€™s the twist: I have a Raspberry Pi with an ARM processor, a Mac with Apple Silicon, and a tiny Linux box so I want my binary to run seamlessly everywhere!

To solve this challenge, Iโ€™ll be using Goreleaser a powerful tool that automates building and publishing binaries for multiple platforms. With a single configuration YAML file, Goreleaser simplifies cross-platform releases, ensuring our CLI runs smoothly on ARM, Apple Silicon, Linux, and more all without manual hassle.

.goreleaser.yml

version: 2

project_name: hello-cli
dist: bin

builds:
  - id: hello-cli
    main: main.go
    goos:
      - linux
      - darwin
    goarch:
      - amd64
      - arm64

  - id: hello-cli-win
    main: main.go
    goos:
      - windows
    goarch:
      - amd64
      - arm64

archives:
  - id: default
    ids: [hello-cli]
    formats: [tar.gz]
    name_template: "hello-cli_{{ .Os }}_{{ .Arch }}"
    files:
      - LICENSE.txt
      - README.md

  - id: hello-cli-win
    ids: [hello-cli-win]
    formats: [zip]
    name_template: "hello-cli_{{ .Os }}_{{ .Arch }}"
    files:
      - LICENSE.txt
      - README.md

release:
  github:
    owner: gdegiorgio
    name: golang-rock-solid-cicd

changelog:
  use: git

Enter fullscreen mode Exit fullscreen mode

.github/workflows/package-hello-cli.yml

name: Release Packages

on:
  push:
    tags:
      - v[0-9]+.[0-9]+.[0-9]+

jobs:
  release:
    runs-on: ubuntu-latest

    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: "1.24.3"

      - name: Install GoReleaser
        uses: goreleaser/goreleaser-action@v6
        with:
          version: '~> v2'
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

Binary Packages

Thatโ€™s really incredible, donโ€™t you think? ๐Ÿคฏ

๐Ÿ” Securing Your Application: CodeQL and Dependabot

No CI/CD pipeline is complete without a strong focus on security and dependency management. As your application grows, so does the attack surface and the complexity of keeping everything up to date. Fortunately, GitHub provides two powerful tools to help us stay ahead: CodeQL and Dependabot.

๐Ÿง  CodeQL: Automated Security Analysis

CodeQL is GitHubโ€™s semantic code analysis engine. It allows you to query your codebase like a database, identifying security vulnerabilities and logic flaws before they make it to production.

Once integrated into your workflow, CodeQL will:

  • Automatically scan your code
  • Detect common vulnerability patterns like SQL injection, command injection, or unsafe deserialization

By running CodeQL scans regularly, you introduce a proactive layer of defense directly into your development cycle, catching bugs early and enforcing security hygiene with minimal effort.

.github/workflows/codeql.yml

name: "CodeQL"

on:
  pull_request:
    branches: [main]

jobs:
  analyze:
    name: Analyze
    runs-on: ubuntu-latest

    permissions:
      actions: read
      contents: read
      security-events: write

    strategy:
      fail-fast: false
      matrix:
        language: [go]

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: ${{ matrix.language }}

      - name: Autobuild
        uses: github/codeql-action/autobuild@v3

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”„ Dependabot: Automated Dependency Updates

Modern applications rely on open-source libraries and keeping those up to date is essential. Dependabot helps you do exactly that by automatically scanning your go.mod and other dependency files, opening pull requests when:

  • A newer version is available
  • A known vulnerability has been reported in a dependency

.github/dependabot.yml

version: 2
updates:
  - package-ecosystem: gomod
    directory: /
    schedule:
      interval: daily
Enter fullscreen mode Exit fullscreen mode

โœ… Wrap-Up: A Pipeline You Can Trust

In this journey, we started with a simple idea :

building a lightweight CLI tool in Go and used it as a foundation to construct a solid, secure, and automated CI/CD workflow using GitHub Actions.

By layering in tools and best practices like:

  • PR-based checks for linting, testing, and compiling
  • Semantic commit enforcement to drive meaningful versioning
  • Automated changelog generation and release tagging
  • Multi-platform binary publishing with Goreleaser
  • Security scanning with CodeQL
  • Dependency hygiene via Dependabot

โ€ฆweโ€™ve established a pipeline that not only supports continuous delivery, but also builds trust through automation, clarity, and repeatability.

Whether youโ€™re building a toy CLI or the next critical internal tool, think of this as a good foundation you can tweak and grow depending on what your project needs.

๐Ÿ“š References

  • ๐Ÿ—‚๏ธย hello-cli GitHub Repository

    The sample project used throughout this post. Explore the code, workflows, and automation setup.

  • ๐Ÿ“˜ The Phoenix Project โ€“ Gene Kim, Kevin Behr, George Spafford

    A novel that inspired this postโ€™s focus on flow, feedback, and continual improvement in software delivery.

  • ๐Ÿงฐ GitHub Actions

    Automate, customize, and execute software workflows directly in your GitHub repo.

  • ๐Ÿ›ก๏ธ CodeQL

    GitHubโ€™s static analysis engine for discovering vulnerabilities in your code.

  • ๐Ÿ”„ Dependabot

    Keeps your dependencies up-to-date and secure with automated pull requests.

  • ๐Ÿ“ฆ Goreleaser

    Build and release Go binaries for multiple platforms with ease.

  • ๐Ÿ Cobra CLI Framework

    A widely-used library for building powerful CLI applications in Go.

  • ๐Ÿ““ Conventional Commits

    A standardized format for writing commit messages that power semantic versioning.

Top comments (0)