---
title: Using GoReleaser and GitHub Actions to release Rust and Zig projects
date: 2025-01-02
slug: rust-zig
tags:
  - tutorials
authors:
  - caarlos0
---

[GoReleaser v2.5][v2.5] is out with Rust and Zig support - let's explore how we
can use it!

<!--more-->

## What is GoReleaser?

I'm aware that [GoReleaser][] is kind of famous in the Go community, and I'm also
aware most Zig and Rust developers might have never heard of it.

This is an attempt to fix it a little bit.

GoReleaser is a release automation tool. Up until recently, it only supported
Go, but now we're slowly adding more languages to it, Zig and Rust being in the
first batch.

Some of the key features are:

- Cross-compilation and packaging for multiple platforms
- Automatic changelog generation from Git history
- Creation of Linux packages (deb, rpm, apk)
- Docker image building and publishing
- Artifact signing and SBOM generation
- Integration with package managers (Homebrew, Scoop, Winget, etc.)
- GitHub/GitLab/Gitea releases automation

It handles complex release workflows through a single YAML file, eliminating the
need to maintain separate release scripts/complex workflows and ensuring
consistent releases.

## Goal

By the end of this, you should have GitHub action do all the work of releasing
your binaries, universal binaries, including packaging, Docker images, signing,
and more.

## Assumptions

- You either have a Zig project that can be cross-compiled with `zig build`, or
  a Rust project that can be cross-compiled with `cargo zigbuild`
- Your project is hosted on GitHub
- You have [GoReleaser installed][install][^v2.5.1]

## Getting started

Into your project root directory, run:

```bash
goreleaser init
```

This should add some entries to your `.gitignore`, and create a
`.goreleaser.yaml` with `builds`, `archives`, `changelog` and a release footer
set up.

You can now run:

```sh
goreleaser release --clean --snapshot
```

And it should complete with no problems.

## The build

Whether you are using Zig or Rust, GoReleaser should have set up the
configuration file accordingly when you ran `goreleaser init`.

The main note here is that, with Rust, by default, we'll use [cargo-zigbuild][].
You can change that to use [cross-rs][] or do more complex stuff, like using
[goreleaser-rust-cross][].

If cargo-zigbuild works for you, though, I'd stay with it, as it is simpler.

## Adding stuff

Let's add some features to our release.

After each step, you should be able to run
`goreleaser release --clean --snapshot`
like we did earlier to verify if it works.

### Universal binaries

Universal binaries (also known as fat binaries) is a format introduced in macOS
a while ago, when Apple migrated from PPC to Intel.
It has become common again recently with the introduction of their ARM chips.
It's pretty much both binaries glued together plus a special header.

Luckily, you don't really need to know any of that, GoReleaser handles it for
us.
You can enable it by adding this to your `.goreleaser.yml`:

```yaml {filename=".goreleaser.yaml"}
# ...
universal_binaries:
  - replace: true
```

That's it!

There are some customization options available, you can see documentation
[here][unibins-docs].

### Linux packages

GoReleaser uses [nFPM][] to generate Linux packages for many formats.
You can enable it by adding this to your `.goreleaser.yml`:

```yaml {filename=".goreleaser.yaml"}
# ...
nfpms:
  - file_name_template: "{{ .ConventionalFileName }}" # optional, better file names
    formats:
      - deb
      - apk
      - rpm
```

There you can add more things, for example the maintainer, license, more files,
etc. See documentation [here][nfpm-docs].

### Sign with cosign

It's generally a good idea to sign your artifacts.
I usually sign the checksums file, as it ensures that the checksums didn't
change, and so you can check that the artifacts haven't changed either.

You can use GPG, but generally, [cosign][] is way easier!
It even allows keyless signing, which uses either GitHub actions, or asks you to
login to sign.

It works pretty great.

You can enable it by adding this to your `.goreleaser.yml`:

```yaml {filename=".goreleaser.yaml"}
# ...
signs:
  - cmd: cosign
    certificate: "${artifact}.pem"
    artifacts: checksum
    args:
      - sign-blob
      - "--output-certificate=${certificate}"
      - "--output-signature=${signature}"
      - "${artifact}"
      - "--yes"
```

The signing feature in GoReleaser allows for many other options, see
documentation [here][sign-docs] for more details.

This will require you to have `cosign` installed and available in your `$PATH`.

### Software Bill of Materials (SBOM)

SBOM is a formal record that lists all software components, dependencies, and
their relationships.
It's mainly used to assess risk in a software supply chain.

