Ship & distribute

Building the CLI is half the job; getting binaries to users — versioned, verifiable, updatable — is the other half. zcli ships that half too: a release command, a CI workflow scaffold, a signing story with teeth, a self-upgrade plugin, and shell completions.

One-time setup: the GitHub workflows

zcli gh add workflow ci        # build + test on every push and PR
zcli gh add workflow release   # tag-driven multi-platform releases

The release workflow (.github/workflows/release.yml) is the one this page is about: on any *-v* tag it builds your app for x86_64-linux and aarch64-linux (static musl), x86_64-macos, and aarch64-macos with ReleaseFast, names each artifact {app}-{target}, writes a checksums.txt, and publishes a GitHub release with everything attached. Commit it once and releases are tag-driven from then on.

Both workflows can also be scaffolded at project creation — zcli init offers them in its extras step (--github ci,release), and preselects the release workflow when the github_upgrade plugin is chosen, since it’s what feeds the self-updater.

Cutting a release

zcli release patch          # or minor, major, or an explicit 1.5.0

One command runs the whole checklist:

  1. verifies you’re in a zcli project, on the release branch, with a clean tree
  2. runs zig build test
  3. opens your $EDITOR for release notes, pre-filled with the commits since the last tag (or takes --message)
  4. bumps .version in build.zig.zon and commits it
  5. creates the annotated tag {app}-vX.Y.Z and pushes — which triggers the workflow above

--dry-run previews every step; --skip-tests, --skip-checks, --no-push, --sign (GPG-sign the tag), and --branch adjust the strictness. The version in build.zig.zon is the same one zcli.generate bakes into --version — nothing to keep in sync.

Signing releases

Checksums alone can’t survive a compromised publisher — checksums.txt ships in the same release as the binaries, so whoever can swap a binary can rewrite its hash. zcli’s trust model (ADR-0023) closes that hole with minisign: a signature under a key that never enters the release pipeline.

  • Generate the keypair offline, once: minisign -G. The secret key lives in a password manager — never a CI secret.
  • Pin the public key where verification happens: your install script and your github_upgrade plugin config.
  • Sign each release locally: the release is created as a draft, you sign its checksums.txt, upload checksums.txt.minisig, and publish.
  • Put the release tag in minisign’s trusted comment when you sign (minisign -S -t "myapp $TAG — signed release checksums"). That comment is covered by a second signature, so it can’t be edited after the fact.

Verification is fail-closed: when a public key is pinned, a missing or invalid signature aborts the install or upgrade — it never falls back to “unsigned but probably fine”. zcli’s own releases work exactly this way, and install.sh and install.ps1 verify the minisign signature and the SHA-256 checksum before moving a binary into place.

Signatures are also bound to their release tag. checksums.txt lists artifacts but no version, so an authentic signature alone can’t tell a current release from an old one — anyone able to influence what releases/latest returns could serve a genuinely-signed older build and downgrade your users onto a vulnerable version. Requiring the trusted comment to name the exact tag being installed, as a whole token, closes that (zcli-v0.2 must not satisfy zcli-v0.20.0). zcli’s installers, zcli upgrade, and the signing script itself all enforce it.

Self-upgrade for your users

Enable the github_upgrade plugin and your app gains an upgrade command:

zcli.builtin(.github_upgrade, .{
    .repo = "you/yourapp",
    .command_name = "upgrade",
    .inform_out_of_date = true,             // optional: startup notice, max once/day
    .verification = .{ .minisign = "RWT…" }, // required: pin your minisign key — fail-closed verify
    // .verification = .checksum_only,      // or: explicit opt-out (checksum only, loudly warned)
}),

The plugin renders its progress (download, verify, smoke test, install) as live spinners, which ride zcli’s ui engine — so your root source file needs the two terminal-restoring crash hooks, both enforced at compile time: pub const panic = zcli.ui.panic; and pub const debug = zcli.ui.debug; (segfaults are routed through root.debug.handleSegfault, not root.panic). Projects scaffolded by zcli init already declare whatever their pinned zcli requires — the debug hook joins the scaffold in the first release that carries it.

verification has no default — you must pick one. Pin a minisign key (recommended: fail-closed signature verification, see Release signing) or opt out explicitly with .checksum_only, which still verifies the SHA-256 digest but warns loudly on every real upgrade that the release could be forged by whoever can publish to your GitHub repo.

myapp upgrade resolves the latest release (or an explicit version), downloads the right {app}-{target} artifact, verifies — minisign signature when a key is pinned, SHA-256 always — test-runs the new binary, then replaces itself with an atomic rename — a crash mid-swap leaves the original binary intact (on Windows, where a running executable can’t be overwritten, the old image is moved aside instead). Version comparison uses real semver ordering (std.SemanticVersion), so pre-release precedence is handled correctly. A bare myapp upgrade never downgrades: it refuses any target that isn’t newer, even with --force; to move to an older release, name it explicitly (myapp upgrade 1.2.0). myapp upgrade --check only reports whether a newer version exists — it never modifies anything. With inform_out_of_date, the app mentions new versions on startup: checked at most once per 24 hours, with a 3-second timeout so a dead network can never hang your users’ commands.

Shell completions

Enable the completions plugin and users install tab-completion themselves:

myapp completions install          # detects bash, zsh, fish, or PowerShell
myapp completions generate zsh     # or print the script to stdout
myapp completions generate powershell > _myapp.ps1

Completion data is generated from the same registry as everything else, so it’s always in sync with your commands. For bash, zsh, fish, and PowerShell alike the scripts complete commands and subcommands (including aliases), flags, the values of enum options, and — for fields that declare a dynamic .complete hook — live candidates resolved by invoking your app at completion time (see Completion authoring for writing one, plus the .file/.dir builtins for path-valued fields). PowerShell (and zsh/fish) additionally show your command and option descriptions as tooltips. The bash script uses the bash-completion package’s helpers when present but ships a fallback so it works without it — installing bash-completion is still recommended for the best experience. PowerShell has no auto-loaded completions directory, so install drops the script in the app’s platform config directory~/.config/powershell/completions/ on POSIX, %APPDATA%\powershell\completions\ on Windows — and prints the one line to dot-source from your $PROFILE. bash and fish destinations follow their own tools’ contracts: $BASH_COMPLETION_USER_DIR (first entry) else $XDG_DATA_HOME/bash-completion/completions, and $XDG_CONFIG_HOME/fish/completions. Whatever the resolved destination, install and uninstall print the actual path and the exact lines to add or remove, so custom-XDG setups get correct instructions rather than a hard-coded ~/… that isn’t where the file went.

Generated docs

zig build docs renders markdown, man pages, and an HTML site from your command metadata — see Build integration. Man pages belong in release artifacts; the HTML output is deployable as-is.