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
- Versioning Strategy
- Git Tags
- GitHub Releases
- Building for Release
- Zig Package Manager — Libraries
- GitHub Actions — Automated Release Workflow
- npm
- Homebrew
- AUR (Arch User Repository)
- apt / Debian
- Windows
- 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
- Go to Releases → Draft a new release
- Choose your tag (or create it here)
- Set target branch/commit
- Write release notes (supports Markdown)
- Attach binary assets (
.tar.gz,.zip,.deb, etc.) - 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
.versioninbuild.zig.zon - Update
CHANGELOG.md - Run
zig build testcleanly - Verify
.pathsinbuild.zig.zonincludes 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 helpermy-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.zonversion bumped (for libraries) -
CHANGELOG.mdupdated - Public API docs updated (if library)
-
--versionflag 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.txtattached to release - Library: test
zig fetch --saveagainst 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-toolpulls new version from tap - AUR:
.SRCINFOupdated,pkgver/sha256sumscorrect - apt: new
.debin repo,apt update && apt upgrade my-toolworks - Winget: PR merged in
microsoft/winget-pkgs - Scoop:
autoupdateran 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
-devpre-release inbuild.zig.zonif tracking HEAD