Skip to content

Using GoReleaser and GitHub Actions to release Rust and Zig projects

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

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 installed2

Getting started

Into your project root directory, run:

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:

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:

.goreleaser.yaml
# ...
universal_binaries:
  - replace: true

That's it!

There are some customization options available, you can see documentation here.

Linux packages

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

.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.

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:

.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 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:

.goreleaser.yaml
# ...
sboms:
  - artifacts: archive

Generally speaking you can tune this to use any other tool you want, see documentation here 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, and the taps generated also work on Linux.

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

.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 examples.

There you can add more things, for example the maintainer, license, more files, etc. See documentation here.

Docker Images

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

Before proceeding, we need a Dockerfile:

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:

.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:

.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:

.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:

.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:

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 cases1.

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

.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/[email protected]

      # only needed if using SBOMs
      - uses: anchore/sbom-action/[email protected]

      # 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 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, AUR3, NUR4, 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, which is paid.

Troubleshooting

Some things that can help you verify your configuration:

# 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 and here.

Also worth noting that Bun and 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

Cross-posted from carlosbecker.com.


  1. Most CIs do not support the Keyless Signing mechanism we are using with cosign, so that's probably the main consideration. 

  2. 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). 

  3. Arch User Repositories 

  4. Nix User Repositories