Concurrency and Shipping
Ship Tasker
Goal
Turn Tasker into a real release. At the end, one command builds Tasker for Linux, macOS and Windows, and every push to GitHub runs your tests automatically.
What you will learn
- How to set the version of a program at build time with
-ldflags - How to read the version from
go installwithdebug.ReadBuildInfo - How to build for other operating systems with
GOOSandGOARCH - How to make smaller binaries with
-trimpathand-s -w - How to package releases with GoReleaser
- How to run tests and releases with GitHub Actions
Why this matters
Code on your laptop helps only you. Users need a file they can download and run. Go is very good at this: it builds one static binary with no runtime to install. In this lesson you use that strength. You also add CI (continuous integration), so a broken test is found on every push, not by your users.
Step 1: A version that changes at build time
Until now, root.go had a constant like Version = "0.2.0". You had to edit the code for each release, and it was easy to forget.
A better way is to keep a default in the code and replace it when you build. Create internal/cli/version.go:
package cli
import (
"fmt"
"runtime/debug"
)
// These values are set at build time with -ldflags, for example:
//
// go build -ldflags "-X github.com/yourname/tasker/internal/cli.Version=1.0.0"
var (
Version = "dev"
Commit = "none"
Date = "unknown"
)
// versionString returns the version, commit, and build date as one line.
// For programs installed with "go install", it reads the version from the module.
func versionString() string {
version := Version
if version == "dev" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
}
return fmt.Sprintf("%s (commit %s, built %s)", version, Commit, Date)
}
Important: these must be var, not const. The linker can change string variables, but it cannot change constants.
In internal/cli/root.go, remove the old const Version and set the command version:
root := &cobra.Command{
Use: "tasker",
Short: "Tasker is a small task manager",
Version: versionString(),
// ... the other fields stay the same
}
The debug log in PersistentPreRunE still uses zap.String("version", Version). That still works, because Version is now a variable with the same name.
Build and run it:
go build -o tasker ./cmd/tasker
./tasker --version
Output:
tasker version dev (commit none, built unknown)
Now set the values with the linker flag -X. The format is -X full/package/path.Name=value:
go build -o tasker -ldflags "-X github.com/yourname/tasker/internal/cli.Version=1.0.0 -X github.com/yourname/tasker/internal/cli.Commit=abc1234 -X github.com/yourname/tasker/internal/cli.Date=2026-10-16" ./cmd/tasker
./tasker --version
Output:
tasker version 1.0.0 (commit abc1234, built 2026-10-16)
You did not change any code. Only the build command changed.
Step 2: Versions for go install
Many Go users install tools like this:
go install github.com/yourname/tasker/cmd/[email protected]
This command does not use your -ldflags. But Go records the module version inside the binary. debug.ReadBuildInfo() reads it. That is why versionString checks it when Version is still "dev". A user who installs @v1.0.0 sees v1.0.0, not dev.
When you build from a local folder, the module version is (devel), so the code ignores it.
Step 3: Smaller binaries
Check the normal size:
go build -o tasker ./cmd/tasker
ls -l tasker
On our machine the file was 11473546 bytes (about 11 MB). Now try:
go build -trimpath -ldflags "-s -w" -o tasker ./cmd/tasker
ls -l tasker
Now it was 7770404 bytes (about 7.4 MB). Your numbers will be a little different.
-trimpathremoves the folder paths of your computer from the binary. The build is cleaner and more repeatable.-s -wremoves the symbol table and debug information. The program works the same, but debuggers get less information.
Step 4: Build for other systems
Go can build for another operating system from your machine. Set two environment variables:
GOOS: the operating system (linux,darwinfor macOS,windows)GOARCH: the CPU type (amd64for most PCs,arm64for Apple Silicon and many servers)
GOOS=windows GOARCH=amd64 go build -o tasker.exe ./cmd/tasker
GOOS=darwin GOARCH=arm64 go build -o tasker-mac ./cmd/tasker
file tasker.exe tasker-mac
Output:
tasker.exe: PE32+ executable (console) x86-64, for MS Windows
tasker-mac: Mach-O 64-bit arm64 executable
On Windows PowerShell, set the variables like this: $env:GOOS="linux"; go build ./cmd/tasker.
To see all supported platforms:
go tool dist list
Our Go 1.24 listed 48 platforms.
This is easy for Tasker because it is pure Go. Programs that use C code (cgo) are much harder to cross-compile. Set CGO_ENABLED=0 to make sure you build without C.
Step 5: Package releases with GoReleaser
Running six build commands by hand, then making zip files and checksums, is slow and easy to get wrong. GoReleaser does all of it from one config file.
Install it by following the guide at goreleaser.com (for example brew install goreleaser on macOS, or go install github.com/goreleaser/goreleaser/v2@latest).
First, add a .gitignore in the project root, so build output does not go into git:
/tasker
/tasker.exe
/dist/
Then create .goreleaser.yaml in the project root:
version: 2
before:
hooks:
- go mod tidy
- go test ./...
builds:
- id: tasker
main: ./cmd/tasker
binary: tasker
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
flags:
- -trimpath
ldflags:
- -s -w
- -X github.com/yourname/tasker/internal/cli.Version={{.Version}}
- -X github.com/yourname/tasker/internal/cli.Commit={{.ShortCommit}}
- -X github.com/yourname/tasker/internal/cli.Date={{.Date}}
archives:
- formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
What each part does:
before.hooksruns commands first. If a test fails, there is no release.buildsis the same as your manual commands: 3 systems × 2 CPU types = 6 binaries. The{{.Version}}parts are templates. GoReleaser fills them in from your git tag and commit.archivespacks each binary. Windows users get.zip, others get.tar.gz.checksumwrites a file with SHA-256 hashes, so users can check their download.changelogbuilds release notes from commit messages.
GoReleaser reads versions from git tags. Your project must be a git repository with a GitHub remote:
git init
git add -A
git commit -m "Tasker 1.0.0"
git tag v1.0.0
git remote add origin https://github.com/yourname/tasker.git
Check the config:
goreleaser check
Output:
• checking path=.goreleaser.yaml
• 1 configuration file(s) validated
• thanks for using GoReleaser!
If you see no remote configured to list refs from, you forgot the git remote add step.
Now make a local test release. --snapshot builds everything but publishes nothing:
goreleaser release --snapshot --clean
The end of the output (shortened):
• building binaries
• building paths=cmd/tasker binaries=tasker target=linux_amd64_v1
• building paths=cmd/tasker binaries=tasker target=darwin_arm64_v8.0
...
• archives
• archiving name=dist/tasker_1.0.0-SNAPSHOT-ed71cb5_linux_amd64.tar.gz
• archiving name=dist/tasker_1.0.0-SNAPSHOT-ed71cb5_windows_amd64.zip
...
• calculating checksums
• release succeeded after 1m4s
Look in dist/. You will find six archives, checksums.txt, and one folder per platform. Run the Linux build (or the one for your system):
./dist/tasker_linux_amd64_v1/tasker --version
Output:
tasker version 1.0.0-SNAPSHOT-ed71cb5 (commit ed71cb5, built 2026-10-16T23:24:56Z)
Your commit hash and date will be different. The ldflags worked: no code changed, but the version, commit and date are all real.
Step 6: Run tests on every push with GitHub Actions
GitHub Actions runs commands on GitHub's servers when something happens in your repository. Workflows are YAML files in .github/workflows/.
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Vet
run: go vet ./...
- name: Test
run: go test -race ./...
- name: Build
run: go build ./cmd/tasker
onsays when to run: on every push tomainand on every pull request.actions/checkoutdownloads your code.actions/setup-goinstalls Go.go-version-file: go.moduses the version in yourgo.mod, so CI and your laptop agree.- The steps run vet, the tests with the race detector from Lesson 6.1, and a build.
If any step fails, GitHub marks the commit with a red cross and you get an email.
You can run the same steps locally before you push:
go vet ./... && go test -race ./...
Output:
? github.com/yourname/tasker/cmd/tasker [no test files]
ok github.com/yourname/tasker/internal/cli 1.048s
ok github.com/yourname/tasker/internal/importer 1.136s
ok github.com/yourname/tasker/internal/storage 1.033s
ok github.com/yourname/tasker/internal/task 1.027s
Step 7: Release when you push a tag
Create .github/workflows/release.yml:
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- It runs only when you push a tag that starts with
v, likev1.0.0. permissions: contents: writelets the workflow create a GitHub release.fetch-depth: 0downloads the full git history. GoReleaser needs it for tags and the changelog.GITHUB_TOKENis created by GitHub for each run. You do not need to make it yourself.
To publish a release:
git push origin main
git push origin v1.0.0
After a few minutes, the "Releases" page of your repository shows v1.0.0 with all six archives and checksums.txt.
Note: the action versions (@v4, @v5, @v6) were current when this course was written. Newer major versions may exist. Check the page of each action on GitHub.
Why it works
- Go puts everything into one static binary. With
CGO_ENABLED=0there are no C libraries to match, so a binary built on Linux runs on any Windows machine of the same CPU type. - The Go linker can replace the value of any package-level string variable with
-X. That is why the version must be avar. - GoReleaser is only a careful wrapper around
go build,tar,zipandsha256. It reads the version from the git tag, so the tag is the single source of truth. - CI runs the same commands as you do, on a clean machine. If it works there, it does not depend on something special on your laptop.
Exercises
- Build Tasker with
-ldflagsand set onlyCommitto the output ofgit rev-parse --short HEAD. Check the result with--version. - Add
386togoarchin.goreleaser.yaml. Run the snapshot again. GoReleaser skipsdarwin_386automatically, because macOS does not support it. How many archives do you get now? - Add a
tasker versionsubcommand that prints the same line as--version. Write a test for it withrunTaskerfrom Lesson 5.3. Check that the output containsdev. - Change
ci.ymlso it tests on two Go versions with amatrix(for examplego-version: ['1.24', 'stable']). Usego-version: ${{ matrix.go-version }}instead ofgo-version-file. - Break one test on purpose and run
goreleaser release --snapshot --clean. What happens? Fix the test again.
Checkpoint
You are ready for the next module when you can:
- Explain why
Versionmust be avarand not aconst - Build Tasker for Windows from Linux or macOS
- Make a snapshot release with GoReleaser and run a binary from
dist/ - Explain when
ci.ymlruns and whenrelease.ymlruns
This is the end of Part 1. Tasker is now a tested, logged, concurrent CLI that you can ship to any user.
