Releasing & Distributing Zig Projects

A complete reference for tagging, GitHub releases, and distributing Zig libraries and CLI tools across npm, Homebrew, AUR, apt, and Windows package managers.


Table of Contents

  1. Versioning Strategy
  2. Git Tags
  3. GitHub Releases
  4. Building for Release
  5. Zig Package Manager — Libraries
  6. GitHub Actions — Automated Release Workflow
  7. npm
  8. Homebrew
  9. AUR (Arch User Repository)
  10. apt / Debian
  11. Windows
  12. Release Checklist

1. Versioning Strategy

Semantic Versioning (SemVer)

Use MAJOR.MINOR.PATCH:

Bump When
MAJOR Breaking API change
MINOR New functionality, backward-compatible
PATCH Bug fixes, backward-compatible

Pre-release: 1.0.0-alpha.1, 1.0.0-beta.3, 1.0.0-rc.1

For libraries: SemVer is non-negotiable. Consumers pin to your version in build.zig.zon. For CLI tools: SemVer is strongly preferred; package managers like winget require it.

CalVer (alternative for CLI tools)

Format: YYYY.MM.PATCH — e.g., 2025.06.0. Useful when the tool tracks external specs or releases that are date-driven (language servers, formatters). Avoid mixing CalVer and SemVer in the same ecosystem.


2. Git Tags

Annotated vs Lightweight Tags

Prefer annotated tags for releases. They carry a tagger, date, and message — and are treated as first-class objects by git describe, GitHub Releases, and most package managers.

# annotated tag (recommended for releases)
git tag -a v1.2.0 -m "Release v1.2.0"

# lightweight tag (avoid for releases)
git tag v1.2.0

Tagging Workflow

# 1. Make sure you're on the commit you want to release
git log --oneline -5

# 2. Create annotated tag
git tag -a v1.2.0 -m "Release v1.2.0"

# 3. Push tag explicitly (git push does NOT push tags by default)
git push origin v1.2.0

# Push all local tags at once (use with care)
git push origin --tags

# 4. Verify
git tag -l "v*" --sort=-version:refname | head -5

Tagging a Past Commit

git log --oneline
# a3f9c12 Fix segfault in allocator path
# 7bc1d44 Add streaming API          <-- want to tag this

git tag -a v1.1.0 7bc1d44 -m "Release v1.1.0"
git push origin v1.1.0

Signed Tags (GPG)

git tag -s v1.2.0 -m "Release v1.2.0"   # sign with default GPG key
git tag -v v1.2.0                         # verify signature
git push origin v1.2.0

Export your public key and attach it to your GitHub profile or repo KEYS file so users can verify independently.

Deleting / Moving a Tag

# Delete locally and remotely (only before others have fetched it)
git tag -d v1.2.0
git push origin --delete v1.2.0

# Recreate on correct commit
git tag -a v1.2.0 <correct-sha> -m "Release v1.2.0"
git push origin v1.2.0

Never re-tag a version that has already been distributed via a package manager. Bump to a patch version instead.


3. GitHub Releases

Via GitHub UI

  1. Go to Releases → Draft a new release
  2. Choose your tag (or create it here)
  3. Set target branch/commit
  4. Write release notes (supports Markdown)
  5. Attach binary assets (.tar.gz, .zip, .deb, etc.)
  6. Publish or save as draft

GitHub auto-generates a source tarball at:

https://github.com/USER/REPO/archive/refs/tags/v1.2.0.tar.gz

Via GitHub CLI

# Create release from existing tag
gh release create v1.2.0 \
  --title "v1.2.0" \
  --notes "Bug fixes and performance improvements" \
  ./dist/my-tool-linux-x86_64.tar.gz \
  ./dist/my-tool-macos-aarch64.tar.gz \
  ./dist/my-tool-windows-x86_64.zip

# Auto-generate release notes from PRs/commits
gh release create v1.2.0 --generate-notes

# Create as draft first, review, then publish
gh release create v1.2.0 --draft --generate-notes
gh release edit v1.2.0 --draft=false

# Upload additional assets to an existing release
gh release upload v1.2.0 ./dist/my-tool-linux-aarch64.tar.gz

Release Notes Structure

## What's Changed

### Breaking Changes
- `foo()` now returns `error.OutOfMemory` instead of panicking

