Concurrency and Shipping

Ship Tasker

ReadingPreview

You are viewing a free preview lesson.

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 install with debug.ReadBuildInfo
  • How to build for other operating systems with GOOS and GOARCH
  • How to make smaller binaries with -trimpath and -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.

  • -trimpath removes the folder paths of your computer from the binary. The build is cleaner and more repeatable.
  • -s -w removes 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, darwin for macOS, windows)
  • GOARCH: the CPU type (amd64 for most PCs, arm64 for 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.hooks runs commands first. If a test fails, there is no release.
  • builds is 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.
  • archives packs each binary. Windows users get .zip, others get .tar.gz.
  • checksum writes a file with SHA-256 hashes, so users can check their download.
  • changelog builds 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
  • on says when to run: on every push to main and on every pull request.
  • actions/checkout downloads your code.
  • actions/setup-go installs Go. go-version-file: go.mod uses the version in your go.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, like v1.0.0.
  • permissions: contents: write lets the workflow create a GitHub release.
  • fetch-depth: 0 downloads the full git history. GoReleaser needs it for tags and the changelog.
  • GITHUB_TOKEN is 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=0 there 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 a var.
  • GoReleaser is only a careful wrapper around go build, tar, zip and sha256. 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

  1. Build Tasker with -ldflags and set only Commit to the output of git rev-parse --short HEAD. Check the result with --version.
  2. Add 386 to goarch in .goreleaser.yaml. Run the snapshot again. GoReleaser skips darwin_386 automatically, because macOS does not support it. How many archives do you get now?
  3. Add a tasker version subcommand that prints the same line as --version. Write a test for it with runTasker from Lesson 5.3. Check that the output contains dev.
  4. Change ci.yml so it tests on two Go versions with a matrix (for example go-version: ['1.24', 'stable']). Use go-version: ${{ matrix.go-version }} instead of go-version-file.
  5. 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 Version must be a var and not a const
  • Build Tasker for Windows from Linux or macOS
  • Make a snapshot release with GoReleaser and run a binary from dist/
  • Explain when ci.yml runs and when release.yml runs

This is the end of Part 1. Tasker is now a tested, logged, concurrent CLI that you can ship to any user.