GitHub Releases: A poor man's Nix binary cache

tl;dr

Run or install binaries without compiling them by relying on GitHub Releases as a binary cache:

$ nix run github:sshine/walltime-rs#walltime-bin -- --version
walltime 0.2.0

Motivation

The simplest way to export a Rust CLI from a Nix flake is with rustPlatform.buildRustPackage.

Anyone who adds it as an input compiles it. A Rust developer or a Nix fanatic might accept a few gigabytes of build dependencies and a few more of build artifacts, plus several minutes of downloading, for a 4 MiB static binary.

You can run a Nix binary cache with Cachix or attic. Cachix is a paid service with a free tier, and attic is not trivial to run. Uploading a precompiled binary to a GitHub or Forgejo Release takes one step.

Unlike a poor man’s Nix cache, which was an nginx proxy in front of cache.nixos.org, this is a binary cache for programs that are not released on nixpkgs. That one caches everything and speeds up whatever you build. This one caches exactly one package and means you do not build it at all.

Cost of building Rust from source

Measured on walltime-rs, a single-crate workspace with no C dependencies, so this is the cheap end:

runtime closureinto an empty storebuild needs
packages.default49.3 MiB2.7 GiB, 223srustc, cargo, 86 store paths
packages.walltime-bin4.2 MiB628 MiB, 20sstdenv
tarball as a flake input4.2 MiB4.2 MiBnothing

The runtime difference is glibc and libgcc against a static musl binary:

$ ldd result/bin/walltime
	statically linked

This eliminates the entire toolchain from the derivation:

$ nix-store -qR "$(nix eval --raw .#default.drvPath)" | grep -c 'rust\|cargo'
86
$ nix-store -qR "$(nix eval --raw .#walltime-bin.drvPath)" | grep -c 'rust\|cargo'
0

Producing the artifact

There are two static targets and a checksum beside each:

release_targets := "x86_64-unknown-linux-musl aarch64-unknown-linux-musl"

# Assert the release tag names the version cargo would publish
check-version version:
    @pkgid="$(cargo pkgid -p walltime)"; crate="v${pkgid##*#}"; \
    if [ "$crate" != "{{version}}" ]; then \
        echo "tag {{version}} does not match crate version $crate" >&2; exit 1; \
    fi

# Build the static release artifacts and their checksums into dist/
dist version: (check-version version)
    rm -rf dist && mkdir -p dist
    set -e; for target in {{release_targets}}; do \
        cargo build --release --locked --target "$target" -p walltime; \
        name="walltime-{{version}}-$target"; \
        tar -czf "dist/$name.tar.gz" -C "target/$target/release" walltime; \
        ( cd dist && sha256sum "$name.tar.gz" > "$name.tar.gz.sha256" ); \
    done
    @ls -1 dist

musl keeps the binary from linking against whatever glibc the caller happens to have.

Cross-compiling with cargo rather than pkgsCross.*.rustPlatform keeps this to seconds, because every dependency is pure Rust and the only thing missing for a foreign target is a linker:

# rustc finds musl's crt objects in its own `self-contained` directory and can
# link x86_64 unaided; only a foreign architecture needs a cross linker.
aarch64-cc = pkgs.pkgsCross.aarch64-multiplatform-musl.stdenv.cc;
aarch64-linker = "${aarch64-cc}/bin/aarch64-unknown-linux-musl-cc";

devshells.default = {
  packages = [ aarch64-cc ];

  env = [
    {
      name = "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER";
      value = aarch64-linker;
    }
  ];
};

If the crate pulls the ring dependency in through rustls, both targets need a full C compiler and not merely a linker, so CC_<triple> has to be set beside the linker variable. The cc crate accepts a dashed name there, but a dashed name is not a valid shell identifier and devshell exports these, so underscore it.

On GitHub, uploading is one step:

- name: Build static musl artifacts
  run: nix develop -c just dist "${GITHUB_REF_NAME}"