### New Features
- Added `bar()` for streaming output (#42)
- Windows ARM64 binaries now included

### Bug Fixes
- Fixed null pointer in connection pool cleanup (#37)
- Corrected byte order in packet header (#39)

### Performance
- 2× faster tokenizer via SIMD path on x86_64

## Installation

See [README.md](README.md#installation) or use one of the methods below.

**Zig package manager:**

zig fetch --save https://github.com/user/repo/archive/refs/tags/v1.2.0.tar.gz

**Full Changelog:** https://github.com/USER/REPO/compare/v1.1.0...v1.2.0

4. Building for Release

Zig Optimize Modes

Flag Use Case
Debug Development, assertions enabled, no optimization
ReleaseSafe Production with bounds checking; good default for libraries
ReleaseFast Maximum performance, no safety checks
ReleaseSmall Minimize binary size

For CLI tools shipped as binaries: ReleaseFast is standard. For libraries: let the consumer choose via -Doptimize=.

Cross-Compilation Target Strings

Zig targets follow the triple cpu-os-abi:

# Linux — static (musl) binaries, maximally portable
x86_64-linux-musl
aarch64-linux-musl

# Linux — dynamic (glibc), requires matching glibc version on target
x86_64-linux-gnu
aarch64-linux-gnu

# macOS
x86_64-macos
aarch64-macos   # Apple Silicon

# Windows
x86_64-windows-gnu    # MinGW ABI, no MSVC dependency
x86_64-windows-msvc   # Requires MSVC (usually avoid for distribution)
aarch64-windows-gnu

# WebAssembly
wasm32-freestanding   # no OS
wasm32-wasi           # WASI interface
wasm32-emscripten

Use musl for Linux distribution binaries. glibc-linked binaries will fail on systems with an older glibc than what you compiled against. musl produces static binaries that run anywhere.

Building

# Local release build
zig build -Doptimize=ReleaseFast

# Cross-compile for a specific target
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseFast

# Output lands in zig-out/bin/
ls zig-out/bin/

Strip and Compress

Zig strips debug info automatically in ReleaseFast and ReleaseSmall. To further reduce binary size:

# Strip (if not already stripped)
strip zig-out/bin/my-tool

# UPX compress (optional, tradeoff: slower startup, antivirus false positives)
upx --best zig-out/bin/my-tool

Packaging Artifacts

VERSION=1.2.0

# Linux/macOS — tar.gz
tar czf my-tool-linux-x86_64-v${VERSION}.tar.gz -C zig-out/bin my-tool

# Include license and readme
tar czf my-tool-linux-x86_64-v${VERSION}.tar.gz \
  -C zig-out/bin my-tool \
  LICENSE README.md

# Windows — zip
zip my-tool-windows-x86_64-v${VERSION}.zip zig-out/bin/my-tool.exe

# Generate checksums
sha256sum my-tool-*.tar.gz my-tool-*.zip > checksums.txt

Always ship a checksums.txt alongside binaries. Package managers require these hashes.


5. Zig Package Manager — Libraries

build.zig.zon Structure (Zig 0.14+)

.{
    // Atom syntax (not a string) — introduced in 0.14.0
    .name = .my_library,
    .version = "1.2.0",
    .minimum_zig_version = "0.14.0",

    .dependencies = .{
        // Example external dependency
        .zflags = .{
            .url = "https://github.com/user/zflags/archive/refs/tags/v0.3.0.tar.gz",
            .hash = "zflags-0.3.0-...",  // filled by zig fetch
        },
    },

    // Files included when someone fetches your package.
    // Keep this tight — exclude tests, benchmarks, CI, docs if not needed by consumers.
    .paths = .{
        "build.zig",
        "build.zig.zon",
        "src",
        "LICENSE",
    },
}

build.zig — Exposing a Module

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // The module consumers import
    const lib_mod = b.addModule("my_library", .{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });

    // Optional: build a static lib artifact for C consumers
    const lib = b.addStaticLibrary(.{
        .name = "my_library",
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(lib);

    // Tests
    const unit_tests = b.addTest(.{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });
    const run_unit_tests = b.addRunArtifact(unit_tests);
    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_unit_tests.step);

    _ = lib_mod; // suppress unused warning if no further use
}

How Consumers Fetch Your Library

# Fetch and save to build.zig.zon in one step
zig fetch --save https://github.com/user/my-library/archive/refs/tags/v1.2.0.tar.gz

# Or fetch via git ref
zig fetch --save git+https://github.com/user/my-library#v1.2.0

This automatically adds the dependency and hash to the consumer’s build.zig.zon.

Consumer’s build.zig:

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "my-app",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    // Pull in the library
    const my_library = b.dependency("my_library", .{
        .target = target,
        .optimize = optimize,
    });
    exe.root_module.addImport("my_library", my_library.module("my_library"));

    b.installArtifact(exe);
}

Library Release Checklist

  • Bump .version in build.zig.zon
  • Update CHANGELOG.md
  • Run zig build test cleanly
  • Verify .paths in build.zig.zon includes everything consumers need (and nothing extra)
  • Verify the package builds from a clean zig fetch (test in a throwaway project)
  • Push tag → GitHub Release
  • No further changes needed — consumers fetch directly from the tarball URL

Zig has no central package registry (like crates.io or npm). Your GitHub release tag IS the distribution mechanism. Make sure your tags are stable and never rewritten.


6. GitHub Actions — Automated Release Workflow

Repository Layout

.github/
  workflows/
    ci.yml          # runs on every push/PR
    release.yml     # runs only on version tags

ci.yml — Continuous Integration

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4

      - name: Install Zig
        uses: mlugg/setup-zig@v1
        with:
          version: "0.14.0"  # pin to your minimum_zig_version

      - name: Build
        run: zig build

      - name: Test
        run: zig build test

release.yml — Full Cross-Compiled Release

name: Release

on:
  push:
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"           # v1.2.0
      - "v[0-9]+.[0-9]+.[0-9]+-*"          # v1.2.0-rc.1

env:
  ZIG_VERSION: "0.14.0"

jobs:
  # ── Build matrix ──────────────────────────────────────────────────────────
  build:
    name: Build ${{ matrix.target }}
    runs-on: ${{ matrix.runner }}
    strategy:
      fail-fast: false
      matrix:
        include:
          # Linux (cross-compile from Ubuntu, musl for max portability)
          - target: x86_64-linux-musl
            runner: ubuntu-latest
            artifact_name: my-tool-linux-x86_64
            binary: my-tool

          - target: aarch64-linux-musl
            runner: ubuntu-latest
            artifact_name: my-tool-linux-aarch64
            binary: my-tool

          # macOS — build on macOS runner for SDK access
          - target: x86_64-macos
            runner: macos-13          # Intel runner
            artifact_name: my-tool-macos-x86_64
            binary: my-tool

          - target: aarch64-macos
            runner: macos-latest      # Apple Silicon runner
            artifact_name: my-tool-macos-aarch64
            binary: my-tool

          # Windows
          - target: x86_64-windows-gnu
            runner: ubuntu-latest     # cross-compile from Linux
            artifact_name: my-tool-windows-x86_64
            binary: my-tool.exe

          - target: aarch64-windows-gnu
            runner: ubuntu-latest
            artifact_name: my-tool-windows-aarch64
            binary: my-tool.exe

    steps:
      - uses: actions/checkout@v4

      - name: Install Zig
        uses: mlugg/setup-zig@v1
        with:
          version: ${{ env.ZIG_VERSION }}

      - name: Build
        run: |
          zig build \
            -Dtarget=${{ matrix.target }} \
            -Doptimize=ReleaseFast

      - name: Package (Unix)
        if: ${{ !contains(matrix.target, 'windows') }}
        run: |
          mkdir -p dist
          tar czf dist/${{ matrix.artifact_name }}.tar.gz \
            -C zig-out/bin ${{ matrix.binary }} \
            -C ${{ github.workspace }} LICENSE README.md

      - name: Package (Windows)
        if: ${{ contains(matrix.target, 'windows') }}
        run: |
          mkdir -p dist
          zip -j dist/${{ matrix.artifact_name }}.zip \
            zig-out/bin/${{ matrix.binary }} \
            LICENSE README.md

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.artifact_name }}
          path: dist/
          retention-days: 1

  # ── Create GitHub Release ─────────────────────────────────────────────────
  release:
    name: Create Release
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4

      - name: Download all artifacts
        uses: actions/download-artifact@v4
        with:
          path: dist/
          merge-multiple: true

      - name: Generate checksums
        run: |
          cd dist
          sha256sum *.tar.gz *.zip | tee checksums.txt

      - name: Create Release
        uses: softprops/action-gh-release@v2
        with:
          files: |
            dist/*.tar.gz
            dist/*.zip
            dist/checksums.txt
          generate_release_notes: true
          draft: false
          prerelease: ${{ contains(github.ref_name, '-') }}

Triggering a Release

git tag -a v1.2.0 -m "Release v1.2.0"
git push origin v1.2.0
# GitHub Actions picks up the tag and runs release.yml

7. npm

Distributing a Zig CLI tool via npm allows JavaScript/Node.js users to install it with npm install -g my-tool or use it in package.json scripts without knowing about Zig.

Package Structure

my-tool/                        # root package (published as "my-tool")
  package.json
  bin/
    my-tool                     # JS wrapper script
  install.js                    # postinstall — resolves correct platform binary
  README.md

npm/
  my-tool-linux-x64/            # platform package
    package.json
    my-tool                     # actual Zig binary
  my-tool-linux-arm64/
    package.json
    my-tool
  my-tool-darwin-x64/
    package.json
    my-tool
  my-tool-darwin-arm64/
    package.json
    my-tool
  my-tool-win32-x64/
    package.json
    my-tool.exe

Platform Package package.json

Each platform package declares os and cpu so npm only installs the right one:

{
  "name": "my-tool-linux-x64",
  "version": "1.2.0",
  "description": "Linux x64 binary for my-tool",
  "os": ["linux"],
  "cpu": ["x64"],
  "files": ["my-tool"],
  "license": "MIT"
}

Map Zig targets to npm platform names:

Zig Target npm os npm cpu
x86_64-linux-musl linux x64
aarch64-linux-musl linux arm64
x86_64-macos darwin x64
aarch64-macos darwin arm64
x86_64-windows-gnu win32 x64
aarch64-windows-gnu win32 arm64

Root Package package.json

{
  "name": "my-tool",
  "version": "1.2.0",
  "description": "A fast CLI tool written in Zig",
  "bin": {
    "my-tool": "bin/my-tool"
  },
  "scripts": {
    "postinstall": "node install.js"
  },
  "optionalDependencies": {
    "my-tool-linux-x64":   "1.2.0",
    "my-tool-linux-arm64": "1.2.0",
    "my-tool-darwin-x64":  "1.2.0",
    "my-tool-darwin-arm64":"1.2.0",
    "my-tool-win32-x64":   "1.2.0",
    "my-tool-win32-arm64": "1.2.0"
  },
  "files": ["bin", "install.js"],
  "engines": { "node": ">=18" },
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/user/my-tool"
  }
}

install.js — Postinstall Script

const { execSync } = require("child_process");
const path = require("path");
const fs = require("fs");

const PLATFORMS = {
  "linux-x64":    "my-tool-linux-x64",
  "linux-arm64":  "my-tool-linux-arm64",
  "darwin-x64":   "my-tool-darwin-x64",
  "darwin-arm64": "my-tool-darwin-arm64",
  "win32-x64":    "my-tool-win32-x64",
  "win32-arm64":  "my-tool-win32-arm64",
};

const key = `${process.platform}-${process.arch}`;
const pkgName = PLATFORMS[key];

if (!pkgName) {
  console.error(`my-tool: unsupported platform ${key}`);
  process.exit(1);
}

let pkgDir;
try {
  pkgDir = path.dirname(require.resolve(`${pkgName}/package.json`));
} catch {
  console.error(`my-tool: could not find platform package ${pkgName}.`);
  console.error("Try: npm install --ignore-scripts && npm run postinstall");
  process.exit(1);
}

const ext = process.platform === "win32" ? ".exe" : "";
const src = path.join(pkgDir, `my-tool${ext}`);
const binDir = path.join(__dirname, "bin");
const dest = path.join(binDir, `my-tool${ext}`);

fs.mkdirSync(binDir, { recursive: true });
fs.copyFileSync(src, dest);
fs.chmodSync(dest, 0o755);

console.log(`my-tool: installed ${key} binary`);

bin/my-tool — Thin JS Shim (Alternative Approach)

Instead of copying via postinstall, you can use a shim that resolves at runtime. This avoids postinstall but adds a small Node.js startup cost:

#!/usr/bin/env node
"use strict";
const { spawnSync } = require("child_process");
const path = require("path");

const PLATFORMS = {
  "linux-x64":    ["my-tool-linux-x64", "my-tool"],
  "darwin-arm64": ["my-tool-darwin-arm64", "my-tool"],
  "win32-x64":    ["my-tool-win32-x64",   "my-tool.exe"],
  // ... etc.
};

const key = `${process.platform}-${process.arch}`;
const entry = PLATFORMS[key];
if (!entry) { console.error(`Unsupported: ${key}`); process.exit(1); }

const [pkg, bin] = entry;
const binPath = require.resolve(`${pkg}/${bin}`);
const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" });
process.exit(result.status ?? 1);

Publishing

# Publish platform packages first
cd npm/my-tool-linux-x64
# Copy the binary built by CI
cp ../../dist/my-tool-linux-x86_64/my-tool ./my-tool
chmod +x my-tool
npm publish --access public

# Repeat for each platform...

# Then publish the root package
cd ../../
npm publish --access public

Automating npm Publish in GitHub Actions

Add to release.yml after the release job:

  npm-publish:
    name: Publish to npm
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/download-artifact@v4
        with:
          path: dist/
          merge-multiple: true

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          registry-url: "https://registry.npmjs.org"

      - name: Publish platform packages
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          VERSION="${{ github.ref_name }}"
          VERSION="${VERSION#v}"   # strip leading 'v'

          declare -A TARGETS=(
            ["my-tool-linux-x64"]="dist/my-tool-linux-x86_64/my-tool"
            ["my-tool-linux-arm64"]="dist/my-tool-linux-aarch64/my-tool"
            ["my-tool-darwin-x64"]="dist/my-tool-macos-x86_64/my-tool"
            ["my-tool-darwin-arm64"]="dist/my-tool-macos-aarch64/my-tool"
            ["my-tool-win32-x64"]="dist/my-tool-windows-x86_64/my-tool.exe"
          )

          for pkg in "${!TARGETS[@]}"; do
            binary="${TARGETS[$pkg]}"
            dir="npm/${pkg}"
            cp "$binary" "$dir/"
            # update version
            jq --arg v "$VERSION" '.version = $v' "$dir/package.json" > /tmp/pkg.json
            mv /tmp/pkg.json "$dir/package.json"
            (cd "$dir" && npm publish --access public)
          done

      - name: Publish root package
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          VERSION="${{ github.ref_name }}"
          VERSION="${VERSION#v}"
          jq --arg v "$VERSION" '
            .version = $v |
            .optionalDependencies |= with_entries(.value = $v)
          ' package.json > /tmp/pkg.json
          mv /tmp/pkg.json package.json
          npm publish --access public

8. Homebrew

Homebrew supports Linux and macOS. The cleanest approach for distributing pre-built Zig binaries is a personal tap with a relocatable binary formula.

Create a Tap

A tap is a GitHub repo named homebrew-<name> (e.g., homebrew-tap):

# Users add your tap with:
brew tap user/tap
# which points to https://github.com/user/homebrew-tap

# Formula files live at:
# homebrew-tap/Formula/my-tool.rb

Formula with Pre-Built Binaries

# Formula/my-tool.rb
class MyTool < Formula
  desc "A fast CLI tool written in Zig"
  homepage "https://github.com/user/my-tool"
  version "1.2.0"
  license "MIT"

  on_macos do
    on_arm do
      url "https://github.com/user/my-tool/releases/download/v#{version}/my-tool-macos-aarch64.tar.gz"
      sha256 "REPLACE_WITH_ACTUAL_SHA256"
    end
    on_intel do
      url "https://github.com/user/my-tool/releases/download/v#{version}/my-tool-macos-x86_64.tar.gz"
      sha256 "REPLACE_WITH_ACTUAL_SHA256"
    end
  end

  on_linux do
    on_arm do
      url "https://github.com/user/my-tool/releases/download/v#{version}/my-tool-linux-aarch64.tar.gz"
      sha256 "REPLACE_WITH_ACTUAL_SHA256"
    end
    on_intel do
      url "https://github.com/user/my-tool/releases/download/v#{version}/my-tool-linux-x86_64.tar.gz"
      sha256 "REPLACE_WITH_ACTUAL_SHA256"
    end
  end

  def install
    bin.install "my-tool"
  end

  test do
    assert_match version.to_s, shell_output("#{bin}/my-tool --version")
  end
end

Formula Built from Source (Alternative)

If you want brew to compile from source (requires Zig in PATH):

class MyTool < Formula
  desc "A fast CLI tool written in Zig"
  homepage "https://github.com/user/my-tool"
  url "https://github.com/user/my-tool/archive/refs/tags/v1.2.0.tar.gz"
  sha256 "REPLACE_WITH_ACTUAL_SHA256"
  version "1.2.0"
  license "MIT"
  head "https://github.com/user/my-tool.git", branch: "main"

  depends_on "zig" => :build

  def install
    system "zig", "build", "-Doptimize=ReleaseFast", "--prefix", prefix
  end

  test do
    assert_match "1.2.0", shell_output("#{bin}/my-tool --version")
  end
end

Note: depends_on "zig" requires Zig to be in homebrew-core or your tap. The pre-built binary approach avoids this dependency.

Automating Formula Updates via GitHub Actions

  update-homebrew:
    name: Update Homebrew Formula
    needs: [build, release]
    runs-on: ubuntu-latest
    steps:
      - name: Checkout tap repo
        uses: actions/checkout@v4
        with:
          repository: user/homebrew-tap
          token: ${{ secrets.TAP_GITHUB_TOKEN }}

      - name: Download checksums
        run: |
          VERSION="${{ github.ref_name }}"
          VERSION="${VERSION#v}"
          BASE="https://github.com/user/my-tool/releases/download/v${VERSION}"
          curl -L "${BASE}/checksums.txt" -o checksums.txt

      - name: Update formula
        run: |
          VERSION="${{ github.ref_name }}"
          VERSION="${VERSION#v}"

          SHA_MACOS_ARM=$(grep "macos-aarch64.tar.gz" checksums.txt | awk '{print $1}')
          SHA_MACOS_X64=$(grep "macos-x86_64.tar.gz"  checksums.txt | awk '{print $1}')
          SHA_LINUX_ARM=$(grep "linux-aarch64.tar.gz" checksums.txt | awk '{print $1}')
          SHA_LINUX_X64=$(grep "linux-x86_64.tar.gz"  checksums.txt | awk '{print $1}')

          FORMULA="Formula/my-tool.rb"

          # Update version
          sed -i "s/version \".*\"/version \"${VERSION}\"/" "$FORMULA"

          # Update SHA256s — assumes they are in order in the file
          # A more robust approach: use yq/ruby to parse and replace
          python3 - <<EOF
          import re

          with open("$FORMULA", "r") as f:
              content = f.read()

          replacements = [
              ("aarch64-macos", "$SHA_MACOS_ARM"),
              ("x86_64-macos",  "$SHA_MACOS_X64"),
              ("aarch64-linux", "$SHA_LINUX_ARM"),
              ("x86_64-linux",  "$SHA_LINUX_X64"),
          ]

          # Pattern: sha256 "..." following a specific URL
          # This is simplified — real scripts are more surgical
          sha_pattern = r'sha256 "[a-f0-9]{64}"'
          shas = ["$SHA_MACOS_ARM", "$SHA_MACOS_X64", "$SHA_LINUX_ARM", "$SHA_LINUX_X64"]
          idx = 0
          def replace_sha(m):
              global idx
              r = f'sha256 "{shas[idx]}"'
              idx += 1
              return r
          content = re.sub(sha_pattern, replace_sha, content)

          with open("$FORMULA", "w") as f:
              f.write(content)
          EOF

      - name: Commit and push
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add Formula/my-tool.rb
          git commit -m "my-tool: update to v${{ github.ref_name }}"
          git push

Installation by Users

brew tap user/tap
brew install my-tool

# Or one-liner
brew install user/tap/my-tool

9. AUR (Arch User Repository)

AUR packages are built by users from PKGBUILD scripts. Two common variants:

  • my-tool — builds from source using the AUR helper
  • my-tool-bin — installs pre-built binaries (faster, preferred for CI-distributed tools)

Prerequisites

# Create an AUR account at https://aur.archlinux.org
# Add your SSH public key to your AUR account

Submitting a Package

# Clone the (empty) AUR repo for your package name
git clone ssh://[email protected]/my-tool.git
cd my-tool

# Add your PKGBUILD and .SRCINFO
# ...

git add PKGBUILD .SRCINFO
git commit -m "Initial release"
git push

PKGBUILD — Build from Source

# Maintainer: Your Name <[email protected]>
pkgname=my-tool
pkgver=1.2.0
pkgrel=1
pkgdesc="A fast CLI tool written in Zig"
arch=('x86_64' 'aarch64')
url="https://github.com/user/my-tool"
license=('MIT')
makedepends=('zig')
options=('!strip')   # Zig strips in ReleaseFast already
source=("$pkgname-$pkgver.tar.gz::https://github.com/user/$pkgname/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('REPLACE_WITH_SHA256')

build() {
    cd "$pkgname-$pkgver"
    zig build -Doptimize=ReleaseFast
}

check() {
    cd "$pkgname-$pkgver"
    zig build test
}

package() {
    cd "$pkgname-$pkgver"
    install -Dm755 "zig-out/bin/$pkgname" "$pkgdir/usr/bin/$pkgname"
    install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
    install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
}

PKGBUILD — Pre-Built Binary (my-tool-bin)

# Maintainer: Your Name <[email protected]>
pkgname=my-tool-bin
pkgver=1.2.0
pkgrel=1
pkgdesc="A fast CLI tool written in Zig (pre-built binary)"
arch=('x86_64' 'aarch64')
url="https://github.com/user/my-tool"
license=('MIT')
provides=('my-tool')
conflicts=('my-tool')
options=('!strip')

source_x86_64=(
    "my-tool-linux-x86_64.tar.gz::https://github.com/user/my-tool/releases/download/v$pkgver/my-tool-linux-x86_64.tar.gz"
)
sha256sums_x86_64=('REPLACE_WITH_SHA256_X86_64')

source_aarch64=(
    "my-tool-linux-aarch64.tar.gz::https://github.com/user/my-tool/releases/download/v$pkgver/my-tool-linux-aarch64.tar.gz"
)
sha256sums_aarch64=('REPLACE_WITH_SHA256_AARCH64')

package() {
    install -Dm755 my-tool "$pkgdir/usr/bin/my-tool"
}

Generate .SRCINFO

# Required by AUR — must be regenerated after every PKGBUILD change
makepkg --printsrcinfo > .SRCINFO
git add PKGBUILD .SRCINFO
git commit -m "my-tool-bin 1.2.0"
git push

Updating an Existing Package

cd my-tool-bin/

# Edit PKGBUILD: update pkgver, pkgrel (reset to 1 for new version), sha256sums
nano PKGBUILD

# Regenerate .SRCINFO
makepkg --printsrcinfo > .SRCINFO

# Verify it builds
makepkg -si

git add PKGBUILD .SRCINFO
git commit -m "upgpkg: my-tool-bin 1.2.0 → 1.3.0"
git push

Automate .SRCINFO in CI

  update-aur:
    name: Update AUR
    needs: release
    runs-on: ubuntu-latest
    container: archlinux:latest
    steps:
      - name: Install tools
        run: pacman -Sy --noconfirm git openssh base-devel

      - name: Configure SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.AUR_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts

      - name: Clone AUR repo
        run: git clone ssh://[email protected]/my-tool-bin.git

      - name: Update PKGBUILD
        run: |
          VERSION="${{ github.ref_name }}"
          VERSION="${VERSION#v}"
          cd my-tool-bin

          # Fetch checksums
          BASE="https://github.com/user/my-tool/releases/download/v${VERSION}"
          curl -L "${BASE}/checksums.txt" -o checksums.txt

          SHA_X86=$(grep "linux-x86_64.tar.gz" checksums.txt | awk '{print $1}')
          SHA_ARM=$(grep "linux-aarch64.tar.gz" checksums.txt | awk '{print $1}')

          sed -i "s/^pkgver=.*/pkgver=${VERSION}/"   PKGBUILD
          sed -i "s/^pkgrel=.*/pkgrel=1/"             PKGBUILD

          # Update sha256sums_x86_64 and sha256sums_aarch64
          sed -i "s/^sha256sums_x86_64=.*/sha256sums_x86_64=('${SHA_X86}')/" PKGBUILD
          sed -i "s/^sha256sums_aarch64=.*/sha256sums_aarch64=('${SHA_ARM}')/" PKGBUILD

          # Generate .SRCINFO
          makepkg --printsrcinfo > .SRCINFO

          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add PKGBUILD .SRCINFO
          git commit -m "upgpkg: my-tool-bin ${VERSION}"
          git push

10. apt / Debian

There are three approaches, in increasing complexity:

Approach Effort Reach
Custom apt repo (GitHub Pages / R2) Medium Anyone who adds your repo
Launchpad PPA High Ubuntu users, add-apt-repository
Submit to Debian Very High All Debian/Ubuntu users (upstream)

Building a .deb Package

Directory structure:

packaging/deb/
  my-tool_1.2.0_amd64/
    DEBIAN/
      control
      postinst          # (optional) post-install script
    usr/
      bin/
        my-tool
      share/
        doc/
          my-tool/
            copyright
            changelog.Debian.gz
        man/
          man1/
            my-tool.1.gz   # (optional) manpage

DEBIAN/control:

Package: my-tool
Version: 1.2.0
Architecture: amd64
Maintainer: Your Name <[email protected]>
Installed-Size: 2048
Description: A fast CLI tool written in Zig
 Single-line summary here.
 .
 Longer description. Lines after the first start with a space.
 Blank lines within the description use " ." (space-dot).
Homepage: https://github.com/user/my-tool
Section: utils
Priority: optional

For aarch64, use Architecture: arm64.

usr/share/doc/my-tool/copyright:

Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: my-tool
Source: https://github.com/user/my-tool

Files: *
Copyright: 2025 Your Name <[email protected]>
License: MIT

Build the .deb:

VERSION=1.2.0

# Set up directory structure
PKGDIR="packaging/deb/my-tool_${VERSION}_amd64"
mkdir -p "$PKGDIR/DEBIAN"
mkdir -p "$PKGDIR/usr/bin"
mkdir -p "$PKGDIR/usr/share/doc/my-tool"

# Copy binary
cp zig-out/bin/my-tool "$PKGDIR/usr/bin/my-tool"
chmod 755 "$PKGDIR/usr/bin/my-tool"

# Write control file (substitute values)
cat > "$PKGDIR/DEBIAN/control" <<EOF
Package: my-tool
Version: ${VERSION}
Architecture: amd64
...
EOF

# Write copyright
cp LICENSE "$PKGDIR/usr/share/doc/my-tool/copyright"

# Build .deb
dpkg-deb --build --root-owner-group "$PKGDIR"
# Produces: my-tool_1.2.0_amd64.deb

# Validate
dpkg-deb --info my-tool_1.2.0_amd64.deb
lintian my-tool_1.2.0_amd64.deb

Hosting a Custom apt Repository on GitHub Pages

Repository structure:

apt-repo/                    # GitHub repo, Pages enabled from main branch
  dists/
    stable/
      Release
      Release.gpg
      InRelease
      main/
        binary-amd64/
          Packages
          Packages.gz
        binary-arm64/
          Packages
          Packages.gz
  pool/
    main/
      m/
        my-tool/
          my-tool_1.2.0_amd64.deb
          my-tool_1.2.0_arm64.deb

Setting up with reprepro:

# Install reprepro
apt-get install reprepro

# conf/distributions:
cat > conf/distributions <<EOF
Codename: stable
Components: main
Architectures: amd64 arm64
SignWith: YOUR_GPG_KEY_ID
EOF

# Add a .deb
reprepro includedeb stable my-tool_1.2.0_amd64.deb
reprepro includedeb stable my-tool_1.2.0_arm64.deb

User installation:

# Import your public GPG key
curl -fsSL https://user.github.io/apt-repo/key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/my-tool.gpg

# Add the repo
echo "deb [signed-by=/usr/share/keyrings/my-tool.gpg] https://user.github.io/apt-repo stable main" \
  | sudo tee /etc/apt/sources.list.d/my-tool.list

sudo apt update
sudo apt install my-tool

Automate .deb Build in GitHub Actions

  build-deb:
    name: Build .deb (${{ matrix.arch }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - arch: amd64
            zig_target: x86_64-linux-musl
          - arch: arm64
            zig_target: aarch64-linux-musl
    steps:
      - uses: actions/checkout@v4

      - uses: mlugg/setup-zig@v1
        with:
          version: "0.14.0"

      - name: Build binary
        run: |
          zig build -Dtarget=${{ matrix.zig_target }} -Doptimize=ReleaseFast

      - name: Build .deb
        run: |
          VERSION="${{ github.ref_name }}"
          VERSION="${VERSION#v}"
          ARCH="${{ matrix.arch }}"
          PKGDIR="my-tool_${VERSION}_${ARCH}"

          mkdir -p "$PKGDIR/DEBIAN"
          mkdir -p "$PKGDIR/usr/bin"
          mkdir -p "$PKGDIR/usr/share/doc/my-tool"

          cp zig-out/bin/my-tool "$PKGDIR/usr/bin/"
          chmod 755 "$PKGDIR/usr/bin/my-tool"
          cp LICENSE "$PKGDIR/usr/share/doc/my-tool/copyright"

          cat > "$PKGDIR/DEBIAN/control" <<EOF
          Package: my-tool
          Version: ${VERSION}
          Architecture: ${ARCH}
          Maintainer: Your Name <[email protected]>
          Description: A fast CLI tool written in Zig
          EOF

          dpkg-deb --build --root-owner-group "$PKGDIR"

      - uses: actions/upload-artifact@v4
        with:
          name: deb-${{ matrix.arch }}
          path: "*.deb"

11. Windows

Winget (Windows Package Manager)

Winget is the official Windows package manager. Packages live in a public GitHub repo (microsoft/winget-pkgs) and are reviewed by Microsoft.

Manifest format (manifests/u/User/MyTool/1.2.0/):

User.MyTool.installer.yaml:

PackageIdentifier: User.MyTool
PackageVersion: 1.2.0
Platform:
  - Windows.Desktop
MinimumOSVersion: "10.0.17763.0"
InstallerType: zip
Installers:
  - Architecture: x64
    InstallerUrl: https://github.com/user/my-tool/releases/download/v1.2.0/my-tool-windows-x86_64.zip
    InstallerSha256: REPLACE_WITH_SHA256
    InstallerType: zip
    NestedInstallerType: portable
    NestedInstallerFiles:
      - RelativeFilePath: my-tool.exe
        PortableCommandAlias: my-tool
  - Architecture: arm64
    InstallerUrl: https://github.com/user/my-tool/releases/download/v1.2.0/my-tool-windows-aarch64.zip
    InstallerSha256: REPLACE_WITH_SHA256
    InstallerType: zip
    NestedInstallerType: portable
    NestedInstallerFiles:
      - RelativeFilePath: my-tool.exe
        PortableCommandAlias: my-tool
ManifestType: installer
ManifestVersion: 1.6.0

User.MyTool.locale.en-US.yaml:

PackageIdentifier: User.MyTool
PackageVersion: 1.2.0
PackageLocale: en-US
Publisher: User
PublisherUrl: https://github.com/user
PackageName: MyTool
PackageUrl: https://github.com/user/my-tool
License: MIT
LicenseUrl: https://github.com/user/my-tool/blob/main/LICENSE
ShortDescription: A fast CLI tool written in Zig
Description: Longer description here.
Tags:
  - cli
  - zig
ManifestType: defaultLocale
ManifestVersion: 1.6.0

User.MyTool.yaml:

PackageIdentifier: User.MyTool
PackageVersion: 1.2.0
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.6.0

Submit:

# Use winget-create to auto-generate manifests
winget-create new https://github.com/user/my-tool/releases/download/v1.2.0/my-tool-windows-x86_64.zip

# Or open a PR manually to microsoft/winget-pkgs

Installation by users:

winget install User.MyTool
winget upgrade User.MyTool

Scoop (Community Package Manager)

Scoop is bucket-based — create a GitHub repo named scoop-<name> (e.g., scoop-bucket).

bucket/my-tool.json:

{
    "version": "1.2.0",
    "description": "A fast CLI tool written in Zig",
    "homepage": "https://github.com/user/my-tool",
    "license": "MIT",
    "architecture": {
        "64bit": {
            "url": "https://github.com/user/my-tool/releases/download/v1.2.0/my-tool-windows-x86_64.zip",
            "hash": "REPLACE_WITH_SHA256"
        },
        "arm64": {
            "url": "https://github.com/user/my-tool/releases/download/v1.2.0/my-tool-windows-aarch64.zip",
            "hash": "REPLACE_WITH_SHA256"
        }
    },
    "bin": "my-tool.exe",
    "checkver": {
        "github": "https://github.com/user/my-tool"
    },
    "autoupdate": {
        "architecture": {
            "64bit": {
                "url": "https://github.com/user/my-tool/releases/download/v$version/my-tool-windows-x86_64.zip"
            },
            "arm64": {
                "url": "https://github.com/user/my-tool/releases/download/v$version/my-tool-windows-aarch64.zip"
            }
        }
    }
}

Installation by users:

scoop bucket add user https://github.com/user/scoop-bucket
scoop install my-tool

# Once in the main bucket:
scoop install my-tool

Chocolatey

Chocolatey packages are NuGet-based. Create an account at chocolatey.org.

Package structure:

my-tool/
  my-tool.nuspec
  tools/
    chocolateyInstall.ps1
    chocolateyUninstall.ps1  (optional)
    LICENSE.txt

my-tool.nuspec:

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
  <metadata>
    <id>my-tool</id>
    <version>1.2.0</version>
    <title>my-tool</title>
    <authors>Your Name</authors>
    <projectUrl>https://github.com/user/my-tool</projectUrl>
    <licenseUrl>https://github.com/user/my-tool/blob/main/LICENSE</licenseUrl>
    <requireLicenseAcceptance>false</requireLicenseAcceptance>
    <description>A fast CLI tool written in Zig</description>
    <tags>cli zig</tags>
    <releaseNotes>https://github.com/user/my-tool/releases/tag/v1.2.0</releaseNotes>
  </metadata>
  <files>
    <file src="tools\**" target="tools" />
  </files>
</package>

tools/chocolateyInstall.ps1:

$ErrorActionPreference = 'Stop'
$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"

$packageArgs = @{
    packageName    = $env:ChocolateyPackageName
    unzipLocation  = $toolsDir
    fileType       = 'zip'
    url64bit       = 'https://github.com/user/my-tool/releases/download/v1.2.0/my-tool-windows-x86_64.zip'
    checksum64     = 'REPLACE_WITH_SHA256'
    checksumType64 = 'sha256'
}

Install-ChocolateyZipPackage @packageArgs

# Add to PATH
$exePath = Join-Path $toolsDir "my-tool.exe"
Install-BinFile -Name "my-tool" -Path $exePath

Build and publish:

# Build .nupkg
choco pack my-tool.nuspec

# Test locally
choco install my-tool -dv --source="'.'"

# Publish (requires API key)
choco apikey --key YOUR_KEY --source https://push.chocolatey.org/
choco push my-tool.1.2.0.nupkg --source https://push.chocolatey.org/

Installation by users:

choco install my-tool
choco upgrade my-tool

PowerShell One-Liner Installer (Bonus)

Ship a simple install.ps1 for users who don’t use a package manager:

# install.ps1 — hosted in your repo
param(
    [string]$Version = "latest",
    [string]$InstallDir = "$env:USERPROFILE\.local\bin"
)

$ErrorActionPreference = "Stop"
$repo = "user/my-tool"

if ($Version -eq "latest") {
    $release = Invoke-RestMethod "https://api.github.com/repos/$repo/releases/latest"
    $Version = $release.tag_name -replace '^v', ''
}

$arch = if ([Environment]::Is64BitOperatingSystem) { "x86_64" } else { "x86" }
$url  = "https://github.com/$repo/releases/download/v$Version/my-tool-windows-$arch.zip"

Write-Host "Downloading my-tool v$Version..."
$tmp = New-TemporaryFile | Rename-Item -NewName { $_ -replace 'tmp$', 'zip' } -PassThru
Invoke-WebRequest -Uri $url -OutFile $tmp

New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Expand-Archive -Path $tmp -DestinationPath $InstallDir -Force
Remove-Item $tmp

Write-Host "Installed to $InstallDir\my-tool.exe"
Write-Host "Make sure $InstallDir is in your PATH."

Users run:

irm https://raw.githubusercontent.com/user/my-tool/main/install.ps1 | iex

12. Release Checklist

Pre-Release

  • All tests pass (zig build test)
  • build.zig.zon version bumped (for libraries)
  • CHANGELOG.md updated
  • Public API docs updated (if library)
  • --version flag returns new version string
  • No uncommitted changes (git status)
  • On the correct branch (usually main)

Tagging

  • git tag -a vX.Y.Z -m "Release vX.Y.Z"
  • git push origin vX.Y.Z
  • Confirm GitHub Actions triggered

After CI Completes

  • GitHub Release created with all binary assets
  • checksums.txt attached to release
  • Library: test zig fetch --save against the new tarball URL in a throwaway project
  • CLI: download and smoke-test binary on at least one platform

Package Manager Updates

  • npm: verify packages published and install works globally
  • Homebrew: brew upgrade my-tool pulls new version from tap
  • AUR: .SRCINFO updated, pkgver/sha256sums correct
  • apt: new .deb in repo, apt update && apt upgrade my-tool works
  • Winget: PR merged in microsoft/winget-pkgs
  • Scoop: autoupdate ran or manifest manually updated
  • Chocolatey: package approved and live on chocolatey.org

Post-Release

  • Close / reference milestone in GitHub
  • Announce in relevant channels (Discord, mailing list, X, etc.)
  • Tag next -dev pre-release in build.zig.zon if tracking HEAD