Skip to main content
โšก Calmops

Packaging and Distribution of Go CLI Tools

Packaging and Distribution of Go CLI Tools

Introduction

Distributing Go CLI tools requires proper packaging, versioning, and release management. This guide covers building, packaging, and distributing Go applications across platforms.

Proper packaging ensures users can easily install and use your CLI tools.

Building Binaries

Cross-Platform Compilation

package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
)

// BuildConfig holds build configuration
type BuildConfig struct {
	Name    string
	Version string
	OS      string
	Arch    string
	Output  string
}

// Build builds a binary for specified platform
func Build(config BuildConfig) error {
	cmd := exec.Command("go", "build",
		"-o", config.Output,
		"-ldflags", fmt.Sprintf("-X main.Version=%s", config.Version),
	)

	cmd.Env = os.Environ()
	cmd.Env = append(cmd.Env, fmt.Sprintf("GOOS=%s", config.OS))
	cmd.Env = append(cmd.Env, fmt.Sprintf("GOARCH=%s", config.Arch))

	if err := cmd.Run(); err != nil {
		return fmt.Errorf("build failed: %w", err)
	}

	fmt.Printf("Built %s for %s/%s\n", config.Output, config.OS, config.Arch)
	return nil
}

// BuildMultiPlatform builds for multiple platforms
func BuildMultiPlatform(name, version string) error {
	platforms := []struct {
		OS   string
		Arch string
	}{
		{"linux", "amd64"},
		{"linux", "arm64"},
		{"darwin", "amd64"},
		{"darwin", "arm64"},
		{"windows", "amd64"},
	}

	for _, platform := range platforms {
		output := name
		if platform.OS == "windows" {
			output += ".exe"
		}

		config := BuildConfig{
			Name:    name,
			Version: version,
			OS:      platform.OS,
			Arch:    platform.Arch,
			Output:  filepath.Join("dist", fmt.Sprintf("%s-%s-%s", name, platform.OS, platform.Arch), output),
		}

		if err := Build(config); err != nil {
			return err
		}
	}

	return nil
}

Good: Proper Packaging Implementation

package main

import (
	"archive/tar"
	"archive/zip"
	"compress/gzip"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
)

// PackageManager handles packaging and distribution
type PackageManager struct {
	name    string
	version string
	distDir string
}

// NewPackageManager creates a new package manager
func NewPackageManager(name, version, distDir string) *PackageManager {
	return &PackageManager{
		name:    name,
		version: version,
		distDir: distDir,
	}
}

// CreateTarGz creates a tar.gz archive
func (pm *PackageManager) CreateTarGz(sourceDir, outputPath string) error {
	file, err := os.Create(outputPath)
	if err != nil {
		return fmt.Errorf("failed to create file: %w", err)
	}
	defer file.Close()

	gzipWriter := gzip.NewWriter(file)
	defer gzipWriter.Close()

	tarWriter := tar.NewWriter(gzipWriter)
	defer tarWriter.Close()

	return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		header, err := tar.FileInfoHeader(info, "")
		if err != nil {
			return err
		}

		header.Name = filepath.Join(pm.name+"-"+pm.version, strings.TrimPrefix(path, sourceDir))

		if err := tarWriter.WriteHeader(header); err != nil {
			return err
		}

		if !info.IsDir() {
			file, err := os.Open(path)
			if err != nil {
				return err
			}
			defer file.Close()

			if _, err := io.Copy(tarWriter, file); err != nil {
				return err
			}
		}

		return nil
	})
}

// CreateZip creates a zip archive
func (pm *PackageManager) CreateZip(sourceDir, outputPath string) error {
	file, err := os.Create(outputPath)
	if err != nil {
		return fmt.Errorf("failed to create file: %w", err)
	}
	defer file.Close()

	zipWriter := zip.NewWriter(file)
	defer zipWriter.Close()

	return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		header, err := zip.FileInfoHeader(info)
		if err != nil {
			return err
		}

		header.Name = filepath.Join(pm.name+"-"+pm.version, strings.TrimPrefix(path, sourceDir))

		if info.IsDir() {
			header.Name += "/"
			_, err := zipWriter.CreateHeader(header)
			return err
		}

		writer, err := zipWriter.CreateHeader(header)
		if err != nil {
			return err
		}

		file, err := os.Open(path)
		if err != nil {
			return err
		}
		defer file.Close()

		_, err = io.Copy(writer, file)
		return err
	})
}

// CreatePackage creates platform-specific package
func (pm *PackageManager) CreatePackage(os, arch string) error {
	sourceDir := filepath.Join(pm.distDir, fmt.Sprintf("%s-%s-%s", pm.name, os, arch))

	if os == "windows" {
		outputPath := filepath.Join(pm.distDir, fmt.Sprintf("%s-%s-%s.zip", pm.name, pm.version, os+"-"+arch))
		return pm.CreateZip(sourceDir, outputPath)
	}

	outputPath := filepath.Join(pm.distDir, fmt.Sprintf("%s-%s-%s.tar.gz", pm.name, pm.version, os+"-"+arch))
	return pm.CreateTarGz(sourceDir, outputPath)
}

// CreateChecksums creates SHA256 checksums
func (pm *PackageManager) CreateChecksums() error {
	checksumFile, err := os.Create(filepath.Join(pm.distDir, "SHA256SUMS"))
	if err != nil {
		return fmt.Errorf("failed to create checksum file: %w", err)
	}
	defer checksumFile.Close()

	entries, err := os.ReadDir(pm.distDir)
	if err != nil {
		return err
	}

	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}

		filePath := filepath.Join(pm.distDir, entry.Name())
		hash, err := calculateSHA256(filePath)
		if err != nil {
			return err
		}

		fmt.Fprintf(checksumFile, "%s  %s\n", hash, entry.Name())
	}

	return nil
}