- name: Create GitHub Release
  uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2, 2026-07-13
  with:
    generate_release_notes: true
    files: |
      dist/*.tar.gz
      dist/*.tar.gz.sha256

(Quick advertisement for the hashpinner CLI to make hash-pinning of github actions easier; it is incidentally an example of this GitHub release-based binary cache: the release, the github action, and its x86_64/aarch64 release.nix.)

Consuming it

# Prebuilt release binaries, for consumers who want walltime without a Rust toolchain
# entering their build closure.
{ ... }:
{
  perSystem =
    { system, pkgs, lib, ... }:
    let
      release = {
        version = "0.2.0";
        hashes = {
          "x86_64-linux" = "sha256-5FVCATMr1k9zzauV8scjqqXc3TboAUId7bNQOLgz62k=";
          "aarch64-linux" = "sha256-LTKSJWk41gsaALorxZO2GvLvjRiMNoQHJXOWeJzUipc=";
        };
      };

      triple = {
        "x86_64-linux" = "x86_64-unknown-linux-musl";
        "aarch64-linux" = "aarch64-unknown-linux-musl";
      };

      tag = "v${release.version}";

      tarball = pkgs.fetchurl {
        url = "https://github.com/sshine/walltime-rs/releases/download/${tag}/walltime-${tag}-${triple.${system}}.tar.gz";
        hash = release.hashes.${system};
      };
    in
    lib.optionalAttrs (release.hashes ? ${system}) {
      packages.walltime-bin =
        pkgs.runCommand "walltime-bin-${release.version}"
          {
            meta = {
              description = "CLI for measuring time spent in a process (prebuilt)";
              mainProgram = "walltime";
              license = with lib.licenses; [ mit asl20 ];
              platforms = lib.attrNames release.hashes;
              sourceProvenance = [ lib.sourceTypes.binaryNativeCode ];
            };
          }
          ''
            tar -xzf ${tarball}
            install -Dm755 walltime $out/bin/walltime
          '';
    };
}

Notes

  • The pinned version deliberately trails Cargo.toml. It names the last tag whose artifacts are published, not whatever the working tree claims, so bumping the crate cannot point the URL at an asset that doesn’t exist yet.
  • lib.optionalAttrs (release.hashes ? ${system}) omits the output on systems with no artifact. nix flake show on a Mac gets no walltime-bin rather than an evaluation error. This is also what keeps nix flake check green on Darwin when the release builds only Linux.
  • sourceProvenance = [ lib.sourceTypes.binaryNativeCode ] says that it wasn’t built from source.
  • The hashes are of the tarballs the release publishes, so they can be checked against the .sha256 assets instead of taken on faith.

Bumping is a just recipe that prints the lines to paste:

# Print the SRI hashes nix/binary.nix pins, in nixpkgs system order
dist-hashes:
    @for target in {{release_targets}}; do \
        system="${target%%-*}-linux"; \
        hash="$(nix hash file --sri --type sha256 dist/walltime-*-"$target".tar.gz)"; \
        printf '"%s" = "%s";\n' "$system" "$hash"; \
    done

Skipping nixpkgs as well

walltime-bin doesn’t need the Rust toolchain, but evaluating the flake still fetches nixpkgs and rust-overlay, and runCommand still wants a stdenv to run tar in. The 628 MiB breaks down as 485 MiB of nixpkgs tree, 24 MiB of rust-overlay, about 115 MiB of stdenv and 4.2 MiB of program.

A flake = false input skips all of it:

{
  inputs.walltime-bin = {
    url = "https://github.com/sshine/walltime-rs/releases/download/v0.2.0/walltime-v0.2.0-x86_64-unknown-linux-musl.tar.gz";
    flake = false;
  };

  outputs =
    { walltime-bin, ... }:
    {
      packages.x86_64-linux.default = walltime-bin;

      apps.x86_64-linux.default = {
        type = "app";
        program = "${walltime-bin}/walltime";
      };
    };
}

Nix unpacks the tarball into the store itself and records the narHash in flake.lock:

"walltime-bin": {
  "flake": false,
  "locked": {
    "lastModified": 1788575925,
    "narHash": "sha256-H5vGIVaLFY9BHsP0IgksHOjUg/tw0+FkPEtxwzMwk9M=",
    "type": "tarball",
    "url": "https://github.com/sshine/walltime-rs/releases/download/v0.2.0/walltime-v0.2.0-x86_64-unknown-linux-musl.tar.gz"
  }
}

No derivation, no stdenv, no nixpkgs: one store path, 4.2 MiB with zero dependencies.

It is the same store path builtins.fetchTarball gives you for the same URL and hash:

$ nix eval --raw --impure --expr 'toString (builtins.fetchTarball {
    url = "https://github.com/sshine/walltime-rs/releases/download/v0.2.0/walltime-v0.2.0-x86_64-unknown-linux-musl.tar.gz";
    sha256 = "sha256-H5vGIVaLFY9BHsP0IgksHOjUg/tw0+FkPEtxwzMwk9M=";
  })'
/nix/store/mjgwirfijj5ayfzjsi4zp2z2m2cwwhpm-source

The triple is written into the URL, so one input serves one platform, and what lands in the store is a bare directory with no $out/bin and no meta. A devshell or a CI job does not care. Behind environment.systemPackages you want the fetchurl plus runCommand version above, because that behaves like a package.

The same trick on a self-hosted forge

I keep helm-vendor, a CLI that vendors Helm charts from a charts.toml, on my own Forgejo instance. Forgejo serves release assets in the same way GitHub does:

https://git.shine.town/infra/helm-vendor/releases/download/v0.6.0/helm-vendor-v0.6.0-x86_64-unknown-linux-musl.tar.gz

so the consuming side is the walltime binary.nix with a different host, plus a wrapProgram to put helm and diff on its PATH, because the prebuilt package has to behave like the source one. Forgejo has no softprops/action-gh-release, so the upload is a shell script. I keep it as a package rather than a heredoc buried in workflow YAML, which gets it shellcheck’d for free:

packages.forgejo-release = pkgs.writeShellApplication {
  name = "forgejo-release";
  runtimeInputs = [ pkgs.curl pkgs.jq ];
  text = ''
    tag="$1"
    assets="$2"
    api="''${FORGEJO_URL:?}/api/v1/repos/''${FORGEJO_REPO:?}"

    call() {
      local method="$1"
      shift
      curl --fail-with-body --silent --show-error \
        -X "$method" \
        -H "Authorization: token ''${FORGEJO_TOKEN:?}" \
        -H "Accept: application/json" \
        "$@"
    }

    # Re-runnable: adopt an existing release for the tag rather than
    # failing, so a partially uploaded release can be completed.
    if release=$(call GET "$api/releases/tags/$tag" 2>/dev/null); then
      echo "release $tag already exists, updating assets"
    else
      release=$(call POST "$api/releases" \
        -H "Content-Type: application/json" \
        --data "$(jq -n --arg tag "$tag" '{
          tag_name: $tag,
          name: $tag,
          draft: false,
          prerelease: false,
          body: "Statically linked musl binaries. Each tarball has a matching .sha256 asset."
        }')")
      echo "created release $tag"
    fi

    id=$(jq -r .id <<<"$release")

    for file in "$assets"/*; do
      name=$(basename "$file")

      # Uploading a duplicate name would silently create a second asset.
      old=$(call GET "$api/releases/$id/assets" |
        jq -r --arg name "$name" '.[] | select(.name == $name) | .id')
      for asset_id in $old; do
        call DELETE "$api/releases/$id/assets/$asset_id" >/dev/null
      done

      call POST "$api/releases/$id/assets?name=$name" \
        -F "attachment=@$file" >/dev/null
      echo "uploaded $name"
    done
  '';
};

The auth header is Gitea’s Authorization: token <t>, not Bearer. Posting an asset name that already exists creates a second asset with the same name rather than replacing it, so a rerun has to delete first. Uploading whatever dist/ holds, rather than a written-out list, means a new target needs no change here.

The Forgejo Actions workflow:

name: Release

on:
  push:
    tags: ["v*"]

jobs:
  verify:
    runs-on: nixos
    steps:
      - uses: actions/checkout@v4

      - name: Tag matches crate version
        run: nix develop -c just check-version "${{ github.ref_name }}"

      - name: CI checks
        run: nix develop -c just ci

  release:
    needs: verify
    runs-on: nixos # https://git.shine.town/infra/runners
    steps:
      - uses: actions/checkout@v4

      - name: Build static binaries
        run: nix develop -c just dist "${{ github.ref_name }}"

      - name: Publish release
        env:
          FORGEJO_URL: ${{ github.server_url }}
          FORGEJO_REPO: ${{ github.repository }}
          FORGEJO_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
        run: nix run .#forgejo-release -- "${{ github.ref_name }}" dist

check-version runs before the check suite so a mistyped tag fails in seconds rather than after the tests. secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN falls back to the automatic job token, so a secret is only needed on an instance where that token lacks the scope. Mine needed a PAT for pushing to the package registry, so I left the escape hatch in.

runs-on: nixos is a Nix-based runner image, explained in A poor man’s Nix cache.

One Forgejo setting thing that makes the consuming side work without credentials:

service = {
  DISABLE_REGISTRATION = true;
  REQUIRE_SIGNIN_VIEW = false;
  ENABLE_INTERNAL_SIGNIN = false;
};

REQUIRE_SIGNIN_VIEW = false is what lets pkgs.fetchurl reach a release asset anonymously. If your instance is private, the fetch needs a netrc-file in nix.conf, and then the flake is no longer usable by anyone who has not configured it. I would rather have the binaries public and the source private than the other way around.

Made by Simon Shine·Last updated 5 November 2026