make version
•
Mon Nov 10 2025
(updated
Wed Apr 01 2026)
#awk #git #go #semver
Photo by Xavi Cabrera

Photo by Xavi Cabrera

Owning Your Version

Previously, I talked about Conventional Commit Caveats, but it’s still my preferred style of writing git commits. Since it’s such a lightweight specification, we can easily implement our own automated version management system.

First, there are many implementations already out there; plugins for IDE’s and linters, standalone scripts/binaries, and CI/CD templates to name a few. Many of which are likely more feature rich than what I will show here. If you are implementing an automated version management system for a large organization across many types of projects, there may be projects that wish to make full use of the Semantic Version specification in ways you did not foresee.

Choosing an existing implementation with a large feature set may serve you best. However, I love that the simplicity of the specification allows us to write a small script that will cover majority of our use case, should be easily understood at a glance by most developers, empowers us to support types we want, and doesn’t require any 3rd-party dependencies that we might feel obligated to vet or inventory.

Update: Socket.dev posted a report on Feb 20, 2026: SANDWORM_MODE: Shai-Hulud-Style npm Worm Hijacks CI Workflows and Poisons AI Toolchains:

The Action also patches release toolchain configurations (.releaserc, .releaserc.json, .release-it.json, and variants) to inject @semantic-release/exec with prepareCmd: "node -e \"try{require('CARRIER')}catch(e){}\"". This ensures every subsequent npm publish from the infected repo silently executes the carrier package, creating an additional propagation path through the release pipeline.

Public GitHub Action: ci-quality/code-quality-check

If you truly don’t need the entire Semantic Versioning specification, don’t throw the kitchen-sink at it.

git log

We need to craft a git log command that will make it easy to parse for Conventional Commit specification; therefore, the important parts are the subject and git trailers.

git log allows us to specify the exact format string, and even provides a trailers placeholder that has options to limit the git trailer key we are looking for. The default separator is a newline, so we specify a comma using format code “%x2c”. Of course, the default is to print in reverse chronological order, so we must also specify --reverse to read our Conventional Commits chronologically.

git log --reverse --no-merges \
  --pretty='format:%s %(trailers:key=BREAKING-CHANGE,separator=%x2C)'

awk

Below is an awk script that supports the git trailer key “BREAKING-CHANGE” and a subject scope/type appended with an exclamation. We can also choose the exact types we want to support, along with which types should increment our semantic version.

#!/usr/bin/awk -f
BEGIN { major = minor = patch = 0 }
{
  if ($0 ~ /BREAKING-CHANGE:|^[^\s:]+!:/) {
    major++; minor = patch = 0
  } else if (tolower($0) ~ /^(feat|build|ci|chore)[^\s:]*:/) {
    minor++
  } else if (tolower($0) ~ /^fix[^\s:]*:/) {
    patch++
  } else if (tolower($0) !~ /^(docs|style|refactor|test)[^\s:]*:|fixup!/) {
    printf "Invalid type: %s\n", $0 > "/dev/stderr"
  }
}
END { printf "v%d.%d.%d\n", major, minor, patch }

Don’t be tempted to adjust the awk script to support “BREAKING CHANGE” because git will never print it using the %(trailers) placeholder since whitespace is not a valid character for git trailer keys.

Go

How do we integrate this into a our Go project? During the build process, we can set the value of an uninitialized variable:

go build -ldflags="-X 'importpath.name=value'"

make

Finally, I like to use a Makefile with a “build” and “version” target:

build: clean ## Build binary
ifeq ($(shell git rev-parse --is-inside-work-tree 2>/dev/null),true)
  @$(eval VERSION := $(shell $(MAKE) version --no-print-directory))
  go build --ldflags '-X app.version.Version='$(VERSION)
else
  $(error Not in a git repository)
endif

version: ## Make version
ifeq ($(shell git rev-parse --is-inside-work-tree 2>/dev/null),true)
  @git log --reverse --pretty='format:%s %(trailers:key=BREAKING-CHANGE,separator=%x2C)' | \
    awk 'BEGIN { major = minor = patch = 0 } { \
      if ($$0 ~ /BREAKING-CHANGE:|^[^\s:]+!:/) { \
        major++; minor = patch = 0 \
      } else if (tolower($$0) ~ /^(feat|build|ci|chore)[^\s:]*:/) { \
        minor++ \
      } else if (tolower($$0) ~ /^fix[^\s:]*:/) { \
        patch++ \
      } else if (tolower($$0) !~ /^(docs|style|refactor|test)[^\s:]*:|fixup!/) { \
        printf "Invalid type: %s\n", $$0 > "/dev/stderr" \
      } \
    } END { printf "v%d.%d.%d\n", major, minor, patch }'
else
  $(error Not in a git repository)
endif

Conclusion

With just about ten lines of awk, we have a fairly robust implementation of an automated version management system using Conventional Commits and Semantic Versioning.