// calculateSHA256 calculates SHA256 hash of file
func calculateSHA256(filePath string) (string, error) {
	import "crypto/sha256"
	import "encoding/hex"

	file, err := os.Open(filePath)
	if err != nil {
		return "", err
	}
	defer file.Close()

	hash := sha256.New()
	if _, err := io.Copy(hash, file); err != nil {
		return "", err
	}

	return hex.EncodeToString(hash.Sum(nil)), nil
}

Versioning

Version Management

package main

import (
	"fmt"
	"runtime"
	"strings"
)

// Version information
var (
	Version   = "dev"
	BuildTime = "unknown"
	GitCommit = "unknown"
)

// VersionInfo holds version information
type VersionInfo struct {
	Version   string
	BuildTime string
	GitCommit string
	GoVersion string
	OS        string
	Arch      string
}

// GetVersionInfo returns version information
func GetVersionInfo() VersionInfo {
	return VersionInfo{
		Version:   Version,
		BuildTime: BuildTime,
		GitCommit: GitCommit,
		GoVersion: runtime.Version(),
		OS:        runtime.GOOS,
		Arch:      runtime.GOARCH,
	}
}

// String returns formatted version string
func (vi VersionInfo) String() string {
	return fmt.Sprintf(`Version: %s
Build Time: %s
Git Commit: %s
Go Version: %s
OS: %s
Arch: %s`,
		vi.Version,
		vi.BuildTime,
		vi.GitCommit,
		vi.GoVersion,
		vi.OS,
		vi.Arch,
	)
}

// IsPrerelease checks if version is prerelease
func IsPrerelease(version string) bool {
	return strings.Contains(version, "-alpha") ||
		strings.Contains(version, "-beta") ||
		strings.Contains(version, "-rc")
}

// CompareVersions compares two versions
func CompareVersions(v1, v2 string) int {
	// Simplified comparison
	if v1 == v2 {
		return 0
	}
	if v1 < v2 {
		return -1
	}
	return 1
}

Release Management

Release Script

#!/bin/bash

# Build script for releasing Go CLI tool

set -e

VERSION=$1
if [ -z "$VERSION" ]; then
    echo "Usage: ./release.sh <version>"
    exit 1
fi

APP_NAME="myapp"
DIST_DIR="dist"

# Clean previous builds
rm -rf $DIST_DIR

# Create dist directory
mkdir -p $DIST_DIR

# Build for multiple platforms
PLATFORMS=(
    "linux/amd64"
    "linux/arm64"
    "darwin/amd64"
    "darwin/arm64"
    "windows/amd64"
)

for platform in "${PLATFORMS[@]}"; do
    OS=$(echo $platform | cut -d'/' -f1)
    ARCH=$(echo $platform | cut -d'/' -f2)
    
    OUTPUT_DIR="$DIST_DIR/$APP_NAME-$OS-$ARCH"
    mkdir -p $OUTPUT_DIR
    
    OUTPUT_FILE="$OUTPUT_DIR/$APP_NAME"
    if [ "$OS" = "windows" ]; then
        OUTPUT_FILE="$OUTPUT_FILE.exe"
    fi
    
    echo "Building for $OS/$ARCH..."
    GOOS=$OS GOARCH=$ARCH go build \
        -o $OUTPUT_FILE \
        -ldflags "-X main.Version=$VERSION -X main.BuildTime=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
        .
    
    # Create archive
    if [ "$OS" = "windows" ]; then
        cd $DIST_DIR
        zip -r "$APP_NAME-$VERSION-$OS-$ARCH.zip" "$APP_NAME-$OS-$ARCH"
        cd ..
    else
        tar -czf "$DIST_DIR/$APP_NAME-$VERSION-$OS-$ARCH.tar.gz" -C $DIST_DIR "$APP_NAME-$OS-$ARCH"
    fi
done

# Create checksums
cd $DIST_DIR
sha256sum *.tar.gz *.zip > SHA256SUMS
cd ..

echo "Release $VERSION built successfully!"
echo "Artifacts in $DIST_DIR"

Installation Methods

Homebrew Formula

class Myapp < Formula
  desc "My awesome CLI application"
  homepage "https://github.com/user/myapp"
  url "https://github.com/user/myapp/releases/download/v1.0.0/myapp-1.0.0-darwin-amd64.tar.gz"
  sha256 "abc123..."
  license "MIT"

  def install
    bin.install "myapp"
  end

  test do
    system "#{bin}/myapp", "--version"
  end
end

Go Install

# Users can install with:
go install github.com/user/myapp@latest

Best Practices

1. Semantic Versioning

MAJOR.MINOR.PATCH
1.2.3

2. Build Metadata

-ldflags "-X main.Version=1.0.0 -X main.BuildTime=2024-01-01"

3. Checksums

Always provide SHA256 checksums for verification.

4. Release Notes

Document changes in each release.

Common Pitfalls

1. No Version Information

Always include version information.

2. Missing Checksums

Always provide checksums for security.

3. No Release Notes

Document changes for users.

4. Incomplete Platforms

Build for common platforms.

Resources

Summary

Proper packaging and distribution are essential. Key takeaways:

  • Build for multiple platforms
  • Use semantic versioning
  • Include build metadata
  • Provide checksums
  • Create release notes
  • Support multiple installation methods
  • Test on target platforms

By mastering packaging and distribution, you can reach more users.

Comments