Skip to main content

Go CI/CD Workflows

Generated by golang-ci-library, which is composed by all three Go service archetypes:

  • golang-rest-service-archetype
  • golang-grpc-service-archetype
  • golang-graphql-service-archetype

The protocol has no effect on the workflow file - all three render the same two files. It does affect what the build action generates at run time, since golang-build guards its code generation on what exists in the repository.

Build Workflow

.github/workflows/build.yaml, shown as rendered for a project named billing-service:

name: Build

on:
push:
branches: ["**"]
pull_request:

permissions:
contents: write
id-token: write

env:
IMAGE_NAME: billing-service
APPLICATION_NAME: billing-service

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: p6m-actions/golang-setup@v1

- name: Cut Patch Version
if: github.ref_name == 'main'
id: cut-patch
uses: p6m-actions/golang-cut-tag@v1
with:
version-level: patch

- name: Build
uses: p6m-actions/golang-build@v1

# ... shared container build, release and dispatch steps

Step Chain

#StepScopeRuns on
1Checkout with fetch-depth: 0Go-specific variantAlways
2Go setupGoAlways
3Cut Patch VersionGomain
4BuildGoAlways
5Login to Artifactory Container RegistrySharedmain
6Set up Docker BuildxSharedAlways
7Build and Publish Docker ImageSharedAlways
8Make ArtifactsSharedmain
9Create Github releaseSharedmain
10Update Application ManifestSharedmain

Go is the outlier of the six in three ways: it needs the full git history, it never commits a version bump, and its build step generates code that is not committed.

Language-Specific Steps

Checkout with Full History

- uses: actions/checkout@v4
with:
fetch-depth: 0

Every other language reads the current version out of a manifest file. A Go module has no version field, so golang-cut-tag derives the next version from the latest git tag - the tags are the version history. A shallow clone would hide them and every build would try to cut v0.0.1.

Go Setup

- uses: p6m-actions/golang-setup@v1

p6m-actions/golang-setup installs the Go toolchain with module and build caching, plus the protobuf toolchain when it is needed.

InputDefaultPassed by the workflow
go-version(empty)(default) - taken from go-version-file instead
go-version-filego.mod(default) - go.mod is the single source of truth, so the toolchain version is never stated twice
cachetrue(default)
cache-dependency-pathgo.sum then go.mod(default) - a freshly rendered project has no go.sum yet, so go.mod is the fallback cache key
install-protocauto(default) - installs protoc and the Go gRPC plugins only when a proto/ directory exists
protoc-gen-go-versionv1.36.5(default)
protoc-gen-go-grpc-versionv1.5.1(default)
install-dependenciesfalse(default) - no separate go mod download
working-directory.(default)
OutputDescription
go-versionThe installed Go version
cache-hitWhether the module/build cache was restored
protoc-installedtrue when the protobuf toolchain was installed
install-protoc: auto is what makes one workflow serve three protocols

The gRPC archetype renders a proto/ directory, so the toolchain installs. The REST and GraphQL archetypes do not, so the step is a no-op. Contrast with Rust, which installs protoc unconditionally.

Keep the plugin versions in step with the Dockerfile

protoc-gen-go generates bindings against a specific runtime library version. If CI and the production Dockerfile install different plugin versions, the mismatch shows up as a link error at container build time, not as a warning here.

Cut Patch Version

- name: Cut Patch Version
if: github.ref_name == 'main'
id: cut-patch
uses: p6m-actions/golang-cut-tag@v1
with:
version-level: patch

p6m-actions/golang-cut-tag computes the next semantic version from the latest tag and creates a new tag. It never writes a file and never creates a commit.

InputRequiredDefaultNotes
version-levelNopatchpatch, minor, or major, relative to the latest tag
version-fileNo.version-lineOptional file holding <prefix><major>.<minor>, e.g. v1.2. When present it acts as a floor you control; when absent the latest tag alone decides. Read-only - the action never writes it
prefixNo(inferred)Inferred from the version file when one exists, otherwise v - the prefix go get expects
pre-releaseNofalseWhen true, appends an epoch timestamp, e.g. v1.2.5-1782043845
working-directoryNo.Must be inside the git repo
skip-pushNofalseSkips pushing the tag
OutputDescription
versionThe new version without prefix, e.g. 1.2.5
tagThe created git tag with prefix, e.g. v1.2.5
base-versionThe release version without any pre-release suffix
is-pre-releasetrue when a pre-release tag was created

Two consequences follow from "tag only, no commit":

  • No token-exchange, no [skip ci] hook. Go is the only language whose cut-tag action skips the OIDC token exchange. The tag is pushed with the job's own GITHUB_TOKEN, which by design does not trigger further workflow runs - so there is no loop to break.
  • .version-line is the only manual lever. To move a Go service from the 1.x line to 2.x, either run the Cut Tag workflow with major, or commit a .version-line file containing v2.0 as a floor.

Build

- name: Build
uses: p6m-actions/golang-build@v1

p6m-actions/golang-build generates code, tidies the module, then builds and tests. No inputs are overridden, so all defaults apply:

InputDefaultWhat runs
run-codegentrueprotoc when proto/ exists, gqlgen when gqlgen.yml exists
run-tidytruego mod tidy
run-buildtruego build ./...
run-testtruego test ./...
run-format-checkfalsegofmt -l . - off
run-vetfalsego vet ./... - off
run-lintfalsegolangci-lint run - off
archive-coveragefalseCoverage is not uploaded
working-directory.Repository root
OutputDescription
build-statussuccess or failure

Two defaults are worth understanding:

Code generation runs inside the build step. Go services import code that is generated rather than committed - protobuf bindings under gen/ for gRPC, the gqlgen server for GraphQL. A fresh clone does not compile until that code exists. Generating it here, in the step that needs it, is what keeps the CI path and the container build path from disagreeing about what a fresh clone contains.

go mod tidy runs on every build. A freshly rendered project ships without a go.sum, because producing one requires the toolchain and rendering does not have it. run-tidy materializes it on the first CI run; on a checkout that already has one it is a no-op.

Format, vet, and lint are off by default

Unlike Rust, the Go pipeline does not gate on formatting, go vet, or golangci-lint. golangci-lint in particular needs a .golangci.yml that the archetypes do not render. To turn any of them on, pass the corresponding run-* input in your build.yaml:

- name: Build
uses: p6m-actions/golang-build@v1
with:
run-format-check: "true"
run-vet: "true"

Cut Tag Workflow

.github/workflows/cut-tag.yaml:

name: Cut Tag

on:
workflow_dispatch:
inputs:
version-level:
description: "Version bump level"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major

permissions:
id-token: write
contents: write

jobs:
cut-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- uses: p6m-actions/golang-setup@v1

- uses: p6m-actions/golang-cut-tag@v1
with:
version-level: ${{ inputs.version-level }}
Inputversion-level - patch, minor, or major
OutputsA git tag. No commit, so nothing changes in the working tree

Version File

There is no version file. The version is whatever the latest v-prefixed git tag says:

git tag --list 'v*' --sort=-v:refname | head -1
# v1.4.2

The optional .version-line file, if you commit one, sets a floor on the major and minor line:

v2.0