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