By default, GoReleaser will use [syft][] for it.
You can enable it by adding the following to your `.goreleaser.yml`:

```yaml {filename=".goreleaser.yaml"}
# ...
sboms:
  - artifacts: archive
```

Generally speaking you can tune this to use any other tool you want, see
documentation [here][sbom-docs] for more details.

This will require you to have `syft` installed and available in your `$PATH`.

### Homebrew Tap

Homebrew is commonly used on macOS to install software.
GoReleaser supports [homebrew taps][taps], and the taps generated also work on
Linux.

You can enable it by adding this to your `.goreleaser.yml`:

```yaml {filename=".goreleaser.yaml"}
# ...
brews:
  - repository:
      owner: YOUR-USERNAME
      name: YOUR-TAP
      token: "{{ .Env.GH_PAT }}"
    directory: Formula
```

A couple of notes here:

- You'll need a `GH_PAT` in your actions workflow later, as usually taps are
  published in their own repositories
- You'll need to create the repository beforehand. It can be an empty
  repository. See [some][tap1] [examples][tap2].

There you can add more things, for example the maintainer, license, more files,
etc. See documentation [here][brew-docs].

### Docker Images

We can also make GoReleaser create, push, and sign Docker images and manifests.

Before proceeding, we need a `Dockerfile`:

```dockerfile {filename="Dockerfile"}
FROM ubuntu
COPY example /usr/bin/example
ENTRYPOINT [ "/usr/bin/example" ]
```

Notice that we don't build anything here. GoReleaser will setup the context, so
we have the previously built binary available, and can then simply copy it!

Now, we have mainly two options: if you want to push only the Linux/amd64 image,
you can simply add something like this to your configuration:

```yaml {filename=".goreleaser.yaml"}
# ...
dockers:
  - image_templates:
      - "ghcr.io/YOUR_USER/YOUR_REPO:{{ .Tag }}"
      - "ghcr.io/YOUR_USER/YOUR_REPO:latest"
```

> Note: using GHCR will requires us to log in and have the right workflow
> permissions on the GitHub Action.

Now, if we want a manifest with multiple platforms, we need a little more
setting up:

```yaml {filename=".goreleaser.yaml"}
# ...
dockers:
  - image_templates:
      - "ghcr.io/YOUR_USER/YOUR_REPO:{{ .Tag }}-arm64"
    goarch: arm64
    use: buildx
    build_flag_templates:
      # note that this changes according to goarch
      - "--platform=linux/arm64"
  - image_templates:
      - "ghcr.io/YOUR_USER/YOUR_REPO:{{ .Tag }}-amd64"
    goarch: amd64
    use: buildx
    build_flag_templates:
      # note that this changes according to goarch
      - "--platform=linux/amd64"
```

This will build 2 images (and push them later on), which we can then combine in
a single manifest like so:

```yaml {filename=".goreleaser.yaml"}
# ...
docker_manifests:
  - name_template: "ghcr.io/YOUR_USER/YOUR_REPO:{{ .Tag }}"
    image_templates:
      - "ghcr.io/YOUR_USER/YOUR_REPO:{{ .Tag }}-arm64"
      - "ghcr.io/YOUR_USER/YOUR_REPO:{{ .Tag }}-amd64"
```

Finally, we can also sign the manifests:

```yaml {filename=".goreleaser.yaml"}
# ...
docker_signs:
  - cmd: cosign
    artifacts: manifests
    args:
      - "sign"
      - "${artifact}"
      - "--yes"
```

> Note: the manifest will only be built in "production" builds, e.g. without
> the `--snapshot` flag.

Additional options are available on everything we used here, take a look at
the documentation for each feature:

- [Docker Images][docker-docs]
- [Docker Manifests][docker-manifest-docs]
- [Docker Signing][docker-sign-docs]

This will require you to have `docker` and `cosign` installed and available in
your `$PATH`.

## GitHub Actions

Now, you probably don't want to run all this locally on every release!
Let's use GitHub Actions - but any CI should do in most cases[^sign].

To enable this, we can simply add something like this to
`.github/workflows/goreleaser.yml`:

