Zig App Release and Updates via Github ⚡
I built a small app with Zig and I’m using it across my fleet of homelab devices and VMs. I’m testing Zigbar “in production”, or dogfooding as they say. What’s become apparent is that getting new changes to a dozen machines is tricky.
Until now I was copying files over SSH:
scp ./zig-out/aarch64-linux/zigbar root@rdietpi:/usr/local/bin/
This requires root access over the network — for locations like /usr/local/bin
at least — and is far from a professional software update solution. Although I am the primary (and only) user of Zigbar I’d prefer to do things “the right way”. I took a detour to build a secure update and release process via GitHub.
The idea
I noted my initial thinking on an update process thanks to a few suggestions. This evolved into:
- Tag an update with a semver-like
vX.X.X
and push to GitHub - Build signed binaries in an automated GitHub Action
- Publish those files as a tagged release
That was the easy part. The Zig build system is scripted with Zig. My build script hard-codes the app version, target OS and architecture, and Minisign public key (the private key is a secret). It took some wrangling to understand the build step order but I got there. It builds, it tars, and it signs.
Because Zig does the build steps my GitHub workflow is fairly simple. It sets up the environment before calling zig build
and then publishes the release. actions/upload-artifact and ncipollo/release-action do the final step.
Update command
To update my app I run zigbar update
which takes these actions:
- Check the GitHub API for the latest release
- Download the target tarball and signature
- Verify the tarball signature
- Extract and replace the binary
I am using CURL as a child process to request JSON from the GitHub API and to download release files. I can expect curl
to exist on all the systems I use. At first I tried the HTTP client from the Zig standard library. Whilst this was worthwhile to learn it led to significantly larger binaries and longer builds. I’m guessing the parts for TLS are to blame.
const result = std.process.Child.run(.{
.allocator = allocator,
.argv = &.{
"curl",
"-L",
"-H",
"Accept: application/vnd.github+json",
"-H",
"X-GitHub-Api-Version: 2022-11-28",
"https://api.github.com/repos/dbushell/zigbar/releases",
},
}) catch return error.ApiError;
Child.run
returns the JSON body in result.stdout
. It’s a handy wrapper around manual process management. I discuss how I handle errors at the end of this post.
The Zig standard library has a good JSON module too. This allows me to define a partial struct type for the expected data:
pub const Release = struct {
tag_name: []const u8,
assets: []struct {
name: []const u8,
size: usize,
browser_download_url: []const u8,
},
};
The releases endpoint retuns a JSON array so I use []Release
:
var releases = std.json.parseFromSlice(
[]Release,
allocator,
result.stdout,
.{ .ignore_unknown_fields = true },
) catch return error.ParseError;
defer releases.deinit();
ignore_unknown_fields
is required because I’m not providing the full JSON schema. From here I iterate over the array and find the latest version. Zig even has a semver module to parse and compare version numbers.
I download a new release spawning similar curl
commands followed by tar
to extract in /tmp
before validating the signature. Whilst the build process uses an external minisign
program to generate signatures, the app itself imports the zig-minisign library to do verification which is a tiny dependency. I expect curl
and tar
to be available externally.
I wasn’t sure if an app can just delete and rewrite itself in place; it can! At least on macOS and Linux where I’ve tested. I have no need to make my app Windows compatible.
Security
The signing process with Minisign ensures that the binaries cannot be tampered with. Keeping the build and private key away from the file hosting would be better (they’re both on GitHub.) Otherwise this seems like a very reasonable and even overkill solution for a personal project.
What do you think? @ me on the socials!
Zig errors
My update process has a lot of potential errors along the way. Between spawning child processes and using file system APIs there are over 50 unique errors I could theoretically encounter.
My early opinions on Zig errors were positive — I’m coming from JavaScript after all — but I struggled a bit here. I remember reading Dimitri Sabadie’s thoughts on error handling; I felt some of those pains.
Dealing with errors immediately is easy because context is in the surrounding code. Once I start returning errors up the stack they’re harder to handle. Instead of handling errors right away I put common logic in functions using try
to return on first error. When I call those functions I catch a wide set of possible errors and return a more generic error.
At the top of my program I handle only a small set of errors:
if (update.download(allocator)) |version| {
print("New version: {}\n", .{version}),
} else |err| {
const reason = switch (err) {
error.ApiError => "API not responding",
error.DownloadError => "error downloading files",
error.FileError => "error writing temporary files",
error.ParseError => "could not parse API response",
error.SigError => "invalid tarball signature",
error.InstallError => "error installing binary (sudo required?)",
};
print("Update failed: {s}\n", .{reason});
}
This works quite well but the obvious problem is lack of detail. Zig errors only have a name, no value nor description. Maybe my approach just isn’t the Zig way? I have more learning and refactoring to do.
Another option would be to log more detailed error messages when they first appear and still keep this approach for the final message.