```yaml {filename=".github/workflows/goreleaser.yaml"}
name: goreleaser

on:
  push:
    # run only against tags
    tags:
      - "*"

# You might not need all of this...
permissions:
  contents: write
  packages: write
  issues: write
  id-token: write

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # we'll need this for both 'zig build' and 'cargo zigbuild'
      - uses: mlugg/setup-zig@v1

      # only needed if using signing
      - uses: sigstore/cosign-installer@v3.7.0

      # only needed if using SBOMs
      - uses: anchore/sbom-action/download-syft@v0.17.9

      # only needed if using docker
      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: goreleaser/goreleaser-action@v6
        with:
          # either 'goreleaser' (default) or 'goreleaser-pro'
          distribution: goreleaser
          # 'latest', 'nightly', or a semver
          version: "~> v2"
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # used to push the homebrew tap
          GH_PAT: ${{ secrets.GH_PAT }}
          # Your GoReleaser Pro key.
          # Only needed if you're using the 'goreleaser-pro' distribution.
          # GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
```

Once that's done, you can push a new tag and watch the magic happen!

If you fancy building the release artifacts on every release, you can set up
auto snapshotting as well, check [this file out][auto-snapshot] for more
details.

## This is cool! What else can it do?

Glad you enjoy it and glad you asked!

Here's an incomplete list:

- Nightly builds
- S3/GCS/etc
- Winget, AUR[^aur], NUR[^nur], Scoop, Krew
- macOS sign and notarize
- DMG
- MSI
- Chocolatey
- macOS App Bundles
- and many more!

> Note: some features deemed "enterprise-ish" require the
> [Pro version][pro], which is paid.

## Troubleshooting

Some things that can help you verify your configuration:

```bash
# Most features allow to be skipped, which can help speed up your local iterations.
# See the available options with `goreleaser release --help`.
goreleaser release --clean --snapshot --skip=[feature1,feature2]

# Verify if you have all the needed binaries in your $PATH:
goreleaser healthcheck

# Check if your configuration is valid (only check syntax):
goreleaser check

# If you only want to build the binaries, the build sub command might help!
goreleaser build
```

If the build itself fails, it's always good to check if it works outside
GoReleaser, e.g. run `cargo zigbuild --target the-one-that-failed` and see what
happens.

## Closing words

I hope this clarifies a bit what GoReleaser can do!

If you want to check the examples live, they're available [here][example-rust]
and [here][example-zig].

Also worth noting that [Bun][bun] and [Deno][deno] support are in the works, and
we'll probably add Python soon as well, maybe all for v2.6.
Stay tuned!

Thanks for reading!

## Versions used

For reference, these are the tools and versions used:

|      Tool      | Version |
| :------------: | :-----: |
|   goreleaser   |  2.5.1  |
|      zig       | 0.13.0  |
|     cargo      | 1.83.0  |
|     rustc      | 1.83.0  |
| cargo zigbuild | 0.19.7  |
|     cosign     |  2.4.1  |
|      syft      | 1.18.1  |
|     docker     | 27.3.1  |
| docker buildx  | 0.18.0  |

[bun]: /customization/builds/builders/bun/
[deno]: /customization/builds/builders/deno/
[example-zig]: https://github.com/goreleaser/example-zig
[example-rust]: https://github.com/goreleaser/example-rust
[cargo-zigbuild]: https://github.com/rust-cross/cargo-zigbuild
[cross-rs]: https://github.com/cross-rs/cross
[goreleaser-rust-cross]: https://github.com/vedantmgoyal9/goreleaser-rust-cross/tree/main
[v2.5]: /blog/goreleaser-v2.5/
[unibins-docs]: /customization/builds/universalbinaries/
[nFPM]: https://nfpm.goreleaser.com
[nfpm-docs]: /customization/package/nfpm/
[cosign]: https://github.com/sigstore/cosign.md
[sign-docs]: /customization/sign/sign/
[sbom-docs]: /customization/sbom/
[brew-docs]: /customization/publish/homebrew_casks/
[docker-docs]: /customization/package/docker/
[docker-manifest-docs]: /customization/package/docker_manifest/
[docker-sign-docs]: /customization/sign/docker_sign/
[syft]: https://github.com/anchore/syft
[taps]: https://docs.brew.sh/Taps
[tap1]: https://github.com/goreleaser/homebrew-tap
[tap2]: https://github.com/caarlos0/homebrew-tap
[auto-snapshot]: https://github.com/goreleaser/example-auto-snapshot/blob/main/.github/workflows/releaser.yml
[GoReleaser]: /

[^sign]:
    Most CIs do not support the Keyless Signing mechanism we are using with
    cosign, so that's probably the main consideration.

[^v2.5.1]:
    Some things we'll use were not working on v2.5.0, but are fixed in
    v2.5.1. Make sure to install that version (or newer).

[^aur]: Arch User Repositories

[^nur]: Nix User Repositories

---

Cross-posted from
[carlosbecker.com](https://carlosbecker.com/posts/goreleaser-rust-zig/).

[pro]: /pro/
